Skip to content

Commit 78c6107

Browse files
authored
feat: add customizable reactions sorting (#2289)
### 🎯 Goal Currently the order of reactions in the `ReactionsList` depends of the order of keys in the `reaction_counts` object returned by the backend. This leads to unstable reaction order, which can change on page reloads, or when the list of reactions changes. We want to provide a way to customize this behavior, and to have a stable order of reactions by default. ### 🛠 Implementation details Added a new `MessageContext` property: `sortReactions`. It accepts two reaction objects, and should return a positive/negative/zero numeric value, similar to the `Array.prototype.sort` method. The `sortReactions` prop can be passed both to `MessageList` and `VirtualizedMessageList`, as well as to the `Message` component directly. It will then be available via the `MessageContext`.
1 parent 0ef1ba5 commit 78c6107

File tree

16 files changed

+241
-43
lines changed

16 files changed

+241
-43
lines changed

docusaurus/docs/React/components/contexts/message-context.mdx

+8
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,14 @@ When true, show the reactions list component.
377377
| ------- |
378378
| boolean |
379379

380+
### sortReactions
381+
382+
Comparator function to sort reactions. Should have the same signature as an array's `sort` method.
383+
384+
| Type | Default |
385+
| -------------------------------------------------------- | ------------------ |
386+
| (this: ReactionSummary, that: ReactionSummary) => number | alphabetical order |
387+
380388
### threadList
381389

382390
If true, indicates that the current `MessageList` component is part of a `Thread`.

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

+27-22
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ The default `remark` plugins used by SDK are:
7373
1. [`remark-gfm`](https://github.com/remarkjs/remark-gfm) - a third party plugin to add GitHub-like markdown support
7474

7575
The default `rehype` plugins (both specific to this SDK) are:
76+
7677
1. plugin to render user mentions
7778
2. plugin to render emojis
7879

@@ -166,56 +167,54 @@ If you would like to extend the array of plugins used to parse the markdown, you
166167
It is important to understand what constitutes a rehype or remark plugin. A good start is to learn about the library called [`react-remark`](https://github.com/remarkjs/react-remark) which is used under the hood in our `renderText()` function.
167168
:::
168169

169-
170170
```tsx
171171
import { renderText, RenderTextPluginConfigurator } from 'stream-chat-react';
172-
import {customRehypePlugin} from './rehypePlugins';
173-
import {customRemarkPlugin} from './remarkPlugins';
172+
import { customRehypePlugin } from './rehypePlugins';
173+
import { customRemarkPlugin } from './remarkPlugins';
174174

175175
const getRehypePlugins: RenderTextPluginConfigurator = (plugins) => {
176-
return [customRehypePlugin, ...plugins];
177-
}
176+
return [customRehypePlugin, ...plugins];
177+
};
178178
const getRemarkPlugins: RenderTextPluginConfigurator = (plugins) => {
179-
return [customRemarkPlugin, ...plugins];
180-
}
179+
return [customRemarkPlugin, ...plugins];
180+
};
181181

182182
const customRenderText = (text, mentionedUsers) =>
183183
renderText(text, mentionedUsers, {
184184
getRehypePlugins,
185-
getRemarkPlugins
185+
getRemarkPlugins,
186186
});
187187

188-
const CustomMessageList = () => (
189-
<MessageList renderText={customRenderText}/>
190-
);
188+
const CustomMessageList = () => <MessageList renderText={customRenderText} />;
191189
```
192190

193191
It is also possible to define your custom set of allowed tag names for the elements rendered from the parsed markdown. To perform the tree transformations, you will need to use libraries like [`unist-builder`](https://github.com/syntax-tree/unist-builder) to build the trees and [`unist-util-visit`](https://github.com/syntax-tree/unist-util-visit-parents) or [`hast-util-find-and-replace`](https://github.com/syntax-tree/hast-util-find-and-replace) to traverse the tree:
194192

195193
```tsx
196194
import { findAndReplace } from 'hast-util-find-and-replace';
197195
import { u } from 'unist-builder';
198-
import { defaultAllowedTagNames, renderText, RenderTextPluginConfigurator } from 'stream-chat-react';
196+
import {
197+
defaultAllowedTagNames,
198+
renderText,
199+
RenderTextPluginConfigurator,
200+
} from 'stream-chat-react';
199201

200202
// wraps every letter b in <xxx></xxx> tags
201203
const customTagName = 'xxx';
202204
const replace = (match) => u('element', { tagName: customTagName }, [u('text', match)]);
203205
const customRehypePlugin = () => (tree) => findAndReplace(tree, /b/, replace);
204206

205207
const getRehypePlugins: RenderTextPluginConfigurator = (plugins) => {
206-
return [customRehypePlugin, ...plugins];
207-
}
208-
208+
return [customRehypePlugin, ...plugins];
209+
};
209210

210211
const customRenderText = (text, mentionedUsers) =>
211212
renderText(text, mentionedUsers, {
212213
allowedTagNames: [...defaultAllowedTagNames, customTagName],
213214
getRehypePlugins,
214215
});
215216

216-
const CustomMessageList = () => (
217-
<MessageList renderText={customRenderText}/>
218-
);
217+
const CustomMessageList = () => <MessageList renderText={customRenderText} />;
219218
```
220219

221220
#### Custom message list rendering
@@ -233,9 +232,7 @@ const customRenderMessages: MessageRenderer<StreamChatGenerics> = (options) => {
233232
return elements;
234233
};
235234

236-
const CustomMessageList = () => (
237-
<MessageList renderMessages={customRenderMessages}/>
238-
);
235+
const CustomMessageList = () => <MessageList renderMessages={customRenderMessages} />;
239236
```
240237

241238
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.
@@ -621,9 +618,17 @@ The floating notification informing about unread messages will be shown when the
621618
is shown only when viewing unread messages.
622619

623620
| Type | Default |
624-
|---------|---------|
621+
| ------- | ------- |
625622
| boolean | false |
626623

624+
### sortReactions
625+
626+
Comparator function to sort reactions. Should have the same signature as the `sort` method for a string array.
627+
628+
| Type | Default |
629+
| -------------------------------------------------------- | ------------------ |
630+
| (this: ReactionSummary, that: ReactionSummary) => number | alphabetical order |
631+
627632
### threadList
628633

629634
If true, indicates that the current `MessageList` component is part of a `Thread`.

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

+9-1
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ The floating notification informing about unread messages will be shown when the
252252
is shown only when viewing unread messages.
253253

254254
| Type | Default |
255-
|---------|---------|
255+
| ------- | ------- |
256256
| boolean | false |
257257

258258
### stickToBottomScrollBehavior
@@ -264,6 +264,14 @@ The scroll-to behavior when new messages appear. Use `'smooth'` for regular chat
264264
| ------------------ | -------- |
265265
| 'smooth' \| 'auto' | 'smooth' |
266266

267+
### sortReactions
268+
269+
Comparator function to sort reactions. Should have the same signature as an array's `sort` method.
270+
271+
| Type | Default |
272+
| -------------------------------------------------------- | ------------------ |
273+
| (this: ReactionSummary, that: ReactionSummary) => number | alphabetical order |
274+
267275
### threadList
268276

269277
If true, indicates that the current `VirtualizedMessageList` component is part of a `Thread`.

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

+8
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,14 @@ Custom action handler to retry sending a message after a failed request.
363363
| -------- | -------------------------------------------------------------------------------------------------------- |
364364
| function | [ChannelActionContextValue['retrySendMessage']](../contexts/channel-action-context.mdx#retrysendmessage) |
365365

366+
### sortReactions
367+
368+
Comparator function to sort reactions. Should have the same signature as the `sort` method for a string array.
369+
370+
| Type | Default |
371+
| -------------------------------------------------------- | ------------------ |
372+
| (this: ReactionSummary, that: ReactionSummary) => number | alphabetical order |
373+
366374
### threadList
367375

368376
If true, indicates that the current `MessageList` component is part of a `Thread`.

docusaurus/docs/React/components/message-components/reactions.mdx

+33
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,31 @@ const CustomReactionsList = (props) => {
119119
</Chat>;
120120
```
121121

122+
## Sorting reactions
123+
124+
By default, reactions are sorted alphabetically by type. You can change this behavior by passing a `sortReactions` prop to the `MessageList` (or `VirtualizedMessageList`).
125+
126+
In this example, we sort the reactions in the descending order by the number of users:
127+
128+
```jsx
129+
function sortByReactionCount(a, b) {
130+
return b.reactionCount - a.reactionCount;
131+
}
132+
133+
<Chat client={client}>
134+
<Channel
135+
channel={channel}
136+
ReactionSelector={CustomReactionSelector}
137+
ReactionsList={CustomReactionsList}
138+
>
139+
<MessageList sortReactions={sortByReactionCount} />
140+
<MessageInput />
141+
</Channel>
142+
</Chat>;
143+
```
144+
145+
For better performance, keep this function memoized with `useCallback`, or declare it in either global or module scope.
146+
122147
## ReactionSelector Props
123148

124149
### additionalEmojiProps (removed in `11.0.0`)
@@ -276,6 +301,14 @@ If true, adds a CSS class that reverses the horizontal positioning of the select
276301
| ------- | ------- |
277302
| boolean | false |
278303

304+
### sortReactions
305+
306+
Comparator function to sort reactions. Should have the same signature as an array's `sort` method. This prop overrides the function stored in `MessageContext`.
307+
308+
| Type | Default |
309+
| -------------------------------------------------------- | ------------------ |
310+
| (this: ReactionSummary, that: ReactionSummary) => number | alphabetical order |
311+
279312
## SimpleReactionsList Props
280313

281314
### additionalEmojiProps (removed in `11.0.0`)

src/components/Message/Message.tsx

+4-1
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ type MessageContextPropsToPick =
5050
| 'onMentionsHoverMessage'
5151
| 'onReactionListClick'
5252
| 'reactionSelectorRef'
53-
| 'showDetailedReactions';
53+
| 'showDetailedReactions'
54+
| 'sortReactions';
5455

5556
type MessageWithContextProps<
5657
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
@@ -207,6 +208,7 @@ export const Message = <
207208
openThread: propOpenThread,
208209
pinPermissions,
209210
retrySendMessage: propRetrySendMessage,
211+
sortReactions,
210212
} = props;
211213

212214
const { addNotification } = useChannelActionContext<StreamChatGenerics>('Message');
@@ -308,6 +310,7 @@ export const Message = <
308310
readBy={props.readBy}
309311
renderText={props.renderText}
310312
showDetailedReactions={showDetailedReactions}
313+
sortReactions={sortReactions}
311314
threadList={props.threadList}
312315
unsafeHTML={props.unsafeHTML}
313316
userRoles={userRoles}

src/components/Message/types.ts

+3
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { MessageActionsArray } from './utils';
66

77
import type { GroupStyle } from '../MessageList/utils';
88
import type { MessageInputProps } from '../MessageInput/MessageInput';
9+
import type { ReactionsComparator } from '../Reactions/types';
910

1011
import type { ChannelActionContextValue } from '../../context/ChannelActionContext';
1112
import type { StreamMessage } from '../../context/ChannelStateContext';
@@ -97,6 +98,8 @@ export type MessageProps<
9798
) => JSX.Element | null;
9899
/** Custom retry send message handler to override default in [ChannelActionContext](https://getstream.io/chat/docs/sdk/react/contexts/channel_action_context/) */
99100
retrySendMessage?: ChannelActionContextValue<StreamChatGenerics>['retrySendMessage'];
101+
/** Comparator function to sort reactions, defaults to alphabetical order */
102+
sortReactions?: ReactionsComparator;
100103
/** Whether the Message is in a Thread */
101104
threadList?: boolean;
102105
/** render HTML instead of markdown. Posting HTML is only allowed server-side */

src/components/MessageList/MessageList.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,7 @@ type PropsDrilledToMessage =
291291
| 'pinPermissions' // @deprecated in favor of `channelCapabilities` - TODO: remove in next major release
292292
| 'renderText'
293293
| 'retrySendMessage'
294+
| 'sortReactions'
294295
| 'unsafeHTML';
295296

296297
export type MessageListProps<

src/components/MessageList/VirtualizedMessageList.tsx

+5-1
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ type VirtualizedMessageListPropsForContext =
6464
| 'Message'
6565
| 'messageActions'
6666
| 'shouldGroupByUser'
67+
| 'sortReactions'
6768
| 'threadList';
6869

6970
/**
@@ -194,6 +195,7 @@ const VirtualizedMessageListWithContext = <
194195
separateGiphyPreview = false,
195196
shouldGroupByUser = false,
196197
showUnreadNotificationAlways,
198+
sortReactions,
197199
stickToBottomScrollBehavior = 'smooth',
198200
suppressAutoscroll,
199201
threadList,
@@ -443,6 +445,7 @@ const VirtualizedMessageListWithContext = <
443445
ownMessagesReadByOthers,
444446
processedMessages,
445447
shouldGroupByUser,
448+
sortReactions,
446449
threadList,
447450
unreadMessageCount: channelUnreadUiState?.unread_messages,
448451
UnreadMessagesSeparator,
@@ -487,7 +490,8 @@ const VirtualizedMessageListWithContext = <
487490
type PropsDrilledToMessage =
488491
| 'additionalMessageInputProps'
489492
| 'customMessageActions'
490-
| 'messageActions';
493+
| 'messageActions'
494+
| 'sortReactions';
491495

492496
export type VirtualizedMessageListProps<
493497
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics

src/components/MessageList/VirtualizedMessageListComponents.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ export const messageRenderer = <
137137
ownMessagesReadByOthers,
138138
processedMessages: messageList,
139139
shouldGroupByUser,
140+
sortReactions,
140141
unreadMessageCount = 0,
141142
UnreadMessagesSeparator,
142143
virtuosoRef,
@@ -189,6 +190,7 @@ export const messageRenderer = <
189190
Message={MessageUIComponent}
190191
messageActions={messageActions}
191192
readBy={ownMessagesReadByOthers[message.id] || []}
193+
sortReactions={sortReactions}
192194
/>
193195
{showUnreadSeparator && (
194196
<div className='str-chat__unread-messages-separator-wrapper'>

src/components/Reactions/ReactionsList.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { useProcessReactions } from './hooks/useProcessReactions';
88
import type { ReactEventHandler } from '../Message/types';
99
import type { DefaultStreamChatGenerics } from '../../types/types';
1010
import type { ReactionOptions } from './reactionOptions';
11+
import type { ReactionsComparator } from './types';
1112
import { ReactionsListModal } from './ReactionsListModal';
1213
import { MessageContextValue, useTranslationContext } from '../../context';
1314
import { MAX_MESSAGE_REACTIONS_TO_FETCH } from '../Message/hooks';
@@ -27,6 +28,8 @@ export type ReactionsListProps<
2728
reactions?: ReactionResponse<StreamChatGenerics>[];
2829
/** Display the reactions in the list in reverse order, defaults to false */
2930
reverse?: boolean;
31+
/** Comparator function to sort reactions, defaults to alphabetical order */
32+
sortReactions?: ReactionsComparator;
3033
};
3134

3235
const UnMemoizedReactionsList = <

src/components/Reactions/__tests__/ReactionsList.test.js

+45
Original file line numberDiff line numberDiff line change
@@ -134,4 +134,49 @@ describe('ReactionsList', () => {
134134
),
135135
).not.toBeInTheDocument();
136136
});
137+
138+
it('should order reactions alphabetically by default', () => {
139+
const { getByTestId } = renderComponent({
140+
reaction_counts: {
141+
haha: 2,
142+
like: 8,
143+
love: 5,
144+
},
145+
});
146+
147+
expect(
148+
getByTestId('reactions-list-button-haha').compareDocumentPosition(
149+
getByTestId('reactions-list-button-like'),
150+
),
151+
).toStrictEqual(Node.DOCUMENT_POSITION_FOLLOWING);
152+
153+
expect(
154+
getByTestId('reactions-list-button-like').compareDocumentPosition(
155+
getByTestId('reactions-list-button-love'),
156+
),
157+
).toStrictEqual(Node.DOCUMENT_POSITION_FOLLOWING);
158+
});
159+
160+
it('should use custom comparator if provided', () => {
161+
const { getByTestId } = renderComponent({
162+
reaction_counts: {
163+
haha: 2,
164+
like: 8,
165+
love: 5,
166+
},
167+
sortReactions: (a, b) => b.reactionCount - a.reactionCount,
168+
});
169+
170+
expect(
171+
getByTestId('reactions-list-button-like').compareDocumentPosition(
172+
getByTestId('reactions-list-button-love'),
173+
),
174+
).toStrictEqual(Node.DOCUMENT_POSITION_FOLLOWING);
175+
176+
expect(
177+
getByTestId('reactions-list-button-love').compareDocumentPosition(
178+
getByTestId('reactions-list-button-haha'),
179+
),
180+
).toStrictEqual(Node.DOCUMENT_POSITION_FOLLOWING);
181+
});
137182
});

0 commit comments

Comments
 (0)