diff --git a/docusaurus/docs/React/components/core-components/channel-list.mdx b/docusaurus/docs/React/components/core-components/channel-list.mdx index 7c58fa1a7c..52b7e6110e 100644 --- a/docusaurus/docs/React/components/core-components/channel-list.mdx +++ b/docusaurus/docs/React/components/core-components/channel-list.mdx @@ -110,7 +110,7 @@ re-setting the list state, you can customize behavior and UI. | `channel.truncated` | Updates the channel | [onChannelTruncated](#onchanneltruncated) | | `channel.updated` | Updates the channel | [onChannelUpdated](#onchannelupdated) | | `channel.visible` | Adds channel to list | [onChannelVisible](#onchannelvisible) | -| `connection.recovered` | Forces a component render | N/A | +| `connection.recovered` | Forces a component render | N/A | | `message.new` | Moves channel to top of list | [onMessageNewHandler](#onmessagenewhandler) | | `notification.added_to_channel` | Moves channel to top of list and starts watching | [onAddedToChannel](#onaddedtochannel) | | `notification.message_new` | Moves channel to top of list and starts watching | [onMessageNew](#onmessagenew) | @@ -225,13 +225,14 @@ Custom function that handles the channel pagination. Takes parameters: | Parameter | Description | -|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `currentChannels` | The state of loaded `Channel` objects queried thus far. Has to be set with `setChannels` (see below). | | `queryType` | A string indicating, whether the channels state has to be reset to the first page ('reload') or newly queried channels should be appended to the `currentChannels`. | | `setChannels` | Function that allows us to set the channels state reflected in `currentChannels`. | | `setHasNextPage` | Flag indicating whether there are more items to be loaded from the API. Should be infered from the comparison of the query result length and the query options limit. | The function has to: + 1. build / provide own query filters, sort and options parameters 2. query and append channels to the current channels state 3. update the `hasNext` pagination flag after each query with `setChannels` function @@ -239,14 +240,11 @@ The function has to: An example below implements a custom query function that uses different filters sequentially once a preceding filter is exhausted: ```ts -import uniqBy from "lodash.uniqby"; +import uniqBy from 'lodash.uniqby'; import throttle from 'lodash.throttle'; -import {useCallback, useRef} from 'react'; -import {ChannelFilters, ChannelOptions, ChannelSort, StreamChat} from 'stream-chat'; -import { - CustomQueryChannelParams, - useChatContext, -} from 'stream-chat-react'; +import { useCallback, useRef } from 'react'; +import { ChannelFilters, ChannelOptions, ChannelSort, StreamChat } from 'stream-chat'; +import { CustomQueryChannelParams, useChatContext } from 'stream-chat-react'; const DEFAULT_PAGE_SIZE = 30 as const; @@ -312,7 +310,7 @@ export const useCustomQueryChannels = () => { It is recommended to control for duplicate requests by throttling the custom function calls. | Type | -|---------------------------------------------------------------------------------------------------| +| ------------------------------------------------------------------------------------------------- | | | ### EmptyStateIndicator @@ -332,6 +330,14 @@ for more information. | ------ | | object | +### getLatestMessagePreview + +Custom function that generates the message preview in ChannelPreview component. + +| Type | +| ------------------------------------------------------------------------------------------------------------------------------------- | +| `(channel: Channel, t: TranslationContextValue['t'], userLanguage: TranslationContextValue['userLanguage']) => string \| JSX.Element` | + ### List Custom UI component to display the container for the queried channels. @@ -425,7 +431,7 @@ Function to override the default behavior when a message is received on a channe Function to override the default behavior when a message is received on a channel being watched. Handles `message.new` event. | Type | -|-------------------------------------------------------------------------------------------------------------------------------------| +| ----------------------------------------------------------------------------------------------------------------------------------- | | `(setChannels: React.Dispatch>>>, event: Event) => void` | ### onRemovedFromChannel @@ -491,7 +497,7 @@ const App = () => ( ``` | Type | Default | -|--------|---------| +| ------ | ------- | | number | 5000 | ### renderChannels diff --git a/docusaurus/docs/React/components/utility-components/channel-preview-ui.mdx b/docusaurus/docs/React/components/utility-components/channel-preview-ui.mdx index b71511a397..36ed82d36b 100644 --- a/docusaurus/docs/React/components/utility-components/channel-preview-ui.mdx +++ b/docusaurus/docs/React/components/utility-components/channel-preview-ui.mdx @@ -18,12 +18,12 @@ For even deeper customization of the channel list and channel previews, use the To customize the `ChannelList` item UI simply pass your custom `Preview` component to the `ChannelList`. See [The Preview Prop Component](../../guides/customization/channel-list-preview.mdx#the-preview-prop-component) for the extended guide. ```tsx -const CustomChannelPreviewUI = ({ latestMessage, lastMessage }) => { +const CustomChannelPreviewUI = ({ latestMessagePreview, lastMessage }) => { // "lastMessage" property is for the last // message that has been interacted with (pinned/edited/deleted) - // to display last message of the channel use "latestMessage" property - return {latestMessage}; + // to display last message of the channel use "latestMessagePreview" property + return {latestMessagePreview}; }; ; @@ -95,6 +95,14 @@ Title of channel to display. | -------- | | `string` | +### getLatestMessagePreview + +Custom function that generates the message preview in ChannelPreview component. + +| Type | +| ------------------------------------------------------------------------------------------------------------------------------------- | +| `(channel: Channel, t: TranslationContextValue['t'], userLanguage: TranslationContextValue['userLanguage']) => string \| JSX.Element` | + ### lastMessage The last message received in a channel. @@ -105,6 +113,14 @@ The last message received in a channel. ### latestMessage +Deprecated, use `latestMessagePreview` instead. + +| Type | +| ----------------------- | +| `string \| JSX.Element` | + +### latestMessagePreview + Latest message preview to display. Will be either a string or a JSX.Element rendering markdown. | Type | diff --git a/docusaurus/docs/React/components/utility-components/channel-preview.mdx b/docusaurus/docs/React/components/utility-components/channel-preview.mdx index be38ec6059..e39e6a3b87 100644 --- a/docusaurus/docs/React/components/utility-components/channel-preview.mdx +++ b/docusaurus/docs/React/components/utility-components/channel-preview.mdx @@ -55,6 +55,14 @@ Custom class for the channel preview root | -------- | | `string` | +### getLatestMessagePreview + +Custom function that generates the message preview in ChannelPreview component. + +| Type | +| ------------------------------------------------------------------------------------------------------------------------------------- | +| `(channel: Channel, t: TranslationContextValue['t'], userLanguage: TranslationContextValue['userLanguage']) => string \| JSX.Element` | + ### onSelect Custom handler invoked when the `ChannelPreview` is clicked. The SDK uses `ChannelPreview` to display items of channel search results. There, behind the scenes, the new active channel is set. diff --git a/docusaurus/docs/React/guides/customization/channel-list-preview.mdx b/docusaurus/docs/React/guides/customization/channel-list-preview.mdx index 445af9445a..93c556e045 100644 --- a/docusaurus/docs/React/guides/customization/channel-list-preview.mdx +++ b/docusaurus/docs/React/guides/customization/channel-list-preview.mdx @@ -55,12 +55,12 @@ Let's implement a simple custom preview: ```jsx -const CustomChannelPreview = ({ displayImage, displayTitle, latestMessage }) => ( +const CustomChannelPreview = ({ displayImage, displayTitle, latestMessagePreview }) => (
{displayTitle}
-
{latestMessage}
+
{latestMessagePreview}
); @@ -122,7 +122,7 @@ message in the channel: ```jsx const CustomChannelPreview = (props) => { - const { channel, displayImage, displayTitle, latestMessage } = props; + const { channel, displayImage, displayTitle, latestMessagePreview } = props; const { userLanguage } = useTranslationContext(); const latestMessageAt = channel.state.last_message_at; @@ -146,7 +146,7 @@ const CustomChannelPreview = (props) => { {timestamp} -
{latestMessage}
+
{latestMessagePreview}
); @@ -217,7 +217,7 @@ const CustomChannelPreview = (props) => { activeChannel, displayImage, displayTitle, - latestMessage, + latestMessagePreview, setActiveChannel, } = props; const latestMessageAt = channel.state.last_message_at; @@ -252,7 +252,7 @@ const CustomChannelPreview = (props) => { {timestamp} -
{latestMessage}
+
{latestMessagePreview}
); diff --git a/src/components/ChannelList/ChannelList.tsx b/src/components/ChannelList/ChannelList.tsx index 7b8c3066d6..0ce8c6075f 100644 --- a/src/components/ChannelList/ChannelList.tsx +++ b/src/components/ChannelList/ChannelList.tsx @@ -35,7 +35,7 @@ import { ChannelListContextProvider } from '../../context'; import { useChatContext } from '../../context/ChatContext'; import type { Channel, ChannelFilters, ChannelOptions, ChannelSort, Event } from 'stream-chat'; - +import type { TranslationContextValue } from '../../context/TranslationContext'; import type { DefaultStreamChatGenerics, PaginatorProps } from '../../types/types'; const DEFAULT_FILTERS = {}; @@ -70,6 +70,12 @@ export type ChannelListProps< EmptyStateIndicator?: React.ComponentType; /** An object containing channel query filters */ filters?: ChannelFilters; + /** Custom function that generates the message preview in ChannelPreview component */ + getLatestMessagePreview?: ( + channel: Channel, + t: TranslationContextValue['t'], + userLanguage: TranslationContextValue['userLanguage'], + ) => string | JSX.Element; /** Custom UI component to display the container for the queried channels, defaults to and accepts same props as: [ChannelListMessenger](https://github.com/GetStream/stream-chat-react/blob/master/src/components/ChannelList/ChannelListMessenger.tsx) */ List?: React.ComponentType>; /** Custom UI component to display the loading error indicator, defaults to and accepts same props as: [ChatDown](https://github.com/GetStream/stream-chat-react/blob/master/src/components/ChatDown/ChatDown.tsx) */ @@ -168,6 +174,7 @@ const UnMemoizedChannelList = < customQueryChannels, EmptyStateIndicator = DefaultEmptyStateIndicator, filters, + getLatestMessagePreview, LoadingErrorIndicator = ChatDown, LoadingIndicator = LoadingChannels, List = ChannelListMessenger, @@ -333,6 +340,7 @@ const UnMemoizedChannelList = < channel: item, // forces the update of preview component on channel update channelUpdateCount, + getLatestMessagePreview, key: item.cid, Preview, setActiveChannel, diff --git a/src/components/ChannelList/__tests__/ChannelList.test.js b/src/components/ChannelList/__tests__/ChannelList.test.js index d903ca077a..380d1e2186 100644 --- a/src/components/ChannelList/__tests__/ChannelList.test.js +++ b/src/components/ChannelList/__tests__/ChannelList.test.js @@ -60,11 +60,11 @@ const channelsQueryStateMock = { * to those components might end up breaking tests for ChannelList, which will be quite painful * to debug then. */ -const ChannelPreviewComponent = ({ channel, channelUpdateCount, latestMessage }) => ( +const ChannelPreviewComponent = ({ channel, channelUpdateCount, latestMessagePreview }) => (
{channelUpdateCount}
{channel.data.name}
-
{latestMessage}
+
{latestMessagePreview}
); @@ -457,6 +457,36 @@ describe('ChannelList', () => { }); }); + it('allows to customize latest message preview generation', async () => { + const previewText = 'custom preview text'; + const getLatestMessagePreview = () => previewText; + + useMockedApis(chatClient, [queryChannelsApi([testChannel1])]); + const { rerender } = render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText('Nothing yet...')).toBeInTheDocument(); + }); + + rerender( + + + , + ); + + await waitFor(() => { + expect(screen.getByText(previewText)).toBeInTheDocument(); + }); + }); + describe('Default and custom active channel', () => { let setActiveChannel; const watchersConfig = { limit: 20, offset: 0 }; diff --git a/src/components/ChannelPreview/ChannelPreview.tsx b/src/components/ChannelPreview/ChannelPreview.tsx index 8b17bc555b..d368ae3a4a 100644 --- a/src/components/ChannelPreview/ChannelPreview.tsx +++ b/src/components/ChannelPreview/ChannelPreview.tsx @@ -4,7 +4,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { ChannelPreviewMessenger } from './ChannelPreviewMessenger'; import { useIsChannelMuted } from './hooks/useIsChannelMuted'; import { useChannelPreviewInfo } from './hooks/useChannelPreviewInfo'; -import { getLatestMessagePreview } from './utils'; +import { getLatestMessagePreview as defaultGetLatestMessagePreview } from './utils'; import { ChatContextValue, useChatContext } from '../../context/ChatContext'; import { useTranslationContext } from '../../context/TranslationContext'; @@ -15,7 +15,7 @@ import type { Channel, Event } from 'stream-chat'; import type { AvatarProps } from '../Avatar/Avatar'; import type { StreamMessage } from '../../context/ChannelStateContext'; - +import type { TranslationContextValue } from '../../context/TranslationContext'; import type { DefaultStreamChatGenerics } from '../../types/types'; export type ChannelPreviewUIComponentProps< @@ -29,8 +29,10 @@ export type ChannelPreviewUIComponentProps< displayTitle?: string; /** The last message received in a channel */ lastMessage?: StreamMessage; - /** Latest message preview to display, will be a string or JSX element supporting markdown. */ + /** @deprecated Use latestMessagePreview prop instead. */ latestMessage?: string | JSX.Element; + /** Latest message preview to display, will be a string or JSX element supporting markdown. */ + latestMessagePreview?: string | JSX.Element; /** Status describing whether own message has been delivered or read by another. If the last message is not an own message, then the status is undefined. */ messageDeliveryStatus?: MessageDeliveryStatus; /** Number of unread Messages */ @@ -50,6 +52,12 @@ export type ChannelPreviewProps< channelUpdateCount?: number; /** Custom class for the channel preview root */ className?: string; + /** Custom function that generates the message preview in ChannelPreview component */ + getLatestMessagePreview?: ( + channel: Channel, + t: TranslationContextValue['t'], + userLanguage: TranslationContextValue['userLanguage'], + ) => string | JSX.Element; key?: string; /** Custom ChannelPreview click handler function */ onSelect?: (event: React.MouseEvent) => void; @@ -66,7 +74,12 @@ export const ChannelPreview = < >( props: ChannelPreviewProps, ) => { - const { channel, Preview = ChannelPreviewMessenger, channelUpdateCount } = props; + const { + channel, + Preview = ChannelPreviewMessenger, + channelUpdateCount, + getLatestMessagePreview = defaultGetLatestMessagePreview, + } = props; const { channel: activeChannel, client, setActiveChannel } = useChatContext( 'ChannelPreview', ); @@ -123,26 +136,29 @@ export const ChannelPreview = < useEffect(() => { refreshUnreadCount(); - const handleEvent = (event: Event) => { - if (event.message) setLastMessage(event.message); + const handleEvent = () => { + setLastMessage(channel.state.latestMessages[channel.state.latestMessages.length - 1]); refreshUnreadCount(); }; channel.on('message.new', handleEvent); channel.on('message.updated', handleEvent); channel.on('message.deleted', handleEvent); + channel.on('message.undeleted', handleEvent); + channel.on('channel.truncated', handleEvent); return () => { channel.off('message.new', handleEvent); channel.off('message.updated', handleEvent); channel.off('message.deleted', handleEvent); + channel.off('message.undeleted', handleEvent); + channel.off('channel.truncated', handleEvent); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [refreshUnreadCount, channelUpdateCount]); + }, [channel, refreshUnreadCount, channelUpdateCount]); if (!Preview) return null; - const latestMessage = getLatestMessagePreview(channel, t, userLanguage); + const latestMessagePreview = getLatestMessagePreview(channel, t, userLanguage); return ( )} -
{latestMessage}
+
+ {latestMessagePreview} +
); diff --git a/src/components/ChannelPreview/__tests__/ChannelPreview.test.js b/src/components/ChannelPreview/__tests__/ChannelPreview.test.js index b95e59416b..d78712bca4 100644 --- a/src/components/ChannelPreview/__tests__/ChannelPreview.test.js +++ b/src/components/ChannelPreview/__tests__/ChannelPreview.test.js @@ -8,6 +8,7 @@ import { Chat } from '../../Chat'; import { ChatContext } from '../../../context/ChatContext'; import { + dispatchChannelTruncatedEvent, dispatchMessageDeletedEvent, dispatchMessageNewEvent, dispatchMessageUpdatedEvent, @@ -24,11 +25,24 @@ import { useMockedApis, } from 'mock-builders'; +const EMPTY_CHANNEL_PREVIEW_TEXT = 'Empty channel'; + const PreviewUIComponent = (props) => ( <>
{props.channel.id}
{props.unread}
-
{props.lastMessage && props.lastMessage.text}
+
+ {props.lastMessage ? props.lastMessage.text : EMPTY_CHANNEL_PREVIEW_TEXT} +
+ +); +const PreviewUIComponentWithLatestMessagePreview = (props) => ( + <> +
{props.channel.id}
+
{props.unread}
+
+ {props.lastMessage ? props.latestMessagePreview : EMPTY_CHANNEL_PREVIEW_TEXT} +
); @@ -65,7 +79,18 @@ describe('ChannelPreview', () => { beforeEach(async () => { client = await getTestClientWithUser(user); - useMockedApis(client, [queryChannelsApi([generateChannel(), generateChannel()])]); + useMockedApis(client, [ + queryChannelsApi([ + generateChannel({ + channel: { name: 'c0' }, + messages: Array.from({ length: 5 }, generateMessage), + }), + generateChannel({ + channel: { name: 'c1' }, + messages: Array.from({ length: 5 }, generateMessage), + }), + ]), + ]); [c0, c1] = await client.queryChannels({}, {}); }); @@ -128,14 +153,31 @@ describe('ChannelPreview', () => { await expectUnreadCountToBe(getByTestId, newUnreadCount); }); + it('allows to customize latest message preview generation', async () => { + const getLatestMessagePreview = (channel) => channel.data.name; + + const { getByTestId } = renderComponent( + { + activeChannel: c0, + channel: c0, + getLatestMessagePreview, + Preview: PreviewUIComponentWithLatestMessagePreview, + }, + render, + ); + + await expectLastEventMessageToBe(getByTestId, c0.data.name); + }); + const eventCases = [ ['message.new', dispatchMessageNewEvent], ['message.updated', dispatchMessageUpdatedEvent], ['message.deleted', dispatchMessageDeletedEvent], + ['message.undeleted', dispatchMessageDeletedEvent], ]; describe.each(eventCases)('On %s event', (eventType, dispatcher) => { - it('should update lastMessage', async () => { + it('should update latest message preview', async () => { const newUnreadCount = getRandomInt(1, 10); c0.countUnread = () => newUnreadCount; @@ -151,9 +193,10 @@ describe('ChannelPreview', () => { expect(getByTestId('channel-id')).toBeInTheDocument(); }); - const message = generateMessage(); - act(() => { - dispatcher(client, message, c0); + const message = + eventType === 'message.new' ? generateMessage() : c0.state.messages.slice(-1)[0]; + await act(async () => { + await dispatcher(client, message, c0); }); await expectLastEventMessageToBe(getByTestId, message.text); @@ -233,6 +276,61 @@ describe('ChannelPreview', () => { }); }); + it('on channel.truncated event should update latest message preview', async () => { + const newUnreadCount = getRandomInt(1, 10); + c0.countUnread = () => newUnreadCount; + + const { getByTestId } = renderComponent( + { + activeChannel: c1, + channel: c0, + }, + render, + ); + + await waitFor(() => { + expect(getByTestId('channel-id')).toBeInTheDocument(); + }); + + await act(async () => { + await dispatchChannelTruncatedEvent(client, c0); + }); + + await expectLastEventMessageToBe(getByTestId, EMPTY_CHANNEL_PREVIEW_TEXT); + }); + + it.each([ + ['message.updated', dispatchMessageUpdatedEvent], + ['message.deleted', dispatchMessageDeletedEvent], + ['message.undeleted', dispatchMessageDeletedEvent], + ])( + 'on %s event should not update latest message preview for the non-last message', + async (_, dispatcher) => { + const newUnreadCount = getRandomInt(1, 10); + c0.countUnread = () => newUnreadCount; + + const { getByTestId } = renderComponent( + { + activeChannel: c1, + channel: c0, + }, + render, + ); + + await waitFor(() => { + expect(getByTestId('channel-id')).toBeInTheDocument(); + }); + + const lastMessage = c0.state.messages.slice(-1)[0]; + const penultimateMessage = c0.state.messages.slice(-2)[0]; + await act(async () => { + await dispatcher(client, penultimateMessage, c0); + }); + + await expectLastEventMessageToBe(getByTestId, lastMessage.text); + }, + ); + describe('notification.mark_read', () => { it('should set unread count to 0 for event missing CID', () => { const unreadCount = getRandomInt(1, 10); @@ -282,6 +380,7 @@ describe('ChannelPreview', () => { expectUnreadCountToBe(screen.getByTestId, unreadCount); }); }); + describe('notification.mark_unread', () => { it('should be ignored if not originated from the current user', () => { const unreadCount = 0; diff --git a/src/components/ChannelPreview/__tests__/ChannelPreviewMessenger.test.js b/src/components/ChannelPreview/__tests__/ChannelPreviewMessenger.test.js index 36447f8b2c..56b26c7d4f 100644 --- a/src/components/ChannelPreview/__tests__/ChannelPreviewMessenger.test.js +++ b/src/components/ChannelPreview/__tests__/ChannelPreviewMessenger.test.js @@ -30,7 +30,7 @@ describe('ChannelPreviewMessenger', () => { channel={channel} displayImage='https://randomimage.com/src.jpg' displayTitle='Channel name' - latestMessage='Latest message!' + latestMessagePreview='Latest message!' setActiveChannel={jest.fn()} unread={10} {...props} diff --git a/src/components/ChannelPreview/utils.tsx b/src/components/ChannelPreview/utils.tsx index 3efaa3a6f6..1aaa77672b 100644 --- a/src/components/ChannelPreview/utils.tsx +++ b/src/components/ChannelPreview/utils.tsx @@ -17,7 +17,7 @@ export const getLatestMessagePreview = < t: TranslationContextValue['t'], userLanguage: TranslationContextValue['userLanguage'] = 'en', ): string | JSX.Element => { - const latestMessage = channel.state.messages[channel.state.messages.length - 1]; + const latestMessage = channel.state.latestMessages[channel.state.latestMessages.length - 1]; const previewTextToRender = latestMessage?.i18n?.[`${userLanguage}_text` as `${TranslationLanguages}_text`] || @@ -32,8 +32,7 @@ export const getLatestMessagePreview = < } if (previewTextToRender) { - const renderedText = renderPreviewText(previewTextToRender); - return renderedText; + return renderPreviewText(previewTextToRender); } if (latestMessage.command) { diff --git a/src/mock-builders/event/messageUndeleted.js b/src/mock-builders/event/messageUndeleted.js new file mode 100644 index 0000000000..5407563fde --- /dev/null +++ b/src/mock-builders/event/messageUndeleted.js @@ -0,0 +1,10 @@ +export default (client, message, channel = {}) => { + const [channel_id, channel_type] = channel.cid.split(':'); + client.dispatchEvent({ + channel_id, + channel_type, + cid: channel.cid, + message, + type: 'message.undeleted', + }); +};