diff --git a/docusaurus/docs/React/components/contexts/message-context.mdx b/docusaurus/docs/React/components/contexts/message-context.mdx index 0d8ac79623..35cce32689 100644 --- a/docusaurus/docs/React/components/contexts/message-context.mdx +++ b/docusaurus/docs/React/components/contexts/message-context.mdx @@ -376,21 +376,21 @@ When true, show the reactions list component. | ------- | | boolean | -### sortReactionDetails +### reactionDetailsSort -Comparator function to sort the list of reacted users. Should have the same signature as an array's `sort` method. +Sort options to provide to a reactions query. Affects the order of reacted users in the default reactions modal. -| Type | Default | -| ---------------------------------------------------------- | ------------------ | -| (this: ReactionResponse, that: ReactionResponse) => number | alphabetical order | +| Type | Default | +| ---------------------- | --------------------------- | +| { created_at: number } | reverse chronological order | ### sortReactions Comparator function to sort reactions. Should have the same signature as an array's `sort` method. -| Type | Default | -| -------------------------------------------------------- | ------------------ | -| (this: ReactionSummary, that: ReactionSummary) => number | alphabetical order | +| Type | Default | +| -------------------------------------------------------- | ------------------- | +| (this: ReactionSummary, that: ReactionSummary) => number | chronological order | ### threadList diff --git a/docusaurus/docs/React/components/core-components/message-list.mdx b/docusaurus/docs/React/components/core-components/message-list.mdx index e132fc2ada..e12632906d 100644 --- a/docusaurus/docs/React/components/core-components/message-list.mdx +++ b/docusaurus/docs/React/components/core-components/message-list.mdx @@ -533,21 +533,21 @@ is shown only when viewing unread messages. | ------- | ------- | | boolean | false | -### sortReactionDetails +### reactionDetailsSort -Comparator function to sort the list of reacted users. Should have the same signature as an array's `sort` method. +Sort options to provide to a reactions query. Affects the order of reacted users in the default reactions modal. -| Type | Default | -| ---------------------------------------------------------- | ------------------ | -| (this: ReactionResponse, that: ReactionResponse) => number | alphabetical order | +| Type | Default | +| ---------------------- | --------------------------- | +| { created_at: number } | reverse chronological order | ### sortReactions Comparator function to sort reactions. Should have the same signature as the `sort` method for a string array. -| Type | Default | -| -------------------------------------------------------- | ------------------ | -| (this: ReactionSummary, that: ReactionSummary) => number | alphabetical order | +| Type | Default | +| -------------------------------------------------------- | ------------------- | +| (this: ReactionSummary, that: ReactionSummary) => number | chronological order | ### threadList diff --git a/docusaurus/docs/React/components/core-components/virtualized-list.mdx b/docusaurus/docs/React/components/core-components/virtualized-list.mdx index ba810983fa..0a050e189c 100644 --- a/docusaurus/docs/React/components/core-components/virtualized-list.mdx +++ b/docusaurus/docs/React/components/core-components/virtualized-list.mdx @@ -327,21 +327,21 @@ The scroll-to behavior when new messages appear. Use `'smooth'` for regular chat | ------------------ | -------- | | 'smooth' \| 'auto' | 'smooth' | -### sortReactionDetails +### reactionDetailsSort -Comparator function to sort the list of reacted users. Should have the same signature as an array's `sort` method. +Sort options to provide to a reactions query. Affects the order of reacted users in the default reactions modal. -| Type | Default | -| ---------------------------------------------------------- | ------------------ | -| (this: ReactionResponse, that: ReactionResponse) => number | alphabetical order | +| Type | Default | +| ---------------------- | --------------------------- | +| { created_at: number } | reverse chronological order | ### sortReactions Comparator function to sort reactions. Should have the same signature as an array's `sort` method. -| Type | Default | -| -------------------------------------------------------- | ------------------ | -| (this: ReactionSummary, that: ReactionSummary) => number | alphabetical order | +| Type | Default | +| -------------------------------------------------------- | ------------------- | +| (this: ReactionSummary, that: ReactionSummary) => number | chronological order | ### threadList diff --git a/docusaurus/docs/React/components/message-components/message.mdx b/docusaurus/docs/React/components/message-components/message.mdx index a8d3ad4c3c..d55399970f 100644 --- a/docusaurus/docs/React/components/message-components/message.mdx +++ b/docusaurus/docs/React/components/message-components/message.mdx @@ -362,21 +362,21 @@ Custom action handler to retry sending a message after a failed request. | -------- | -------------------------------------------------------------------------------------------------------- | | function | [ChannelActionContextValue['retrySendMessage']](../contexts/channel-action-context.mdx#retrysendmessage) | -### sortReactionDetails +### reactionDetailsSort -Comparator function to sort the list of reacted users. Should have the same signature as an array's `sort` method. +Sort options to provide to a reactions query. Affects the order of reacted users in the default reactions modal. -| Type | Default | -| ---------------------------------------------------------- | ------------------ | -| (this: ReactionResponse, that: ReactionResponse) => number | alphabetical order | +| Type | Default | +| ---------------------- | --------------------------- | +| { created_at: number } | reverse chronological order | ### sortReactions Comparator function to sort reactions. Should have the same signature as the `sort` method for a string array. -| Type | Default | -| -------------------------------------------------------- | ------------------ | -| (this: ReactionSummary, that: ReactionSummary) => number | alphabetical order | +| Type | Default | +| -------------------------------------------------------- | ------------------- | +| (this: ReactionSummary, that: ReactionSummary) => number | chronological order | ### threadList diff --git a/docusaurus/docs/React/components/message-components/reactions.mdx b/docusaurus/docs/React/components/message-components/reactions.mdx index 94711b41e1..1f3a796131 100644 --- a/docusaurus/docs/React/components/message-components/reactions.mdx +++ b/docusaurus/docs/React/components/message-components/reactions.mdx @@ -31,7 +31,7 @@ The SDK comes with built-in support for adding reactions to messages. The compon ## Sorting reactions -By default, reactions are sorted alphabetically by type. You can change this behavior by passing the `sortReactions` prop to the `MessageList` (or `VirtualizedMessageList`). +By default, reactions are sorted chronologically by the first time reaction type was used. You can change this behavior by passing the `sortReactions` prop to the `MessageList` (or `VirtualizedMessageList`). In this example, we sort the reactions in the descending order by the number of users: @@ -52,9 +52,9 @@ function sortByReactionCount(a, b) { ; ``` -Similarly, the `sortReactionDetails` prop can be passed to the `MessageList` (or `VirtualizedMessageList`) to sort the list of reacted users. The default implementation used by the reactions list modal dialog sorts users alphabetically by name. +For better performance, keep the sorting function memoized with `useCallback`, or declare it in either global or module scope. -For better performance, keep the sorting functions memoized with `useCallback`, or declare it in either global or module scope. +Similarly, the `reactionDetailsSort` object can be passed to the `MessageList` (or `VirtualizedMessageList`) to sort the list of reacted users. The default implementation used by the reactions list modal dialog sorts users in the reverse chronological order of their reactions. ## Customization @@ -199,21 +199,21 @@ If true, adds a CSS class that reverses the horizontal positioning of the select | ------- | ------- | | boolean | false | -### sortReactionDetails +### reactionDetailsSort -Comparator function to sort the list of reacted users. Should have the same signature as an array's `sort` method. This prop overrides the function stored in `MessageContext`. +Sort options to provide to a reactions query. Affects the order of reacted users in the default reactions modal. This prop overrides the function stored in `MessageContext`. -| Type | Default | -| ---------------------------------------------------------- | ------------------ | -| (this: ReactionResponse, that: ReactionResponse) => number | alphabetical order | +| Type | Default | +| ---------------------- | --------------------------- | +| { created_at: number } | reverse chronological order | ### sortReactions Comparator function to sort reactions. Should have the same signature as an array's `sort` method. This prop overrides the function stored in `MessageContext`. -| Type | Default | -| -------------------------------------------------------- | ------------------ | -| (this: ReactionSummary, that: ReactionSummary) => number | alphabetical order | +| Type | Default | +| -------------------------------------------------------- | ------------------- | +| (this: ReactionSummary, that: ReactionSummary) => number | chronological order | ## SimpleReactionsList Props diff --git a/examples/typescript/src/App.tsx b/examples/typescript/src/App.tsx index 83c4401f3e..4bb8e4c9ad 100644 --- a/examples/typescript/src/App.tsx +++ b/examples/typescript/src/App.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { ChannelFilters, ChannelOptions, ChannelSort, StreamChat } from 'stream-chat'; +import { ChannelFilters, ChannelOptions, ChannelSort, StreamChat, UR } from 'stream-chat'; import { Chat, Channel, @@ -37,6 +37,8 @@ type StreamChatGenerics = { messageType: LocalMessageType; reactionType: LocalReactionType; userType: LocalUserType; + pollType: UR; + pollOptionType: UR; }; const chatClient = StreamChat.getInstance(apiKey); diff --git a/src/components/Message/Message.tsx b/src/components/Message/Message.tsx index 973fd25040..3b6ce5e6aa 100644 --- a/src/components/Message/Message.tsx +++ b/src/components/Message/Message.tsx @@ -50,6 +50,7 @@ type MessageContextPropsToPick = | 'onMentionsHoverMessage' | 'onReactionListClick' | 'reactionSelectorRef' + | 'reactionDetailsSort' | 'showDetailedReactions' | 'sortReactions' | 'sortReactionDetails'; @@ -208,6 +209,7 @@ export const Message = < onMentionsHover: propOnMentionsHover, openThread: propOpenThread, pinPermissions, + reactionDetailsSort, retrySendMessage: propRetrySendMessage, sortReactionDetails, sortReactions, @@ -308,6 +310,7 @@ export const Message = < onUserClick={props.onUserClick} onUserHover={props.onUserHover} pinPermissions={props.pinPermissions} + reactionDetailsSort={reactionDetailsSort} reactionSelectorRef={reactionSelectorRef} readBy={props.readBy} renderText={props.renderText} diff --git a/src/components/Message/hooks/__tests__/useReactionsFetcher.js b/src/components/Message/hooks/__tests__/useReactionsFetcher.js index 9a680c0f07..3fa3376ea4 100644 --- a/src/components/Message/hooks/__tests__/useReactionsFetcher.js +++ b/src/components/Message/hooks/__tests__/useReactionsFetcher.js @@ -6,11 +6,10 @@ import { ChatProvider } from '../../../../context/ChatContext'; import { generateChannel, generateMessage, getTestClient } from '../../../../mock-builders'; import { useReactionsFetcher } from '../useReactionsFetcher'; -async function renderUseReactionsFetcherHook(channel = generateChannel(), notificationOpts) { - const client = await getTestClient(); +function renderUseReactionsFetcherHook(client = getTestClient(), notificationOpts) { const wrapper = ({ children }) => ( - {children} + {children} ); @@ -23,42 +22,36 @@ async function renderUseReactionsFetcherHook(channel = generateChannel(), notifi describe('useReactionsFetcher custom hook', () => { afterEach(() => jest.clearAllMocks()); - it('should generate a function', async () => { - const fetchReactions = await renderUseReactionsFetcherHook(); + it('should generate a function', () => { + const fetchReactions = renderUseReactionsFetcherHook(); expect(typeof fetchReactions).toBe('function'); }); it('generated function should make a request to fetch reactions', async () => { - const getReactionsMock = jest.fn(() => Promise.resolve({ reactions: [] })); - const channel = generateChannel({ - getReactions: getReactionsMock, - }); - const fetchReactions = await renderUseReactionsFetcherHook(channel); + const queryReactionsMock = jest.fn(() => Promise.resolve({ reactions: [] })); + const client = getTestClient({ queryReactions: queryReactionsMock }); + const fetchReactions = renderUseReactionsFetcherHook(client); await fetchReactions(); - expect(getReactionsMock).toHaveBeenCalledTimes(1); + expect(queryReactionsMock).toHaveBeenCalledTimes(1); }); it('generated function should make paged requests to fetch reactions', async () => { - const getReactionsMock = jest + const queryReactionsMock = jest .fn() - .mockImplementationOnce(() => Promise.resolve({ reactions: Array(300) })) - .mockImplementationOnce(() => Promise.resolve({ reactions: [] })); - const channel = generateChannel({ - getReactions: getReactionsMock, - }); - const fetchReactions = await renderUseReactionsFetcherHook(channel); + .mockImplementationOnce(() => Promise.resolve({ next: '42', reactions: Array(20) })) + .mockImplementationOnce(() => Promise.resolve({ reactions: Array(20) })); + const client = getTestClient({ queryReactions: queryReactionsMock }); + const fetchReactions = renderUseReactionsFetcherHook(client); await fetchReactions(); - expect(getReactionsMock).toHaveBeenCalledTimes(2); + expect(queryReactionsMock).toHaveBeenCalledTimes(2); }); it('generated function should notify about errors', async () => { - const getReactionsMock = jest.fn(() => Promise.reject()); - const channel = generateChannel({ - getReactions: getReactionsMock, - }); + const queryReactionsMock = jest.fn(() => Promise.reject()); + const client = getTestClient({ queryReactions: queryReactionsMock }); const getErrorNotificationMock = jest.fn(() => 'Error message'); const notifyMock = jest.fn(); - const fetchReactions = await renderUseReactionsFetcherHook(channel, { + const fetchReactions = renderUseReactionsFetcherHook(client, { getErrorNotification: getErrorNotificationMock, notify: notifyMock, }); diff --git a/src/components/Message/hooks/useReactionsFetcher.ts b/src/components/Message/hooks/useReactionsFetcher.ts index 7db2212519..cbd50036cd 100644 --- a/src/components/Message/hooks/useReactionsFetcher.ts +++ b/src/components/Message/hooks/useReactionsFetcher.ts @@ -1,8 +1,9 @@ -import { StreamMessage, useChannelStateContext, useTranslationContext } from '../../../context'; +import { StreamMessage, useChatContext, useTranslationContext } from '../../../context'; import { DefaultStreamChatGenerics } from '../../../types/types'; -import { Channel, ReactionResponse } from 'stream-chat'; +import { ReactionResponse, ReactionSort, StreamChat } from 'stream-chat'; +import { ReactionType } from '../../Reactions/types'; -export const MAX_MESSAGE_REACTIONS_TO_FETCH = 1200; +export const MAX_MESSAGE_REACTIONS_TO_FETCH = 1000; type FetchMessageReactionsNotifications< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics @@ -17,13 +18,16 @@ export function useReactionsFetcher< message: StreamMessage, notifications: FetchMessageReactionsNotifications = {}, ) { - const { channel } = useChannelStateContext('useReactionFetcher'); + const { client } = useChatContext('useRectionsFetcher'); const { t } = useTranslationContext('useReactionFetcher'); const { getErrorNotification, notify } = notifications; - return async () => { + return async ( + reactionType?: ReactionType, + sort?: ReactionSort, + ) => { try { - return await fetchMessageReactions(channel, message.id); + return await fetchMessageReactions(client, message.id, reactionType, sort); } catch (e) { const errorMessage = getErrorNotification?.(message); notify?.(errorMessage || t('Error fetching reactions'), 'error'); @@ -34,23 +38,28 @@ export function useReactionsFetcher< async function fetchMessageReactions< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics ->(channel: Channel, messageId: string) { +>( + client: StreamChat, + messageId: string, + reactionType?: ReactionType, + sort?: ReactionSort, +) { const reactions: ReactionResponse[] = []; - const limit = 300; - let offset = 0; - const reactionsLimit = MAX_MESSAGE_REACTIONS_TO_FETCH; - let lastPageSize = limit; - - while (lastPageSize === limit && reactions.length < reactionsLimit) { - const response = await channel.getReactions(messageId, { - limit, - offset, - }); - lastPageSize = response.reactions.length; - if (lastPageSize > 0) { - reactions.push(...response.reactions); - } - offset += lastPageSize; + const limit = 25; + let next: string | undefined; + let hasNext = true; + + while (hasNext && reactions.length < MAX_MESSAGE_REACTIONS_TO_FETCH) { + const response = await client.queryReactions( + messageId, + reactionType ? { type: reactionType } : {}, + sort, + { limit, next }, + ); + + reactions.push(...response.reactions); + next = response.next; + hasNext = Boolean(next); } return reactions; diff --git a/src/components/Message/types.ts b/src/components/Message/types.ts index 27a732c215..961c92c192 100644 --- a/src/components/Message/types.ts +++ b/src/components/Message/types.ts @@ -1,5 +1,5 @@ import type { TFunction } from 'i18next'; -import type { UserResponse } from 'stream-chat'; +import type { ReactionSort, UserResponse } from 'stream-chat'; import type { PinPermissions, UserEventHandler } from './hooks'; import type { MessageActionsArray } from './utils'; @@ -88,6 +88,8 @@ export type MessageProps< openThread?: ChannelActionContextValue['openThread']; /** @deprecated in favor of `channelCapabilities - The user roles allowed to pin messages in various channel types */ pinPermissions?: PinPermissions; + /** Sort options to provide to a reactions query */ + reactionDetailsSort?: ReactionSort; /** A list of users that have read this Message if the message is the last one and was posted by my user */ readBy?: UserResponse[]; /** Custom function to render message text content, defaults to the renderText function: [utils](https://github.com/GetStream/stream-chat-react/blob/master/src/utils.ts) */ @@ -98,9 +100,11 @@ export type MessageProps< ) => JSX.Element | null; /** Custom retry send message handler to override default in [ChannelActionContext](https://getstream.io/chat/docs/sdk/react/contexts/channel_action_context/) */ retrySendMessage?: ChannelActionContextValue['retrySendMessage']; - /** Comparator function to sort the list of reacted users, defaults to alphabetical order */ + /** Comparator function to sort the list of reacted users + * @deprecated use `reactionDetailsSort` instead + */ sortReactionDetails?: ReactionDetailsComparator; - /** Comparator function to sort reactions, defaults to alphabetical order */ + /** Comparator function to sort reactions, defaults to chronological order */ sortReactions?: ReactionsComparator; /** Whether the Message is in a Thread */ threadList?: boolean; diff --git a/src/components/MessageList/MessageList.tsx b/src/components/MessageList/MessageList.tsx index ef62395433..6642b22919 100644 --- a/src/components/MessageList/MessageList.tsx +++ b/src/components/MessageList/MessageList.tsx @@ -80,7 +80,10 @@ const MessageListWithContext = < loadMore: loadMoreCallback, loadMoreNewer: loadMoreNewerCallback, hasMoreNewer = false, + reactionDetailsSort, showUnreadNotificationAlways, + sortReactionDetails, + sortReactions, suppressAutoscroll, highlightedMessageId, jumpToLatestMessage = () => Promise.resolve(), @@ -167,8 +170,11 @@ const MessageListWithContext = < onUserHover: props.onUserHover, openThread: props.openThread, pinPermissions, + reactionDetailsSort, renderText: props.renderText, retrySendMessage: props.retrySendMessage, + sortReactionDetails, + sortReactions, unsafeHTML, }, messageGroupStyles, @@ -296,6 +302,7 @@ type PropsDrilledToMessage = | 'onUserHover' | 'openThread' | 'pinPermissions' // @deprecated in favor of `channelCapabilities` - TODO: remove in next major release + | 'reactionDetailsSort' | 'renderText' | 'retrySendMessage' | 'sortReactions' diff --git a/src/components/MessageList/VirtualizedMessageList.tsx b/src/components/MessageList/VirtualizedMessageList.tsx index c09a9437a6..7a8f1366ee 100644 --- a/src/components/MessageList/VirtualizedMessageList.tsx +++ b/src/components/MessageList/VirtualizedMessageList.tsx @@ -71,6 +71,7 @@ type VirtualizedMessageListPropsForContext = | 'Message' | 'messageActions' | 'shouldGroupByUser' + | 'reactionDetailsSort' | 'sortReactions' | 'sortReactionDetails' | 'threadList'; @@ -205,6 +206,7 @@ const VirtualizedMessageListWithContext = < separateGiphyPreview = false, shouldGroupByUser = false, showUnreadNotificationAlways, + reactionDetailsSort, sortReactionDetails, sortReactions, stickToBottomScrollBehavior = 'smooth', @@ -459,6 +461,7 @@ const VirtualizedMessageListWithContext = < numItemsPrepended, ownMessagesReadByOthers, processedMessages, + reactionDetailsSort, shouldGroupByUser, sortReactionDetails, sortReactions, @@ -507,6 +510,7 @@ type PropsDrilledToMessage = | 'additionalMessageInputProps' | 'customMessageActions' | 'messageActions' + | 'reactionDetailsSort' | 'sortReactions' | 'sortReactionDetails'; diff --git a/src/components/MessageList/VirtualizedMessageListComponents.tsx b/src/components/MessageList/VirtualizedMessageListComponents.tsx index a2180baa2c..d66e658988 100644 --- a/src/components/MessageList/VirtualizedMessageListComponents.tsx +++ b/src/components/MessageList/VirtualizedMessageListComponents.tsx @@ -137,6 +137,7 @@ export const messageRenderer = < numItemsPrepended, ownMessagesReadByOthers, processedMessages: messageList, + reactionDetailsSort, shouldGroupByUser, sortReactionDetails, sortReactions, @@ -219,6 +220,7 @@ export const messageRenderer = < message={message} Message={MessageUIComponent} messageActions={messageActions} + reactionDetailsSort={reactionDetailsSort} readBy={ownMessagesReadByOthers[message.id] || []} sortReactionDetails={sortReactionDetails} sortReactions={sortReactions} diff --git a/src/components/Reactions/ReactionsList.tsx b/src/components/Reactions/ReactionsList.tsx index 2c0eb72ec8..e251973eac 100644 --- a/src/components/Reactions/ReactionsList.tsx +++ b/src/components/Reactions/ReactionsList.tsx @@ -8,14 +8,16 @@ import { useProcessReactions } from './hooks/useProcessReactions'; import type { ReactEventHandler } from '../Message/types'; import type { DefaultStreamChatGenerics } from '../../types/types'; import type { ReactionOptions } from './reactionOptions'; -import type { ReactionDetailsComparator, ReactionsComparator } from './types'; +import type { ReactionDetailsComparator, ReactionsComparator, ReactionType } from './types'; import { ReactionsListModal } from './ReactionsListModal'; import { MessageContextValue, useTranslationContext } from '../../context'; import { MAX_MESSAGE_REACTIONS_TO_FETCH } from '../Message/hooks'; export type ReactionsListProps< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics -> = Partial, 'handleFetchReactions'>> & { +> = Partial< + Pick, 'handleFetchReactions' | 'reactionDetailsSort'> +> & { /** Custom on click handler for an individual reaction, defaults to `onReactionListClick` from the `MessageContext` */ onClick?: ReactEventHandler; /** An array of the own reaction objects to distinguish own reactions visually */ @@ -33,9 +35,11 @@ export type ReactionsListProps< reactions?: ReactionResponse[]; /** Display the reactions in the list in reverse order, defaults to false */ reverse?: boolean; - /** Comparator function to sort the list of reacted users, defaults to alphabetical order */ + /** Comparator function to sort the list of reacted users + * @deprecated use `reactionDetailsSort` instead + */ sortReactionDetails?: ReactionDetailsComparator; - /** Comparator function to sort reactions, defaults to alphabetical order */ + /** Comparator function to sort reactions, defaults to chronological order */ sortReactions?: ReactionsComparator; }; @@ -44,9 +48,18 @@ const UnMemoizedReactionsList = < >( props: ReactionsListProps, ) => { - const { handleFetchReactions, reverse = false, sortReactionDetails, ...rest } = props; + const { + handleFetchReactions, + reactionDetailsSort, + reverse = false, + sortReactionDetails, + ...rest + } = props; const { existingReactions, hasReactions, totalReactionCount } = useProcessReactions(rest); - const [selectedReactionType, setSelectedReactionType] = useState(null); + const [ + selectedReactionType, + setSelectedReactionType, + ] = useState | null>(null); const { t } = useTranslationContext('ReactionsList'); const handleReactionButtonClick = (reactionType: string) => { @@ -54,7 +67,7 @@ const UnMemoizedReactionsList = < return; } - setSelectedReactionType(reactionType); + setSelectedReactionType(reactionType as ReactionType); }; if (!hasReactions) return null; @@ -104,15 +117,17 @@ const UnMemoizedReactionsList = < - setSelectedReactionType(null)} - onSelectedReactionTypeChange={setSelectedReactionType} - open={selectedReactionType !== null} - reactions={existingReactions} - selectedReactionType={selectedReactionType} - sortReactionDetails={sortReactionDetails} - /> + {selectedReactionType !== null && ( + setSelectedReactionType(null)} + onSelectedReactionTypeChange={setSelectedReactionType} + open={selectedReactionType !== null} + reactions={existingReactions} + selectedReactionType={selectedReactionType} + sortReactionDetails={sortReactionDetails} + /> + )} ); }; diff --git a/src/components/Reactions/ReactionsListModal.tsx b/src/components/Reactions/ReactionsListModal.tsx index 8cde32da1a..f9021dceb7 100644 --- a/src/components/Reactions/ReactionsListModal.tsx +++ b/src/components/Reactions/ReactionsListModal.tsx @@ -1,7 +1,7 @@ import React, { useMemo } from 'react'; import clsx from 'clsx'; -import type { ReactionDetailsComparator, ReactionSummary } from './types'; +import type { ReactionDetailsComparator, ReactionSummary, ReactionType } from './types'; import { Modal, ModalProps } from '../Modal'; import { useFetchReactions } from './hooks/useFetchReactions'; @@ -9,55 +9,63 @@ import { LoadingIndicator } from '../Loading'; import { Avatar } from '../Avatar'; import { MessageContextValue, useMessageContext } from '../../context'; import { DefaultStreamChatGenerics } from '../../types/types'; +import { ReactionSort } from 'stream-chat'; type ReactionsListModalProps< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics > = ModalProps & - Partial, 'handleFetchReactions'>> & { + Partial< + Pick, 'handleFetchReactions' | 'reactionDetailsSort'> + > & { reactions: ReactionSummary[]; - selectedReactionType: string | null; - onSelectedReactionTypeChange?: (reactionType: string) => void; - sortReactionDetails?: ReactionDetailsComparator; + selectedReactionType: ReactionType; + onSelectedReactionTypeChange?: (reactionType: ReactionType) => void; + sort?: ReactionSort; + /** @deprecated use `sort` instead */ + sortReactionDetails?: ReactionDetailsComparator; }; -const defaultSortReactionDetails: ReactionDetailsComparator = (a, b) => { - const aName = a.user?.name ?? a.user?.id; - const bName = b.user?.name ?? b.user?.id; - return aName ? (bName ? aName.localeCompare(bName, 'en') : -1) : 1; -}; +const defaultReactionDetailsSort = { created_at: -1 } as const; -export function ReactionsListModal({ +export function ReactionsListModal< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>({ handleFetchReactions, onSelectedReactionTypeChange, + reactionDetailsSort: propReactionDetailsSort, reactions, selectedReactionType, sortReactionDetails: propSortReactionDetails, ...modalProps -}: ReactionsListModalProps) { +}: ReactionsListModalProps) { const selectedReaction = reactions.find( ({ reactionType }) => reactionType === selectedReactionType, ); const SelectedEmojiComponent = selectedReaction?.EmojiComponent ?? null; - const { isLoading: areReactionsLoading, reactions: allReactions } = useFetchReactions({ + const { + reactionDetailsSort: contextReactionDetailsSort, + sortReactionDetails: contextSortReactionDetails, + } = useMessageContext('ReactionsListModal'); + const legacySortReactionDetails = propSortReactionDetails ?? contextSortReactionDetails; + const reactionDetailsSort = + propReactionDetailsSort ?? contextReactionDetailsSort ?? defaultReactionDetailsSort; + const { + isLoading: areReactionsLoading, + reactions: reactionDetails, + } = useFetchReactions({ handleFetchReactions, + reactionType: selectedReactionType, shouldFetch: modalProps.open, + sort: reactionDetailsSort, }); - const { sortReactionDetails: contextSortReactionDetails } = useMessageContext( - 'ReactionsListModal', - ); - const sortReactionDetails = - propSortReactionDetails ?? contextSortReactionDetails ?? defaultSortReactionDetails; - const currentReactions = useMemo(() => { - if (!selectedReactionType) { - return []; - } - const unsortedCurrentReactions = allReactions.filter( - (reaction) => reaction.type === selectedReactionType && reaction.user, - ); - - return unsortedCurrentReactions.sort(sortReactionDetails); - }, [allReactions, selectedReactionType, sortReactionDetails]); + const reactionDetailsWithLegacyFallback = useMemo( + () => + legacySortReactionDetails + ? [...reactionDetails].sort(legacySortReactionDetails) + : reactionDetails, + [legacySortReactionDetails, reactionDetails], + ); return ( @@ -73,7 +81,9 @@ export function ReactionsListModal({ })} data-testid={`reaction-details-selector-${reactionType}`} key={reactionType} - onClick={() => onSelectedReactionTypeChange?.(reactionType)} + onClick={() => + onSelectedReactionTypeChange?.(reactionType as ReactionType) + } > @@ -96,7 +106,7 @@ export function ReactionsListModal({ {areReactionsLoading ? ( ) : ( - currentReactions.map(({ user }) => ( + reactionDetailsWithLegacyFallback.map(({ user }) => (
{ love: { count: 5 }, }; const reactions = generateReactionsFromReactionGroups(reactionGroups); - const fetchReactions = jest.fn(() => Promise.resolve(reactions)); + const fetchReactions = jest.fn((type) => + Promise.resolve(reactions.filter((r) => r.type === type)), + ); const { container, getAllByTestId, getByTestId } = renderComponent({ handleFetchReactions: fetchReactions, @@ -181,31 +183,6 @@ describe('ReactionsListModal', () => { ).toStrictEqual(Node.DOCUMENT_POSITION_FOLLOWING); }); - it('should order reacted users alphabetically by default', async () => { - const reactionGroups = { - haha: { count: 3 }, - }; - const reactions = generateReactionsFromReactionGroups(reactionGroups).reverse(); - const fetchReactions = jest.fn(() => Promise.resolve(reactions)); - const { getByTestId, getByText } = renderComponent({ - handleFetchReactions: fetchReactions, - reaction_groups: reactionGroups, - reactions, - }); - - await act(() => { - fireEvent.click(getByTestId('reactions-list-button-haha')); - }); - - expect( - getByText('Mark Number 0').compareDocumentPosition(getByText('Mark Number 1')), - ).toStrictEqual(Node.DOCUMENT_POSITION_FOLLOWING); - - expect( - getByText('Mark Number 1').compareDocumentPosition(getByText('Mark Number 2')), - ).toStrictEqual(Node.DOCUMENT_POSITION_FOLLOWING); - }); - it('should use custom reaction details comparator if provided', async () => { const reactionGroups = { haha: { count: 3 }, diff --git a/src/components/Reactions/hooks/useFetchReactions.ts b/src/components/Reactions/hooks/useFetchReactions.ts index 9274d33b6f..0070ce91ca 100644 --- a/src/components/Reactions/hooks/useFetchReactions.ts +++ b/src/components/Reactions/hooks/useFetchReactions.ts @@ -1,23 +1,31 @@ import { useEffect, useState } from 'react'; -import { ReactionResponse } from 'stream-chat'; +import { ReactionResponse, ReactionSort } from 'stream-chat'; import { MessageContextValue, useMessageContext } from '../../../context'; import { DefaultStreamChatGenerics } from '../../../types/types'; +import { ReactionType } from '../types'; export interface FetchReactionsOptions< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics > { + reactionType: ReactionType; shouldFetch: boolean; handleFetchReactions?: MessageContextValue['handleFetchReactions']; + sort?: ReactionSort; } export function useFetchReactions< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics ->(options: FetchReactionsOptions) { +>(options: FetchReactionsOptions) { const { handleFetchReactions: contextHandleFetchReactions, } = useMessageContext('useFetchReactions'); - const [reactions, setReactions] = useState([]); - const { handleFetchReactions: propHandleFetchReactions, shouldFetch } = options; + const [reactions, setReactions] = useState[]>([]); + const { + handleFetchReactions: propHandleFetchReactions, + reactionType, + shouldFetch, + sort, + } = options; const [isLoading, setIsLoading] = useState(shouldFetch); const handleFetchReactions = propHandleFetchReactions ?? contextHandleFetchReactions; @@ -31,7 +39,7 @@ export function useFetchReactions< (async () => { try { setIsLoading(true); - const reactions = await handleFetchReactions(); + const reactions = await handleFetchReactions(reactionType, sort); if (!cancel) { setReactions(reactions); @@ -50,7 +58,7 @@ export function useFetchReactions< return () => { cancel = true; }; - }, [handleFetchReactions, shouldFetch]); + }, [handleFetchReactions, reactionType, shouldFetch, sort]); return { isLoading, reactions }; } diff --git a/src/components/Reactions/types.ts b/src/components/Reactions/types.ts index 12891500c6..030bcc259e 100644 --- a/src/components/Reactions/types.ts +++ b/src/components/Reactions/types.ts @@ -1,5 +1,6 @@ import type { ComponentType } from 'react'; -import type { ReactionResponse } from 'stream-chat'; +import type { DefaultGenerics, ExtendableGenerics, ReactionResponse } from 'stream-chat'; +import { DefaultStreamChatGenerics } from '../../types'; export interface ReactionSummary { EmojiComponent: ComponentType | null; @@ -14,4 +15,10 @@ export interface ReactionSummary { export type ReactionsComparator = (a: ReactionSummary, b: ReactionSummary) => number; -export type ReactionDetailsComparator = (a: ReactionResponse, b: ReactionResponse) => number; +export type ReactionDetailsComparator< + StreamChatGenerics extends ExtendableGenerics = DefaultGenerics +> = (a: ReactionResponse, b: ReactionResponse) => number; + +export type ReactionType< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +> = ReactionResponse['type']; diff --git a/src/context/MessageContext.tsx b/src/context/MessageContext.tsx index f962460b9a..77ac4ed9bc 100644 --- a/src/context/MessageContext.tsx +++ b/src/context/MessageContext.tsx @@ -1,6 +1,6 @@ import React, { PropsWithChildren, useContext } from 'react'; -import type { Mute, ReactionResponse, UserResponse } from 'stream-chat'; +import type { Mute, ReactionResponse, ReactionSort, UserResponse } from 'stream-chat'; import type { ChannelActionContextValue } from './ChannelActionContext'; import type { StreamMessage } from './ChannelStateContext'; @@ -11,7 +11,11 @@ import type { ReactEventHandler } from '../components/Message/types'; import type { MessageActionsArray } from '../components/Message/utils'; import type { MessageInputProps } from '../components/MessageInput/MessageInput'; import type { GroupStyle } from '../components/MessageList/utils'; -import type { ReactionDetailsComparator, ReactionsComparator } from '../components/Reactions/types'; +import type { + ReactionDetailsComparator, + ReactionsComparator, + ReactionType, +} from '../components/Reactions/types'; import type { RenderTextOptions } from '../components/Message/renderText'; import type { DefaultStreamChatGenerics, UnknownType } from '../types/types'; @@ -46,7 +50,10 @@ export type MessageContextValue< /** Function to edit a message in a Channel */ handleEdit: ReactEventHandler; /** Function to fetch the message reactions */ - handleFetchReactions: () => Promise>>; + handleFetchReactions: ( + reactionType?: ReactionType, + sort?: ReactionSort, + ) => Promise>>; /** Function to flag a message in a Channel */ handleFlag: ReactEventHandler; /** Function to mark message and the messages that follow it as unread in a Channel */ @@ -115,6 +122,8 @@ export type MessageContextValue< mutes?: Mute[]; /** @deprecated in favor of `channelCapabilities - The user roles allowed to pin Messages in various channel types */ pinPermissions?: PinPermissions; + /** Sort options to provide to a reactions query */ + reactionDetailsSort?: ReactionSort; /** A list of users that have read this Message */ readBy?: UserResponse[]; /** Custom function to render message text content, defaults to the renderText function: [utils](https://github.com/GetStream/stream-chat-react/blob/master/src/utils.tsx) */ @@ -123,9 +132,11 @@ export type MessageContextValue< mentioned_users?: UserResponse[], options?: RenderTextOptions, ) => JSX.Element | null; - /** Comparator function to sort the list of reacted users, defaults to alphabetical order */ + /** Comparator function to sort the list of reacted users + * @deprecated use `reactionDetailsSort` instead + */ sortReactionDetails?: ReactionDetailsComparator; - /** Comparator function to sort reactions, defaults to alphabetical order */ + /** Comparator function to sort reactions, defaults to chronological order */ sortReactions?: ReactionsComparator; /** Whether or not the Message is in a Thread */ threadList?: boolean; diff --git a/src/mock-builders/index.js b/src/mock-builders/index.js index 67e350d504..1ecab6be1e 100644 --- a/src/mock-builders/index.js +++ b/src/mock-builders/index.js @@ -23,6 +23,7 @@ function mockClient(client, mocks = {}) { jest.spyOn(client, '_setupConnection').mockImplementation(); jest.spyOn(client, '_setupConnection').mockImplementation(); jest.spyOn(client, 'getAppSettings').mockImplementation(mocks.getAppSettings); + jest.spyOn(client, 'queryReactions').mockImplementation(mocks.queryReactions); client.tokenManager = { getToken: jest.fn(() => token), tokenReady: jest.fn(() => true),