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 24c77d235..70f202d27 100644 --- a/docusaurus/docs/React/components/utility-components/channel-preview-ui.mdx +++ b/docusaurus/docs/React/components/utility-components/channel-preview-ui.mdx @@ -109,6 +109,40 @@ Latest message preview to display. Will be either a string or a JSX.Element rend | --------------------- | | string \| JSX.Element | +### messageDeliveryStatus + +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. The value is calculated from `channel.read` data on mount and updated on every `message.new` resp. `message.read` WS event. + +| Type | +|-------------------------| +| `MessageDeliveryStatus` | + +Use `MessageDeliveryStatus` enum to determine the current delivery status, for example: + +```typescript jsx +import { MessageDeliveryStatus } from 'stream-chat-react'; +import { + DoubleCheckMarkIcon, + SingleCheckMarkIcon, +} from '../icons'; + +type MessageDeliveryStatusIndicator = { + messageDeliveryStatus: MessageDeliveryStatus; +} + +export const MessageDeliveryStatusIndicator = ({ messageDeliveryStatus }: MessageDeliveryStatusIndicator) => { + // the last message is not an own message in the channel + if (!messageDeliveryStatus) return null; + + return ( +
+ {messageDeliveryStatus === MessageDeliveryStatus.DELIVERED && } + {messageDeliveryStatus === MessageDeliveryStatus.READ && } +
+ ); +}; +``` + ### 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/components/utility-components/channel-search.mdx b/docusaurus/docs/React/components/utility-components/channel-search.mdx index 52d4ed613..ae32d424e 100644 --- a/docusaurus/docs/React/components/utility-components/channel-search.mdx +++ b/docusaurus/docs/React/components/utility-components/channel-search.mdx @@ -338,6 +338,14 @@ Custom search function to override default. The first argument should expect an | --------------------------------------------------------------------------------------------------- | | (`params: ChannelSearchFunctionParams, event: React.BaseSyntheticEvent` ) => Promise \| void | +### searchDebounceIntervalMs + +The number of milliseconds to debounce the search query. + +| Type | Default | +|----------|---------| +| `number` | 300 | + ### SearchInput Custom UI component to display the search text input. diff --git a/docusaurus/docs/React/guides/customization/channel-list-preview.mdx b/docusaurus/docs/React/guides/customization/channel-list-preview.mdx index 044df24a5..a5b4d425f 100644 --- a/docusaurus/docs/React/guides/customization/channel-list-preview.mdx +++ b/docusaurus/docs/React/guides/customization/channel-list-preview.mdx @@ -141,7 +141,7 @@ import '@stream-io/stream-chat-css/dist/css/index.css'; > ``` -Next, let's add a little bit more useful information to the component using more of the default props and a value pulled from the `ChatContext`, as well as some styling using custom CSS. +Next, let's add a bit more useful information to the component using more of the default props and a value pulled from the `ChatContext`, as well as some styling using custom CSS. This context also exposes the client which makes it possible to use API methods. :::note diff --git a/docusaurus/docs/React/guides/customization/channel-search.mdx b/docusaurus/docs/React/guides/customization/channel-search.mdx index cad9c3acb..239a3916a 100644 --- a/docusaurus/docs/React/guides/customization/channel-search.mdx +++ b/docusaurus/docs/React/guides/customization/channel-search.mdx @@ -311,7 +311,7 @@ const additionalProps = { ### The searchFunction Prop: -By default the `ChannelSearch` component searches just for users. Use the `searchForChannels` prop to also search for channels. +By default, the `ChannelSearch` component searches just for users. Use the `searchForChannels` prop to also search for channels. To override the search method, completely use the `searchFunction` prop. This prop is useful, say, when you want to search just for channels and for only channels that the current logged in user is a member of. See the example below for this. diff --git a/src/components/ChannelList/__tests__/ChannelList.test.js b/src/components/ChannelList/__tests__/ChannelList.test.js index 552840478..361434231 100644 --- a/src/components/ChannelList/__tests__/ChannelList.test.js +++ b/src/components/ChannelList/__tests__/ChannelList.test.js @@ -48,7 +48,7 @@ const channelsQueryStateMock = { }; /** - * We are gonna use following custom UI components for preview and list. + * We use the following custom UI components for preview and list. * If we use ChannelPreviewMessenger or ChannelPreviewLastMessage here, then changes * to those components might end up breaking tests for ChannelList, which will be quite painful * to debug then. @@ -438,6 +438,7 @@ describe('ChannelList', () => { }); describe('channel search', () => { + const defaultSearchDebounceInterval = 300; const inputText = 'xxxxxxxxxx'; const user1 = generateUser(); const user2 = generateUser(); @@ -551,11 +552,13 @@ describe('ChannelList', () => { ])( 'theme v%s %s unmount search results on result click, if configured', async (themeVersion, _, clearSearchOnClickOutside) => { + jest.useFakeTimers('modern'); + jest.spyOn(client, 'queryUsers').mockResolvedValue({ users: [generateUser()] }); const { container } = await renderComponents( { channel, client, themeVersion }, { additionalChannelSearchProps: { clearSearchOnClickOutside } }, ); - const input = screen.queryByTestId('search-input'); + const input = screen.getByTestId('search-input'); await act(() => { fireEvent.change(input, { target: { @@ -563,8 +566,10 @@ describe('ChannelList', () => { }, }); }); - - const searchResults = screen.queryAllByRole('option'); + await act(() => { + jest.advanceTimersByTime(defaultSearchDebounceInterval + 1); + }); + const searchResults = screen.queryAllByTestId('channel-search-result-user'); useMockedApis(client, [getOrCreateChannelApi(generateChannel())]); await act(() => { fireEvent.click(searchResults[0]); @@ -577,6 +582,7 @@ describe('ChannelList', () => { expect(container.querySelector(SEARCH_RESULT_LIST_SELECTOR)).toBeInTheDocument(); } }); + jest.useRealTimers(); }, ); @@ -645,6 +651,8 @@ describe('ChannelList', () => { it.each([['1'], ['2']])( 'theme v%s should add the selected result to the top of the channel list', async (themeVersion) => { + jest.useFakeTimers('modern'); + jest.spyOn(client, 'queryUsers').mockResolvedValue({ users: [generateUser()] }); const getComputedStyleMock = jest.spyOn(window, 'getComputedStyle'); getComputedStyleMock.mockReturnValue({ getPropertyValue: jest.fn().mockReturnValue(themeVersion), @@ -679,8 +687,11 @@ describe('ChannelList', () => { }, }); }); + await act(() => { + jest.advanceTimersByTime(defaultSearchDebounceInterval + 1); + }); - const targetChannelPreview = screen.queryByText(channelNotInTheList.channel.name); + const targetChannelPreview = screen.getByText(channelNotInTheList.channel.name); expect(targetChannelPreview).toBeInTheDocument(); await act(() => { fireEvent.click(targetChannelPreview); @@ -693,6 +704,7 @@ describe('ChannelList', () => { } }); getComputedStyleMock.mockClear(); + jest.useRealTimers(); }, ); }); diff --git a/src/components/ChannelPreview/ChannelPreview.tsx b/src/components/ChannelPreview/ChannelPreview.tsx index 00e8a033c..ac5d707de 100644 --- a/src/components/ChannelPreview/ChannelPreview.tsx +++ b/src/components/ChannelPreview/ChannelPreview.tsx @@ -7,6 +7,7 @@ import { getLatestMessagePreview } from './utils'; import { ChatContextValue, useChatContext } from '../../context/ChatContext'; import { useTranslationContext } from '../../context/TranslationContext'; +import { MessageDeliveryStatus, useMessageDeliveryStatus } from './hooks/useMessageDeliveryStatus'; import type { Channel, Event } from 'stream-chat'; @@ -29,6 +30,8 @@ export type ChannelPreviewUIComponentProps< lastMessage?: StreamMessage; /** Latest message preview to display, will be a string or JSX element supporting markdown. */ latestMessage?: 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 */ unread?: number; }; @@ -73,6 +76,10 @@ export const ChannelPreview = < channel.state.messages[channel.state.messages.length - 1], ); const [unread, setUnread] = useState(0); + const { messageDeliveryStatus } = useMessageDeliveryStatus({ + channel, + lastMessage, + }); const isActive = activeChannel?.cid === channel.cid; const { muted } = useIsChannelMuted(channel); @@ -126,6 +133,7 @@ export const ChannelPreview = < displayTitle={displayTitle} lastMessage={lastMessage} latestMessage={latestMessage} + messageDeliveryStatus={messageDeliveryStatus} setActiveChannel={setActiveChannel} unread={unread} /> diff --git a/src/components/ChannelPreview/hooks/__tests__/useMessageDeliveryStatus.test.js b/src/components/ChannelPreview/hooks/__tests__/useMessageDeliveryStatus.test.js new file mode 100644 index 000000000..d8b25470e --- /dev/null +++ b/src/components/ChannelPreview/hooks/__tests__/useMessageDeliveryStatus.test.js @@ -0,0 +1,488 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { MessageDeliveryStatus, useMessageDeliveryStatus } from '../useMessageDeliveryStatus'; +import { ChatContext } from '../../../../context'; +import { + dispatchMessageDeletedEvent, + dispatchMessageNewEvent, + dispatchMessageReadEvent, + dispatchMessageUpdatedEvent, + generateChannel, + generateMember, + generateMessage, + generateUser, + getOrCreateChannelApi, + getTestClientWithUser, + useMockedApis, +} from '../../../../mock-builders'; +import { act } from '@testing-library/react'; + +const userA = generateUser(); +const userB = generateUser(); +const getClientAndChannel = async (channelData = {}, user = userA) => { + const members = [generateMember({ user: userA }), generateMember({ user: userB })]; + const client = await getTestClientWithUser(user); + const mockedChannel = generateChannel({ + members, + ...channelData, + }); + + // eslint-disable-next-line react-hooks/rules-of-hooks + useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); + + const channel = client.channel('messaging', mockedChannel.channel.id); + await channel.watch(); + + return { + channel, + client, + }; +}; + +const renderComponent = ({ channel, client, lastMessage }) => { + const wrapper = ({ children }) => ( + {children} + ); + + return renderHook(() => useMessageDeliveryStatus({ channel, lastMessage }), { wrapper }); +}; + +describe('Message delivery status', () => { + describe('when initiated from channel state', () => { + it('is undefined if there are no messages in the channel', async () => { + const { channel, client } = await getClientAndChannel({ messages: [] }); + const { result } = renderComponent({ channel, client }); + expect(result.current.messageDeliveryStatus).toBeUndefined(); + }); + + it('is undefined if the last message does not have creation date', async () => { + const messages = [generateMessage({ created_at: undefined, user: userA })]; + const lastMessage = messages[0]; + const read = [ + { + last_read: lastMessage.created_at, + last_read_message_id: lastMessage.id, + unread_messages: 0, + user: userA, + }, + { + last_read: '1970-01-01T00:00:00.00Z', + unread_messages: 1, + user: userB, + }, + ]; + const { channel, client } = await getClientAndChannel({ messages, read }); + const { result } = renderComponent({ channel, client, lastMessage }); + expect(result.current.messageDeliveryStatus).toBeUndefined(); + }); + + it('is undefined if the last message was created by another user', async () => { + const messages = [ + generateMessage({ created_at: new Date('1970-01-01T00:00:01.00Z'), user: userA }), + generateMessage({ created_at: new Date('1970-01-01T00:00:02.00Z'), user: userB }), + ]; + const lastMessage = messages[1]; + const read = [ + { + last_read: messages[1].created_at.toISOString(), + last_read_message_id: messages[1].id, + unread_messages: 0, + user: userA, + }, + { + last_read: messages[0].created_at.toISOString(), + last_read_message_id: messages[0].id, + unread_messages: 1, + user: userB, + }, + ]; + const { channel, client } = await getClientAndChannel({ messages, read }); + const { result } = renderComponent({ channel, client, lastMessage }); + expect(result.current.messageDeliveryStatus).toBeUndefined(); + }); + + it('is "delivered" if the last message in channel was not read by any member other than me', async () => { + const messages = [ + generateMessage({ created_at: new Date('1970-01-01T00:00:01.00Z'), user: userA }), + generateMessage({ created_at: new Date('1970-01-01T00:00:02.00Z'), user: userA }), + ]; + const lastMessage = messages[1]; + const read = [ + { + last_read: messages[1].created_at.toISOString(), + last_read_message_id: messages[1].id, + unread_messages: 0, + user: userA, + }, + { + last_read: messages[0].created_at.toISOString(), + last_read_message_id: messages[0].id, + unread_messages: 1, + user: userB, + }, + ]; + const { channel, client } = await getClientAndChannel({ messages, read }); + const { result } = renderComponent({ channel, client, lastMessage }); + expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.DELIVERED); + }); + + it('is "read" if the last message in channel was read by at least 1 other member', async () => { + const messages = [ + generateMessage({ created_at: new Date('1970-01-01T00:00:01.00Z'), user: userA }), + generateMessage({ created_at: new Date('1970-01-01T00:00:02.00Z'), user: userA }), + ]; + const lastMessage = messages[1]; + const last_read = '1970-01-01T00:00:03.00Z'; + const read = [ + { + last_read, + last_read_message_id: lastMessage.id, + unread_messages: 0, + user: userA, + }, + { + last_read, + last_read_message_id: lastMessage.id, + unread_messages: 0, + user: userB, + }, + ]; + const { channel, client } = await getClientAndChannel({ messages, read }); + const { result } = renderComponent({ channel, client, lastMessage }); + expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.READ); + }); + }); + + describe('on message.new event when other user is muted', () => { + // message.read is not delivered over the WS, when the other is muted + it('is undefined if receives new message to empty channel', async () => { + const { channel, client } = await getClientAndChannel({ messages: [] }); + client.mutedUsers = [{ target: userB }]; + const { result } = renderComponent({ channel, client }); + const newMessage = generateMessage({ + created_at: new Date('1970-01-01T00:00:02.00Z'), + user: userB, + }); + await act(() => { + dispatchMessageNewEvent(client, newMessage, channel); + }); + expect(result.current.messageDeliveryStatus).toBeUndefined(); + }); + + it('is "delivered" if received new message to a channel with last message from own user', async () => { + const messages = [ + generateMessage({ created_at: new Date('1970-01-01T00:00:01.00Z'), user: userA }), + ]; + const lastMessage = messages[0]; + const read = [ + { + last_read: messages[0].created_at.toISOString(), + last_read_message_id: messages[0], + unread_messages: 0, + user: userA, + }, + { + last_read: '1970-01-01T00:00:01.00Z', + unread_messages: 1, + user: userB, + }, + ]; + const { channel, client } = await getClientAndChannel({ messages, read }); + client.mutedUsers = [{ target: userB }]; + const { rerender, result } = renderComponent({ channel, client, lastMessage }); + expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.DELIVERED); + + const newMessage = generateMessage({ + created_at: new Date('1970-01-01T00:00:02.00Z'), + user: userA, + }); + await act(() => { + dispatchMessageNewEvent(client, newMessage, channel); + }); + rerender(); + expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.DELIVERED); + }); + + it('is "delivered" if received new message to channel with last message from another user', async () => { + const messages = [ + generateMessage({ created_at: new Date('1970-01-01T00:00:01.00Z'), user: userB }), + ]; + const lastMessage = messages[0]; + const read = [ + { + last_read: messages[0].created_at.toISOString(), + last_read_message_id: messages[0], + unread_messages: 0, + user: userA, + }, + { + last_read: messages[0].created_at.toISOString(), + last_read_message_id: messages[0], + unread_messages: 0, + user: userB, + }, + ]; + const { channel, client } = await getClientAndChannel({ messages, read }); + client.mutedUsers = [{ target: userB }]; + const { rerender, result } = renderComponent({ channel, client, lastMessage }); + expect(result.current.messageDeliveryStatus).toBeUndefined(); + + const newMessage = generateMessage({ + created_at: new Date('1970-01-01T00:00:02.00Z'), + user: userA, + }); + await act(() => { + dispatchMessageNewEvent(client, newMessage, channel); + }); + rerender(); + expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.DELIVERED); + }); + }); + + describe('on event', () => { + it('is undefined if the new message was created by another user', async () => { + const last_read = '1970-01-01T00:00:02.00Z'; + const read = [ + { + last_read, + user: userA, + }, + { + last_read, + user: userB, + }, + ]; + const { channel, client } = await getClientAndChannel({ messages: [], read }); + const { rerender, result } = renderComponent({ channel, client }); + + const newMessage = generateMessage({ + created_at: new Date('1970-01-01T00:00:02.00Z'), + user: userB, + }); + await act(() => { + dispatchMessageNewEvent(client, newMessage, channel); + }); + rerender(); + expect(result.current.messageDeliveryStatus).toBeUndefined(); + }); + + it('is "delivered" if the channel was not marked read by another user', async () => { + const last_read = '1970-01-01T00:00:02.00Z'; + const read = [ + { + last_read, + user: userA, + }, + { + last_read, + user: userB, + }, + ]; + const { channel, client } = await getClientAndChannel({ messages: [], read }); + const { rerender, result } = renderComponent({ channel, client }); + + const newMessage = generateMessage({ + created_at: new Date('1970-01-01T00:00:02.00Z'), + user: userA, + }); + await act(() => { + dispatchMessageNewEvent(client, newMessage, channel); + }); + rerender(); + expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.DELIVERED); + }); + + it('is "read" if the channel was read by another user', async () => { + const messages = [ + generateMessage({ created_at: new Date('1970-01-01T00:00:02.00Z'), user: userA }), + ]; + const lastMessage = messages[0]; + const read = [ + { + last_read: messages[0].created_at.toISOString(), + last_read_message_id: messages[0].id, + unread_messages: 0, + user: userA, + }, + { + last_read: '1970-01-01T00:00:01.00Z', + unread_messages: 1, + user: userB, + }, + ]; + + const { channel, client } = await getClientAndChannel({ messages, read }); + const { rerender, result } = renderComponent({ channel, client, lastMessage }); + + await act(() => { + dispatchMessageReadEvent(client, userB, channel); + }); + rerender(); + expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.READ); + }); + + it('should ignore mark.read if the last message is not own', async () => { + const messages = [ + generateMessage({ created_at: new Date('1970-01-01T00:00:02.00Z'), user: userB }), + ]; + const lastMessage = messages[0]; + const read = [ + { + last_read: messages[0].created_at.toISOString(), + last_read_message_id: messages[0].id, + unread_messages: 0, + user: userA, + }, + { + last_read: messages[0].created_at.toISOString(), + unread_messages: 1, + user: userB, + }, + ]; + + const { channel, client } = await getClientAndChannel({ messages, read }); + const { rerender, result } = renderComponent({ channel, client, lastMessage }); + + await act(() => { + dispatchMessageReadEvent(client, userB, channel); + }); + rerender(); + expect(result.current.messageDeliveryStatus).toBeUndefined(); + }); + + it('is kept "delivered" when the last unread message is updated', async () => { + const messages = [ + generateMessage({ created_at: new Date('1970-01-01T00:00:02.00Z'), user: userA }), + ]; + const lastMessage = messages[0]; + const read = [ + { + last_read: lastMessage.created_at.toISOString(), + last_read_message_id: lastMessage.id, + unread_messages: 0, + user: userA, + }, + { + last_read: '1970-01-01T00:00:02.00Z', + unread_messages: 1, + user: userB, + }, + ]; + + const { channel, client } = await getClientAndChannel({ messages, read }); + const { rerender, result } = renderComponent({ channel, client, lastMessage }); + expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.DELIVERED); + + const updatedMessage = { + ...lastMessage, + updated_at: new Date('1970-01-01T00:00:02.00Z'), + }; + + await act(() => { + dispatchMessageUpdatedEvent(client, updatedMessage, channel); + }); + rerender(); + expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.DELIVERED); + }); + + it('does not regress to "delivered" when the last read message is updated', async () => { + const messages = [ + generateMessage({ created_at: new Date('1970-01-01T00:00:01.00Z'), user: userA }), + ]; + const lastMessage = messages[0]; + const last_read = '1970-01-01T00:00:03.00Z'; + const read = [ + { + last_read, + last_read_message_id: lastMessage.id, + unread_messages: 0, + user: userA, + }, + { + last_read, + last_read_message_id: lastMessage.id, + unread_messages: 0, + user: userB, + }, + ]; + + const { channel, client } = await getClientAndChannel({ messages, read }); + const { rerender, result } = renderComponent({ channel, client, lastMessage }); + expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.READ); + + const updatedMessage = { + ...lastMessage, + updated_at: new Date('1970-01-01T00:00:02.00Z'), + }; + + await act(() => { + dispatchMessageUpdatedEvent(client, updatedMessage, channel); + }); + rerender(); + expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.READ); + }); + + it('is kept "delivered" when the last unread message is deleted', async () => { + const messages = [ + generateMessage({ created_at: new Date('1970-01-01T00:00:02.00Z'), user: userA }), + ]; + const lastMessage = messages[0]; + const read = [ + { + last_read: lastMessage.created_at.toISOString(), + last_read_message_id: lastMessage.id, + unread_messages: 0, + user: userA, + }, + { + last_read: '1970-01-01T00:00:02.00Z', + unread_messages: 1, + user: userB, + }, + ]; + + const { channel, client } = await getClientAndChannel({ messages, read }); + const { rerender, result } = renderComponent({ channel, client, lastMessage }); + expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.DELIVERED); + + await act(() => { + dispatchMessageDeletedEvent(client, lastMessage, channel); + }); + rerender(); + expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.DELIVERED); + }); + + it('does not regress to "delivered" when the last message is deleted', async () => { + const messages = [ + generateMessage({ created_at: new Date('1970-01-01T00:00:01.00Z'), user: userA }), + ]; + const lastMessage = messages[0]; + const last_read = '1970-01-01T00:00:03.00Z'; + const read = [ + { + last_read, + last_read_message_id: lastMessage.id, + unread_messages: 0, + user: userA, + }, + { + last_read, + last_read_message_id: lastMessage.id, + unread_messages: 0, + user: userB, + }, + ]; + + const { channel, client } = await getClientAndChannel({ messages, read }); + const { rerender, result } = renderComponent({ channel, client, lastMessage }); + expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.READ); + + await act(() => { + dispatchMessageDeletedEvent(client, lastMessage, channel); + }); + + rerender(); + expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.READ); + }); + }); +}); diff --git a/src/components/ChannelPreview/hooks/index.ts b/src/components/ChannelPreview/hooks/index.ts index 9965c537d..16eec9ba2 100644 --- a/src/components/ChannelPreview/hooks/index.ts +++ b/src/components/ChannelPreview/hooks/index.ts @@ -1 +1,2 @@ export { useChannelPreviewInfo } from './useChannelPreviewInfo'; +export { MessageDeliveryStatus } from './useMessageDeliveryStatus'; diff --git a/src/components/ChannelPreview/hooks/useMessageDeliveryStatus.ts b/src/components/ChannelPreview/hooks/useMessageDeliveryStatus.ts new file mode 100644 index 000000000..d7c63aa34 --- /dev/null +++ b/src/components/ChannelPreview/hooks/useMessageDeliveryStatus.ts @@ -0,0 +1,94 @@ +import { useCallback, useEffect, useState } from 'react'; +import type { Channel, Event } from 'stream-chat'; + +import { useChatContext } from '../../../context'; + +import type { DefaultStreamChatGenerics } from '../../../types/types'; +import type { StreamMessage } from '../../../context'; + +export enum MessageDeliveryStatus { + DELIVERED = 'delivered', + READ = 'read', +} + +type UseMessageStatusParamsChannelPreviewProps< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +> = { + channel: Channel; + /** The last message received in a channel */ + lastMessage?: StreamMessage; +}; + +export const useMessageDeliveryStatus = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>({ + channel, + lastMessage, +}: UseMessageStatusParamsChannelPreviewProps) => { + const { client } = useChatContext(); + const [messageDeliveryStatus, setMessageDeliveryStatus] = useState< + MessageDeliveryStatus | undefined + >(); + + const isOwnMessage = useCallback( + (message?: StreamMessage) => + client.user && message?.user?.id === client.user.id, + [client], + ); + + useEffect(() => { + const lastMessageIsOwn = isOwnMessage(lastMessage); + if (!lastMessage?.created_at || !lastMessageIsOwn) return; + + const lastMessageCreatedAtDate = + typeof lastMessage.created_at === 'string' + ? new Date(lastMessage.created_at) + : lastMessage.created_at; + + const channelReadByOthersAfterLastMessageUpdate = Object.values(channel.state.read).some( + ({ last_read: channelLastMarkedReadDate, user }) => { + const ignoreOwnReadStatus = client.user && user.id !== client.user.id; + return ignoreOwnReadStatus && lastMessageCreatedAtDate < channelLastMarkedReadDate; + }, + ); + + setMessageDeliveryStatus( + channelReadByOthersAfterLastMessageUpdate + ? MessageDeliveryStatus.READ + : MessageDeliveryStatus.DELIVERED, + ); + }, [channel.state.read, client, isOwnMessage, lastMessage]); + + useEffect(() => { + const handleMessageNew = (event: Event) => { + // the last message is not mine, so do not show the delivery status + if (!isOwnMessage(event.message)) { + return setMessageDeliveryStatus(undefined); + } + + return setMessageDeliveryStatus(MessageDeliveryStatus.DELIVERED); + }; + + channel.on('message.new', handleMessageNew); + + return () => { + channel.off('message.new', handleMessageNew); + }; + }, [channel, client, isOwnMessage]); + + useEffect(() => { + if (!isOwnMessage(lastMessage)) return; + const handleMarkRead = (event: Event) => { + if (event.user?.id !== client.user?.id) setMessageDeliveryStatus(MessageDeliveryStatus.READ); + }; + channel.on('message.read', handleMarkRead); + + return () => { + channel.off('message.read', handleMarkRead); + }; + }, [channel, client, lastMessage, isOwnMessage]); + + return { + messageDeliveryStatus, + }; +}; diff --git a/src/components/ChannelSearch/__tests__/ChannelSearch.test.js b/src/components/ChannelSearch/__tests__/ChannelSearch.test.js index f65376f4c..e8a8377a2 100644 --- a/src/components/ChannelSearch/__tests__/ChannelSearch.test.js +++ b/src/components/ChannelSearch/__tests__/ChannelSearch.test.js @@ -3,71 +3,357 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra import '@testing-library/jest-dom'; import { ChannelSearch } from '../ChannelSearch'; -import { generateUser, getTestClientWithUser } from '../../../mock-builders'; -import { Chat } from '../../Chat'; +import { + generateChannel, + generateUser, + getTestClientWithUser, + queryUsersApi, + useMockedApis, +} from '../../../mock-builders'; +import { ChatProvider } from '../../../context'; let chatClient; const user = generateUser({ id: 'id', name: 'name' }); -const testId = 'channel-search'; +const channelResponseData = generateChannel(); -const renderSearch = async ({ props }) => { - await act(() => { +const DEFAULT_DEBOUNCE_INTERVAL = 300; +const TEST_ID = { + CHANNEL_SEARCH: 'channel-search', + CHANNEL_SEARCH_RESULTS_HEADER: 'channel-search-results-header', + CLEAR_INPUT_BUTTON: 'clear-input-button', + SEARCH_IN_PROGRESS_INDICATOR: 'search-in-progress-indicator', + SEARCH_INPUT: 'search-input', +}; +const typedText = 'abc'; + +const renderSearch = async ({ client, props } = { props: {} }) => { + chatClient = client || (await getTestClientWithUser(user)); + + const renderResult = await act(() => { render( - + - , + , ); }); - const channelSearch = await waitFor(() => screen.getByTestId(testId)); + const channelSearch = await waitFor(() => screen.getByTestId(TEST_ID.CHANNEL_SEARCH)); + const searchInput = await waitFor(() => screen.getByTestId(TEST_ID.SEARCH_INPUT)); const typeText = (text) => { - fireEvent.change(channelSearch, { target: { value: text } }); + fireEvent.change(searchInput, { target: { value: text } }); }; - return { channelSearch, typeText }; + return { ...renderResult, channelSearch, chatClient, searchInput, typeText }; }; describe('ChannelSearch', () => { - beforeEach(async () => { - chatClient = await getTestClientWithUser(user); - }); - afterEach(cleanup); it('should render component without any props', async () => { - const { channelSearch } = await renderSearch({}); - expect(channelSearch).toMatchInlineSnapshot(` - - `); + const { channelSearch } = await renderSearch(); + + expect(channelSearch).toMatchSnapshot(); }); it('displays custom placeholder', async () => { const placeholder = 'Custom placeholder'; const { channelSearch } = await renderSearch({ props: { placeholder } }); - expect(channelSearch).toMatchInlineSnapshot(` - - `); + expect(channelSearch).toMatchSnapshot(); + }); + + it('updates search query value upon each stroke', async () => { + const { searchInput, typeText } = await renderSearch(); + await act(() => { + typeText(typedText); + }); + + await waitFor(() => { + expect(searchInput).toHaveValue(typedText); + }); + }); + + it('does not update input search query value when disabled', async () => { + const { searchInput, typeText } = await renderSearch({ props: { disabled: true } }); + await act(() => { + typeText(typedText); + }); + + await waitFor(() => { + expect(searchInput).toHaveValue(''); + }); + }); + + it('starts with "searching" flag disabled', async () => { + await renderSearch(); + expect(screen.queryByTestId(TEST_ID.SEARCH_IN_PROGRESS_INDICATOR)).not.toBeInTheDocument(); + }); + + it('sets "searching" flag on first typing stroke', async () => { + const { typeText } = await renderSearch(); + await act(() => { + typeText(typedText); + }); + expect(screen.queryByTestId(TEST_ID.SEARCH_IN_PROGRESS_INDICATOR)).toBeInTheDocument(); + }); + + it('removes "searching" flag upon deleting the last character', async () => { + const { typeText } = await renderSearch(); + await act(() => { + typeText(typedText); + }); + expect(screen.queryByTestId(TEST_ID.SEARCH_IN_PROGRESS_INDICATOR)).toBeInTheDocument(); + await act(() => { + typeText(''); + }); + expect(screen.queryByTestId(TEST_ID.SEARCH_IN_PROGRESS_INDICATOR)).not.toBeInTheDocument(); + }); + + it('removes "searching" flag upon setting search results', async () => { + jest.useFakeTimers('modern'); + const client = await getTestClientWithUser(user); + useMockedApis(client, [queryUsersApi([user])]); + const { typeText } = await renderSearch({ client }); + await act(() => { + typeText(typedText); + }); + expect(screen.queryByTestId(TEST_ID.SEARCH_IN_PROGRESS_INDICATOR)).toBeInTheDocument(); + jest.advanceTimersByTime(1000); + await waitFor(() => { + expect(screen.queryByTestId(TEST_ID.SEARCH_IN_PROGRESS_INDICATOR)).not.toBeInTheDocument(); + }); + jest.useRealTimers(); + }); + + it('search is performed by default on users and not channels', async () => { + jest.useFakeTimers('modern'); + const client = await getTestClientWithUser(user); + jest.spyOn(client, 'queryUsers').mockResolvedValue({ users: [user] }); + jest.spyOn(client, 'queryChannels').mockImplementation(); + const { typeText } = await renderSearch({ client }); + await act(() => { + typeText(typedText); + }); + + await act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(client.queryUsers).toHaveBeenCalledWith( + expect.objectContaining({ + $or: [{ id: { $autocomplete: typedText } }, { name: { $autocomplete: typedText } }], + id: { $ne: 'id' }, + }), + { id: 1 }, + { limit: 8 }, + ); + expect(client.queryUsers).toHaveBeenCalledTimes(1); + expect(client.queryChannels).not.toHaveBeenCalled(); + + jest.useRealTimers(); + }); + + it('search is performed on users and channels if enabled', async () => { + jest.useFakeTimers('modern'); + const client = await getTestClientWithUser(user); + jest.spyOn(client, 'queryUsers').mockResolvedValue({ users: [user] }); + jest.spyOn(client, 'queryChannels').mockResolvedValue([channelResponseData]); + + const { typeText } = await renderSearch({ client, props: { searchForChannels: true } }); + await act(() => { + typeText(typedText); + }); + + await act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(client.queryUsers).toHaveBeenCalledTimes(1); + expect(client.queryChannels).toHaveBeenCalledTimes(1); + + jest.useRealTimers(); + }); + + it('does not perform search queries when the search is disabled', async () => { + jest.useFakeTimers('modern'); + const client = await getTestClientWithUser(user); + jest.spyOn(client, 'queryUsers').mockResolvedValue({ users: [user] }); + jest.spyOn(client, 'queryChannels').mockImplementation(); + const { typeText } = await renderSearch({ client, props: { disabled: true } }); + await act(() => { + typeText(typedText); + }); + + await act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(client.queryUsers).not.toHaveBeenCalled(); + expect(client.queryChannels).not.toHaveBeenCalled(); + + jest.useRealTimers(); + }); + + it('ignores the queries in progress upon clearing the input', async () => { + jest.useFakeTimers('modern'); + const client = await getTestClientWithUser(user); + jest.spyOn(client, 'queryUsers').mockResolvedValue({ users: [user] }); + + const { typeText } = await renderSearch({ client }); + await act(() => { + typeText(typedText); + }); + + await act(() => { + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_INTERVAL); + }); + + await act(() => { + fireEvent.click(screen.getByTestId(TEST_ID.CLEAR_INPUT_BUTTON)); + }); + + expect(client.queryUsers).toHaveBeenCalledTimes(1); + expect(screen.queryByTestId(TEST_ID.CHANNEL_SEARCH_RESULTS_HEADER)).not.toBeInTheDocument(); + expect(screen.queryByTestId(TEST_ID.SEARCH_IN_PROGRESS_INDICATOR)).not.toBeInTheDocument(); + jest.useRealTimers(); + }); + + it('ignores the queries in progress upon deleting the last character', async () => { + jest.useFakeTimers('modern'); + const client = await getTestClientWithUser(user); + jest.spyOn(client, 'queryUsers').mockResolvedValue({ users: [user] }); + + const { typeText } = await renderSearch({ client }); + await act(() => { + typeText(typedText); + }); + + await act(() => { + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_INTERVAL); + }); + + await act(() => { + typeText(''); + }); + + expect(client.queryUsers).toHaveBeenCalledTimes(1); + expect(screen.queryByTestId(TEST_ID.CHANNEL_SEARCH_RESULTS_HEADER)).not.toBeInTheDocument(); + expect(screen.queryByTestId(TEST_ID.SEARCH_IN_PROGRESS_INDICATOR)).not.toBeInTheDocument(); + jest.useRealTimers(); + }); + + it('debounces the queries upon typing', async () => { + jest.useFakeTimers('modern'); + const textToQuery = 'x'; + const client = await getTestClientWithUser(user); + jest.spyOn(client, 'queryUsers').mockResolvedValue({ users: [user] }); + + const { typeText } = await renderSearch({ client }); + await act(() => { + typeText(typedText); + }); + + await act(() => { + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_INTERVAL - 1); + }); + + expect(client.queryUsers).not.toHaveBeenCalled(); + + await act(() => { + typeText(textToQuery); + }); + + await act(() => { + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_INTERVAL); + }); + + expect(client.queryUsers).toHaveBeenCalledTimes(1); + expect(client.queryUsers).toHaveBeenCalledWith( + expect.objectContaining({ + $or: [{ id: { $autocomplete: textToQuery } }, { name: { $autocomplete: textToQuery } }], + id: { $ne: 'id' }, + }), + { id: 1 }, + { limit: 8 }, + ); + jest.useRealTimers(); + }); + + it('allows to configure the search query debounce interval', async () => { + jest.useFakeTimers('modern'); + const textToQuery = 'x'; + const newDebounceInterval = DEFAULT_DEBOUNCE_INTERVAL - 100; + const client = await getTestClientWithUser(user); + jest.spyOn(client, 'queryUsers').mockResolvedValue({ users: [user] }); + + const { typeText } = await renderSearch({ + client, + props: { searchDebounceIntervalMs: newDebounceInterval }, + }); + await act(() => { + typeText(typedText); + }); + + await act(() => { + jest.advanceTimersByTime(newDebounceInterval - 1); + }); + + expect(client.queryUsers).not.toHaveBeenCalled(); + + await act(() => { + typeText(textToQuery); + }); + + await act(() => { + jest.advanceTimersByTime(newDebounceInterval); + }); + + expect(client.queryUsers).toHaveBeenCalledTimes(1); + expect(client.queryUsers).toHaveBeenCalledWith( + expect.objectContaining({ + $or: [{ id: { $autocomplete: textToQuery } }, { name: { $autocomplete: textToQuery } }], + id: { $ne: 'id' }, + }), + { id: 1 }, + { limit: 8 }, + ); + jest.useRealTimers(); + }); + + it('calls custom search function instead of the default', async () => { + jest.useFakeTimers('modern'); + const searchFunction = jest.fn(); + const client = await getTestClientWithUser(user); + jest.spyOn(client, 'queryUsers').mockResolvedValue({ users: [user] }); + const { typeText } = await renderSearch({ client, props: { searchFunction } }); + await act(() => { + typeText(typedText); + }); + + await act(() => { + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_INTERVAL); + }); + + expect(client.queryUsers).not.toHaveBeenCalled(); + expect(searchFunction).toHaveBeenCalledTimes(1); + jest.useRealTimers(); + }); + + it('calls custom onSearch callback', async () => { + jest.useFakeTimers('modern'); + const onSearch = jest.fn(); + const client = await getTestClientWithUser(user); + jest.spyOn(client, 'queryUsers').mockResolvedValue({ users: [user] }); + const { typeText } = await renderSearch({ client, props: { onSearch } }); + await act(() => { + typeText(typedText); + }); + + await act(() => { + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_INTERVAL); + }); + + expect(client.queryUsers).toHaveBeenCalledTimes(1); + expect(onSearch).toHaveBeenCalledTimes(1); + jest.useRealTimers(); }); }); diff --git a/src/components/ChannelSearch/__tests__/__snapshots__/ChannelSearch.test.js.snap b/src/components/ChannelSearch/__tests__/__snapshots__/ChannelSearch.test.js.snap new file mode 100644 index 000000000..556f611f6 --- /dev/null +++ b/src/components/ChannelSearch/__tests__/__snapshots__/ChannelSearch.test.js.snap @@ -0,0 +1,117 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ChannelSearch displays custom placeholder 1`] = ` + +`; + +exports[`ChannelSearch should render component without any props 1`] = ` + +`; diff --git a/src/components/ChannelSearch/hooks/useChannelSearch.ts b/src/components/ChannelSearch/hooks/useChannelSearch.ts index a9ba42c58..916b563ec 100644 --- a/src/components/ChannelSearch/hooks/useChannelSearch.ts +++ b/src/components/ChannelSearch/hooks/useChannelSearch.ts @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import throttle from 'lodash.throttle'; +import debounce from 'lodash.debounce'; import uniqBy from 'lodash.uniqby'; import { ChannelOrUserResponse, isChannel } from '../utils'; @@ -7,15 +7,15 @@ import { ChannelOrUserResponse, isChannel } from '../utils'; import { useChatContext } from '../../../context/ChatContext'; import type { + Channel, ChannelFilters, ChannelOptions, ChannelSort, UserFilters, UserOptions, + UsersAPIResponse, UserSort, } from 'stream-chat'; - -import type { Channel } from 'stream-chat'; import type { SearchBarController } from '../SearchBar'; import type { SearchInputController } from '../SearchInput'; import type { SearchResultsController } from '../SearchResults'; @@ -68,6 +68,8 @@ export type ChannelSearchParams< params: ChannelSearchFunctionParams, result: ChannelOrUserResponse, ) => Promise | void; + /** The number of milliseconds to debounce the search query. The default interval is 200ms. */ + searchDebounceIntervalMs?: number; /** Boolean to search for channels as well as users in the server query, default is false and just searches for users */ searchForChannels?: boolean; /** Custom search function to override the default implementation */ @@ -95,6 +97,7 @@ export const useChannelSearch = < onSearch: onSearchCallback, onSearchExit, onSelectResult, + searchDebounceIntervalMs = 300, searchForChannels = false, searchFunction, searchQueryParams, @@ -109,6 +112,12 @@ export const useChannelSearch = < const [results, setResults] = useState>>([]); const [searching, setSearching] = useState(false); + const searchQueryPromiseInProgress = useRef< + | Promise> + | Promise<[Channel[], UsersAPIResponse]> + >(); + const shouldIgnoreQueryResults = useRef(false); + const inputRef = useRef(null); const searchBarRef = useRef(null); @@ -116,6 +125,10 @@ export const useChannelSearch = < setQuery(''); setResults([]); setSearching(false); + + if (searchQueryPromiseInProgress.current) { + shouldIgnoreQueryResults.current = true; + } }, []); const activateSearch = useCallback(() => { @@ -198,11 +211,9 @@ export const useChannelSearch = < const getChannels = useCallback( async (text: string) => { - if (!text || searching) return; - setSearching(true); - + let results: ChannelOrUserResponse[] = []; try { - const userResponse = await client.queryUsers( + const userQueryPromise = client.queryUsers( // @ts-expect-error { $or: [{ id: { $autocomplete: text } }, { name: { $autocomplete: text } }], @@ -213,8 +224,12 @@ export const useChannelSearch = < { limit: 8, ...searchQueryParams?.userFilters?.options }, ); - if (searchForChannels) { - const channelResponse = client.queryChannels( + if (!searchForChannels) { + searchQueryPromiseInProgress.current = userQueryPromise; + const { users } = await searchQueryPromiseInProgress.current; + results = users; + } else { + const channelQueryPromise = client.queryChannels( // @ts-expect-error { name: { $autocomplete: text }, @@ -224,27 +239,35 @@ export const useChannelSearch = < { limit: 5, ...searchQueryParams?.channelFilters?.options }, ); - const [channels, { users }] = await Promise.all([channelResponse, userResponse]); + searchQueryPromiseInProgress.current = Promise.all([ + channelQueryPromise, + userQueryPromise, + ]); - setResults([...channels, ...users]); - setSearching(false); - return; - } - - const { users } = await Promise.resolve(userResponse); + const [channels, { users }] = await searchQueryPromiseInProgress.current; - setResults(users); + results = [...channels, ...users]; + } } catch (error) { - clearState(); console.error(error); } - setSearching(false); + + if (!shouldIgnoreQueryResults.current) { + setResults(results); + } else { + shouldIgnoreQueryResults.current = false; + } + + searchQueryPromiseInProgress.current = undefined; }, - [client, searching, searchForChannels], + [client, searchForChannels, searchQueryParams], ); - const getChannelsThrottled = throttle(getChannels, 200); + const scheduleGetChannels = useCallback(debounce(getChannels, searchDebounceIntervalMs), [ + getChannels, + searchDebounceIntervalMs, + ]); const onSearch = useCallback( (event: React.ChangeEvent) => { @@ -260,13 +283,17 @@ export const useChannelSearch = < }, event, ); - } else { + } else if (event.target.value) { + setSearching(true); setQuery(event.target.value); - getChannelsThrottled(event.target.value); + scheduleGetChannels(event.target.value); + } else if (!event.target.value) { + clearState(); + scheduleGetChannels.cancel(); } onSearchCallback?.(event); }, - [disabled, getChannelsThrottled, onSearchCallback, searchFunction], + [clearState, disabled, scheduleGetChannels, onSearchCallback, searchFunction], ); return { diff --git a/src/mock-builders/api/getOrCreateChannel.js b/src/mock-builders/api/getOrCreateChannel.js index 10f3e7dbe..7293886f4 100644 --- a/src/mock-builders/api/getOrCreateChannel.js +++ b/src/mock-builders/api/getOrCreateChannel.js @@ -13,6 +13,7 @@ export const getOrCreateChannelApi = ( members: [], messages: [], pinnedMessages: [], + read: [], }, ) => { const result = { @@ -21,6 +22,7 @@ export const getOrCreateChannelApi = ( members: channel.members, messages: channel.messages, pinnedMessages: channel.pinnedMessages, + read: channel.read, }; return mockedApiResponse(result, 'post'); diff --git a/src/mock-builders/event/index.js b/src/mock-builders/event/index.js index 573883c13..f77ed0401 100644 --- a/src/mock-builders/event/index.js +++ b/src/mock-builders/event/index.js @@ -7,6 +7,7 @@ export { default as dispatchConnectionChangedEvent } from './connectionChanged'; export { default as dispatchConnectionRecoveredEvent } from './connectionRecovered'; export { default as dispatchMessageDeletedEvent } from './messageDeleted'; export { default as dispatchMessageNewEvent } from './messageNew'; +export { default as dispatchMessageReadEvent } from './messageRead'; export { default as dispatchMessageUpdatedEvent } from './messageUpdated'; export { default as dispatchNotificationAddedToChannelEvent } from './notificationAddedToChannel'; export { default as dispatchNotificationMessageNewEvent } from './notificationMessageNew';