- {showChannelSearch &&
}
-
- {!loadedChannels?.length ? (
-
- ) : (
-
- {renderChannels
- ? renderChannels(loadedChannels, renderChannel)
- : loadedChannels.map(renderChannel)}
-
- )}
-
+
+ {showChannelSearch && (
+
+ )}
+ {showChannelList && (
+
+ {!loadedChannels?.length ? (
+
+ ) : (
+
+ {renderChannels
+ ? renderChannels(loadedChannels, renderChannel)
+ : loadedChannels.map((channel) => renderChannel(channel))}
+
+ )}
+
+ )}
>
);
diff --git a/src/components/ChannelList/__tests__/ChannelList.test.js b/src/components/ChannelList/__tests__/ChannelList.test.js
index 46cd6213b..552840478 100644
--- a/src/components/ChannelList/__tests__/ChannelList.test.js
+++ b/src/components/ChannelList/__tests__/ChannelList.test.js
@@ -1,10 +1,9 @@
import React from 'react';
-import { getNodeText } from '@testing-library/dom';
-import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react';
+import { nanoid } from 'nanoid';
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { toHaveNoViolations } from 'jest-axe';
import { axe } from '../../../../axe-helper';
-expect.extend(toHaveNoViolations);
import {
dispatchChannelDeletedEvent,
@@ -19,18 +18,17 @@ import {
dispatchNotificationRemovedFromChannel,
erroredPostApi,
generateChannel,
+ generateMember,
generateMessage,
generateUser,
getOrCreateChannelApi,
getTestClientWithUser,
queryChannelsApi,
+ queryUsersApi,
useMockedApis,
} from 'mock-builders';
-import { nanoid } from 'nanoid';
-import { ChatContext } from '../../../context';
import { Chat } from '../../Chat';
-
import { ChannelList } from '../ChannelList';
import {
ChannelPreviewCompact,
@@ -38,6 +36,10 @@ import {
ChannelPreviewMessenger,
} from '../../ChannelPreview';
+import { ChatContext } from '../../../context/ChatContext';
+
+expect.extend(toHaveNoViolations);
+
const channelsQueryStateMock = {
error: null,
queryInProgress: null,
@@ -72,6 +74,8 @@ const ChannelListComponent = (props) => {
return
{props.children}
;
};
const ROLE_LIST_ITEM_SELECTOR = '[role="listitem"]';
+const SEARCH_RESULT_LIST_SELECTOR = '.str-chat__channel-search-result-list';
+const CHANNEL_LIST_SELECTOR = '.str-chat__channel-list-messenger';
describe('ChannelList', () => {
let chatClientUthred;
@@ -102,20 +106,23 @@ describe('ChannelList', () => {
useMockedApis(chatClientUthred, [queryChannelsApi([])]);
});
it('should call `closeMobileNav` prop function, when clicked outside ChannelList', async () => {
- const { container, getByRole, getByTestId } = render(
-
-
-
- ,
- );
-
+ let result;
+ await act(() => {
+ result = render(
+
+
+
+ ,
+ );
+ });
+ const { container, getByRole, getByTestId } = result;
// Wait for list of channels to load in DOM.
await waitFor(() => {
expect(getByRole('list')).toBeInTheDocument();
@@ -130,19 +137,23 @@ describe('ChannelList', () => {
});
it('should not call `closeMobileNav` prop function on click, if ChannelList is collapsed', async () => {
- const { container, getByRole, getByTestId } = render(
-
-
-
- ,
- );
+ let result;
+ await act(() => {
+ result = render(
+
+
+
+ ,
+ );
+ });
+ const { container, getByRole, getByTestId } = result;
// Wait for list of channels to load in DOM.
await waitFor(() => {
@@ -425,6 +436,266 @@ describe('ChannelList', () => {
expect(results).toHaveNoViolations();
});
});
+
+ describe('channel search', () => {
+ const inputText = 'xxxxxxxxxx';
+ const user1 = generateUser();
+ const user2 = generateUser();
+ const mockedChannels = Array.from({ length: 3 }, (_, i) =>
+ generateChannel({
+ channel: { image: `image-xxx-${i}`, name: `channel-xxx-${i}` },
+ members: [generateMember({ user: user1 }), generateMember({ user: user2 })],
+ messages: ' '
+ .repeat(20)
+ .split(' ')
+ .map((v, i) => generateMessage({ user: i % 3 ? user1 : user2 })),
+ }),
+ );
+
+ let client;
+ let channel;
+ beforeEach(async () => {
+ client = await getTestClientWithUser({ id: user1.id });
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ useMockedApis(client, [getOrCreateChannelApi(mockedChannels[0])]);
+ channel = client.channel('messaging', mockedChannels[0].id);
+ await channel.watch();
+ useMockedApis(client, [
+ queryChannelsApi(mockedChannels), // first API call goes to /channels endpoint
+ queryUsersApi([user1, user2]), // onSearch starts searching users first
+ ]);
+ });
+
+ const renderComponents = (chatContext = {}, channeListProps) =>
+ render(
+
+
+ ,
+ );
+
+ it.each([['1'], ['2']])(
+ "theme v%s should not render search results on input focus if user haven't started to type",
+ async (themeVersion) => {
+ const { container } = await renderComponents({ channel, client, themeVersion });
+ const input = screen.queryByTestId('search-input');
+ await act(() => {
+ fireEvent.focus(input);
+ });
+
+ await waitFor(() => {
+ expect(container.querySelector(SEARCH_RESULT_LIST_SELECTOR)).not.toBeInTheDocument();
+ expect(screen.queryByLabelText('Channel list')).toBeInTheDocument();
+ });
+ },
+ );
+ it.each([['1'], ['2']])(
+ 'theme v%s should not render inline search results if popupResults is true',
+ async (themeVersion) => {
+ const { container } = await renderComponents(
+ { channel, client, themeVersion },
+ { additionalChannelSearchProps: { popupResults: true } },
+ );
+ const input = screen.queryByTestId('search-input');
+ await act(() => {
+ fireEvent.change(input, {
+ target: {
+ value: inputText,
+ },
+ });
+ });
+ await waitFor(() => {
+ expect(
+ container.querySelector(`${SEARCH_RESULT_LIST_SELECTOR}.popup`),
+ ).toBeInTheDocument();
+ expect(screen.queryByLabelText('Channel list')).toBeInTheDocument();
+ });
+ },
+ );
+ it('theme v2 should render inline search results if popupResults is false', async () => {
+ const { container } = await renderComponents(
+ { channel, client, themeVersion: '2' },
+ { additionalChannelSearchProps: { popupResults: false } },
+ );
+ const input = screen.queryByTestId('search-input');
+ await act(() => {
+ fireEvent.change(input, {
+ target: {
+ value: inputText,
+ },
+ });
+ });
+ await waitFor(() => {
+ expect(
+ container.querySelector(`${SEARCH_RESULT_LIST_SELECTOR}.inline`),
+ ).toBeInTheDocument();
+ expect(screen.queryByLabelText('Channel list')).not.toBeInTheDocument();
+ });
+ });
+
+ it.each([
+ ['1', 'should not', false],
+ ['2', 'should not', false],
+ ['1', 'should', true],
+ ['2', 'should', true],
+ ])(
+ 'theme v%s %s unmount search results on result click, if configured',
+ async (themeVersion, _, clearSearchOnClickOutside) => {
+ const { container } = await renderComponents(
+ { channel, client, themeVersion },
+ { additionalChannelSearchProps: { clearSearchOnClickOutside } },
+ );
+ const input = screen.queryByTestId('search-input');
+ await act(() => {
+ fireEvent.change(input, {
+ target: {
+ value: inputText,
+ },
+ });
+ });
+
+ const searchResults = screen.queryAllByRole('option');
+ useMockedApis(client, [getOrCreateChannelApi(generateChannel())]);
+ await act(() => {
+ fireEvent.click(searchResults[0]);
+ });
+
+ await waitFor(() => {
+ if (clearSearchOnClickOutside) {
+ expect(container.querySelector(SEARCH_RESULT_LIST_SELECTOR)).not.toBeInTheDocument();
+ } else {
+ expect(container.querySelector(SEARCH_RESULT_LIST_SELECTOR)).toBeInTheDocument();
+ }
+ });
+ },
+ );
+
+ it.each([['1'], ['2']])(
+ 'theme v%s should unmount search results if user cleared the input',
+ async (themeVersion) => {
+ const { container } = await renderComponents({ channel, client, themeVersion });
+ const input = screen.queryByTestId('search-input');
+ await act(() => {
+ input.focus();
+ fireEvent.change(input, {
+ target: {
+ value: inputText,
+ },
+ });
+ });
+
+ await act(() => {
+ if (themeVersion === '2') {
+ const clearButton = screen.queryByTestId('clear-input-button');
+ fireEvent.click(clearButton);
+ } else {
+ fireEvent.change(input, {
+ target: {
+ value: '',
+ },
+ });
+ }
+ });
+ await waitFor(() => {
+ expect(container.querySelector(SEARCH_RESULT_LIST_SELECTOR)).not.toBeInTheDocument();
+ expect(container.querySelector(CHANNEL_LIST_SELECTOR)).toBeInTheDocument();
+ expect(input).toHaveValue('');
+ expect(input).toHaveFocus();
+ if (themeVersion === '2') {
+ expect(screen.queryByTestId('return-icon')).toBeInTheDocument();
+ }
+ });
+ },
+ );
+
+ it('theme v2 should unmount search results if user clicked the return button', async () => {
+ const { container } = await renderComponents({ channel, client, themeVersion: '2' });
+ const input = screen.queryByTestId('search-input');
+
+ await act(() => {
+ input.focus();
+ fireEvent.change(input, {
+ target: {
+ value: inputText,
+ },
+ });
+ });
+
+ const returnIcon = screen.queryByTestId('return-icon');
+ await act(() => {
+ fireEvent.click(returnIcon);
+ });
+ await waitFor(() => {
+ expect(container.querySelector(SEARCH_RESULT_LIST_SELECTOR)).not.toBeInTheDocument();
+ expect(input).not.toHaveFocus();
+ expect(input).toHaveValue('');
+ expect(returnIcon).not.toBeInTheDocument();
+ });
+ });
+ it.each([['1'], ['2']])(
+ 'theme v%s should add the selected result to the top of the channel list',
+ async (themeVersion) => {
+ const getComputedStyleMock = jest.spyOn(window, 'getComputedStyle');
+ getComputedStyleMock.mockReturnValue({
+ getPropertyValue: jest.fn().mockReturnValue(themeVersion),
+ });
+ await render(
+
+
+ ,
+ );
+
+ const channelNotInTheList = generateChannel({
+ channel: { name: 'channel-not-loaded-yet' },
+ });
+
+ await waitFor(() => {
+ expect(screen.queryAllByRole('option')).toHaveLength(3);
+ expect(screen.queryByText(channelNotInTheList.channel.name)).not.toBeInTheDocument();
+ });
+
+ useMockedApis(client, [queryChannelsApi([channelNotInTheList, ...mockedChannels])]);
+ const input = screen.queryByTestId('search-input');
+ await act(() => {
+ input.focus();
+ fireEvent.change(input, {
+ target: {
+ value: inputText,
+ },
+ });
+ });
+
+ const targetChannelPreview = screen.queryByText(channelNotInTheList.channel.name);
+ expect(targetChannelPreview).toBeInTheDocument();
+ await act(() => {
+ fireEvent.click(targetChannelPreview);
+ });
+
+ await waitFor(() => {
+ expect(screen.queryByText(channelNotInTheList.channel.name)).toBeInTheDocument();
+ if (themeVersion === '2') {
+ expect(screen.queryByTestId('return-icon')).not.toBeInTheDocument();
+ }
+ });
+ getComputedStyleMock.mockClear();
+ },
+ );
+ });
});
it('should call `renderChannels` function prop, if provided', async () => {
@@ -1032,15 +1303,13 @@ describe('ChannelList', () => {
expect(getByRole('list')).toBeInTheDocument();
});
- const updateCount = parseInt(getNodeText(getByTestId('channelUpdateCount')), 10);
+ const updateCount = parseInt(getByTestId('channelUpdateCount').textContent, 10);
useMockedApis(chatClientUthred, [queryChannelsApi([channel2])]);
act(() => dispatchConnectionRecoveredEvent(chatClientUthred));
await waitFor(() => {
- expect(parseInt(getNodeText(getByTestId('channelUpdateCount')), 10)).toBe(
- updateCount + 1,
- );
+ expect(parseInt(getByTestId('channelUpdateCount').textContent, 10)).toBe(updateCount + 1);
});
const results = await axe(container);
expect(results).toHaveNoViolations();
diff --git a/src/components/ChannelList/hooks/usePaginatedChannels.ts b/src/components/ChannelList/hooks/usePaginatedChannels.ts
index 8f6a882a0..14665451b 100644
--- a/src/components/ChannelList/hooks/usePaginatedChannels.ts
+++ b/src/components/ChannelList/hooks/usePaginatedChannels.ts
@@ -62,7 +62,7 @@ export const usePaginatedChannels = <
}
} catch (err) {
console.warn(err);
- setError(err);
+ setError(err as Error);
}
setQueryInProgress(null);
diff --git a/src/components/ChannelPreview/ChannelPreview.tsx b/src/components/ChannelPreview/ChannelPreview.tsx
index 175924ad9..2736334bd 100644
--- a/src/components/ChannelPreview/ChannelPreview.tsx
+++ b/src/components/ChannelPreview/ChannelPreview.tsx
@@ -2,7 +2,8 @@ import React, { useCallback, useEffect, useState } from 'react';
import { ChannelPreviewMessenger } from './ChannelPreviewMessenger';
import { useIsChannelMuted } from './hooks/useIsChannelMuted';
-import { getDisplayImage, getDisplayTitle, getLatestMessagePreview } from './utils';
+import { useChannelPreviewInfo } from './hooks/useChannelPreviewInfo';
+import { getLatestMessagePreview } from './utils';
import { ChatContextValue, useChatContext } from '../../context/ChatContext';
import { useTranslationContext } from '../../context/TranslationContext';
@@ -28,6 +29,8 @@ export type ChannelPreviewUIComponentProps<
lastMessage?: StreamMessage
;
/** Latest message preview to display, will be a string or JSX element supporting markdown. */
latestMessage?: string | JSX.Element;
+ /** Custom ChannelPreview click handler function */
+ onSelect?: (event: React.MouseEvent) => void;
/** Number of unread Messages */
unread?: number;
};
@@ -43,7 +46,11 @@ export type ChannelPreviewProps<
Avatar?: React.ComponentType;
/** Forces the update of preview component on channel update */
channelUpdateCount?: number;
+ /** Custom class for the channel preview root */
+ className?: string;
key?: string;
+ /** Custom ChannelPreview click handler function */
+ onSelect?: (event: React.MouseEvent) => void;
/** Custom UI component to display the channel preview in the list, defaults to and accepts same props as: [ChannelPreviewMessenger](https://github.com/GetStream/stream-chat-react/blob/master/src/components/ChannelPreview/ChannelPreviewMessenger.tsx) */
Preview?: React.ComponentType>;
/** Setter for selected Channel */
@@ -62,8 +69,7 @@ export const ChannelPreview = <
'ChannelPreview',
);
const { t, userLanguage } = useTranslationContext('ChannelPreview');
- const [displayTitle, setDisplayTitle] = useState(getDisplayTitle(channel, client.user));
- const [displayImage, setDisplayImage] = useState(getDisplayImage(channel, client.user));
+ const { displayImage, displayTitle } = useChannelPreviewInfo({ channel });
const [lastMessage, setLastMessage] = useState>(
channel.state.messages[channel.state.messages.length - 1],
@@ -110,24 +116,6 @@ export const ChannelPreview = <
};
}, [refreshUnreadCount, channelUpdateCount]);
- useEffect(() => {
- const handleEvent = () => {
- setDisplayTitle((displayTitle) => {
- const newDisplayTitle = getDisplayTitle(channel, client.user);
- return displayTitle !== newDisplayTitle ? newDisplayTitle : displayTitle;
- });
- setDisplayImage((displayImage) => {
- const newDisplayImage = getDisplayImage(channel, client.user);
- return displayImage !== newDisplayImage ? newDisplayImage : displayImage;
- });
- };
-
- client.on('user.updated', handleEvent);
- return () => {
- client.off('user.updated', handleEvent);
- };
- }, []);
-
if (!Preview) return null;
const latestMessage = getLatestMessagePreview(channel, t, userLanguage);
diff --git a/src/components/ChannelPreview/ChannelPreviewMessenger.tsx b/src/components/ChannelPreview/ChannelPreviewMessenger.tsx
index ea29101cf..f46f16028 100644
--- a/src/components/ChannelPreview/ChannelPreviewMessenger.tsx
+++ b/src/components/ChannelPreview/ChannelPreviewMessenger.tsx
@@ -1,4 +1,5 @@
import React, { useRef } from 'react';
+import clsx from 'clsx';
import { Avatar as DefaultAvatar } from '../Avatar';
@@ -15,9 +16,11 @@ const UnMemoizedChannelPreviewMessenger = <
active,
Avatar = DefaultAvatar,
channel,
+ className: customClassName = '',
displayImage,
displayTitle,
latestMessage,
+ onSelect: customOnSelectChannel,
setActiveChannel,
unread,
watchers,
@@ -25,14 +28,13 @@ const UnMemoizedChannelPreviewMessenger = <
const channelPreviewButton = useRef(null);
- const activeClass = active ? 'str-chat__channel-preview-messenger--active' : '';
- const unreadClass = unread && unread >= 1 ? 'str-chat__channel-preview-messenger--unread' : '';
-
const avatarName =
displayTitle || channel.state.messages[channel.state.messages.length - 1]?.user?.id;
- const onSelectChannel = () => {
- if (setActiveChannel) {
+ const onSelectChannel = (e: React.MouseEvent) => {
+ if (customOnSelectChannel) {
+ customOnSelectChannel(e);
+ } else if (setActiveChannel) {
setActiveChannel(channel, watchers);
}
if (channelPreviewButton?.current) {
@@ -44,7 +46,12 @@ const UnMemoizedChannelPreviewMessenger = <
= 1 && 'str-chat__channel-preview-messenger--unread',
+ customClassName,
+ )}
data-testid='channel-preview-button'
onClick={onSelectChannel}
ref={channelPreviewButton}
@@ -53,9 +60,16 @@ const UnMemoizedChannelPreviewMessenger = <
-
-
-
{displayTitle}
+
+
+
+ {displayTitle}
+
+ {!!unread && (
+
+ {unread}
+
+ )}
{latestMessage}
diff --git a/src/components/ChannelPreview/__tests__/ChannelPreviewMessenger.test.js b/src/components/ChannelPreview/__tests__/ChannelPreviewMessenger.test.js
index e8cbec50e..e2d30a77d 100644
--- a/src/components/ChannelPreview/__tests__/ChannelPreviewMessenger.test.js
+++ b/src/components/ChannelPreview/__tests__/ChannelPreviewMessenger.test.js
@@ -1,5 +1,5 @@
import React from 'react';
-import { fireEvent, render, waitFor } from '@testing-library/react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import renderer from 'react-test-renderer';
import { toHaveNoViolations } from 'jest-axe';
@@ -76,4 +76,18 @@ describe('ChannelPreviewMessenger', () => {
const results = await axe(container);
expect(results).toHaveNoViolations();
});
+
+ it('should render custom class name', () => {
+ const className = 'custom-xxx';
+ const { container } = render(renderComponent({ className }));
+ expect(container.querySelector(`.${className}`)).toBeInTheDocument();
+ });
+
+ it('should call custom onSelect function', () => {
+ const onSelect = jest.fn();
+ render(renderComponent({ onSelect }));
+ const previewButton = screen.queryByTestId('channel-preview-button');
+ fireEvent.click(previewButton);
+ expect(onSelect).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/src/components/ChannelPreview/__tests__/__snapshots__/ChannelPreviewMessenger.test.js.snap b/src/components/ChannelPreview/__tests__/__snapshots__/ChannelPreviewMessenger.test.js.snap
index a3c7876bb..546ee379a 100644
--- a/src/components/ChannelPreview/__tests__/__snapshots__/ChannelPreviewMessenger.test.js.snap
+++ b/src/components/ChannelPreview/__tests__/__snapshots__/ChannelPreviewMessenger.test.js.snap
@@ -7,7 +7,7 @@ exports[`ChannelPreviewMessenger should render correctly 1`] = `
>
-
- Channel name
-
+
+
+ Channel name
+
+
+
+ 10
+
= {
+ channel: Channel
;
+ /** Manually set the image to render, defaults to the Channel image */
+ overrideImage?: string;
+ /** Set title manually */
+ overrideTitle?: string;
+};
+
+export const useChannelPreviewInfo = <
+ StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
+>(
+ props: ChannelPreviewInfoParams,
+) => {
+ const { channel, overrideImage, overrideTitle } = props;
+
+ const { client } = useChatContext('ChannelPreview');
+ const [displayTitle, setDisplayTitle] = useState(getDisplayTitle(channel, client.user));
+ const [displayImage, setDisplayImage] = useState(getDisplayImage(channel, client.user));
+
+ useEffect(() => {
+ const handleEvent = () => {
+ setDisplayTitle((displayTitle) => {
+ const newDisplayTitle = getDisplayTitle(channel, client.user);
+ return displayTitle !== newDisplayTitle ? newDisplayTitle : displayTitle;
+ });
+ setDisplayImage((displayImage) => {
+ const newDisplayImage = getDisplayImage(channel, client.user);
+ return displayImage !== newDisplayImage ? newDisplayImage : displayImage;
+ });
+ };
+
+ client.on('user.updated', handleEvent);
+ return () => {
+ client.off('user.updated', handleEvent);
+ };
+ }, []);
+
+ return {
+ displayImage: overrideImage || displayImage,
+ displayTitle: overrideTitle || displayTitle,
+ };
+};
diff --git a/src/components/ChannelSearch/ChannelSearch.tsx b/src/components/ChannelSearch/ChannelSearch.tsx
index c771f9e01..8f6d54700 100644
--- a/src/components/ChannelSearch/ChannelSearch.tsx
+++ b/src/components/ChannelSearch/ChannelSearch.tsx
@@ -1,76 +1,34 @@
-import React, { useEffect, useRef, useState } from 'react';
-import throttle from 'lodash.throttle';
+import React from 'react';
+import { useChatContext } from '../../context/ChatContext';
+
+import { ChannelSearchControllerParams, useChannelSearch } from './hooks/useChannelSearch';
+
+import { SearchBar as DefaultSearchBar } from './SearchBar';
import {
- ChannelSearchFunctionParams,
+ AdditionalSearchInputProps,
SearchInput as DefaultSearchInput,
SearchInputProps,
} from './SearchInput';
-import { DropdownContainerProps, SearchResultItemProps, SearchResults } from './SearchResults';
-
-import { ChannelOrUserResponse, isChannel } from './utils';
-
-import { useChatContext } from '../../context/ChatContext';
-
-import type {
- ChannelFilters,
- ChannelOptions,
- ChannelSort,
- UserFilters,
- UserOptions,
- UserSort,
-} from 'stream-chat';
+import { AdditionalSearchResultsProps, SearchResults } from './SearchResults';
import type { DefaultStreamChatGenerics } from '../../types/types';
+import type { AdditionalSearchBarProps, SearchBarProps } from './SearchBar';
-export type SearchQueryParams<
- StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
-> = {
- channelFilters?: {
- filters?: ChannelFilters;
- options?: ChannelOptions;
- sort?: ChannelSort;
- };
- userFilters?: {
- filters?: UserFilters;
- options?: UserOptions;
- sort?: UserSort;
- };
+export type AdditionalChannelSearchProps = {
+ /** Custom UI component to display the search bar with text input */
+ SearchBar?: React.ComponentType;
+ /** Custom UI component to display the search text input */
+ SearchInput?: React.ComponentType;
};
export type ChannelSearchProps<
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
-> = {
- /** The type of channel to create on user result select, defaults to `messaging` */
- channelType?: string;
- /** Custom UI component to display all of the search results, defaults to accepts same props as: [DefaultDropdownContainer](https://github.com/GetStream/stream-chat-react/blob/master/src/components/ChannelSearch/SearchResults.tsx) */
- DropdownContainer?: React.ComponentType>;
- /** Custom handler function to run on search result item selection */
- onSelectResult?: (result: ChannelOrUserResponse) => Promise | void;
- /** Custom placeholder text to be displayed in the search input */
- placeholder?: string;
- /** Display search results as an absolutely positioned popup, defaults to false and shows inline */
- popupResults?: boolean;
- /** Custom UI component to display empty search results */
- SearchEmpty?: React.ComponentType;
- /** 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 default */
- searchFunction?: (
- params: ChannelSearchFunctionParams,
- event: React.BaseSyntheticEvent,
- ) => Promise | void;
- /** Custom UI component to display the search text input */
- SearchInput?: React.ComponentType>;
- /** Custom UI component to display the search loading state */
- SearchLoading?: React.ComponentType;
- /** Object containing filters/sort/options overrides for user search */
- searchQueryParams?: SearchQueryParams;
- /** Custom UI component to display a search result list item, defaults to and accepts same props as: [DefaultSearchResultItem](https://github.com/GetStream/stream-chat-react/blob/master/src/components/ChannelSearch/SearchResults.tsx) */
- SearchResultItem?: React.ComponentType>;
- /** Custom UI component to display the search results header */
- SearchResultsHeader?: React.ComponentType;
-};
+> = AdditionalSearchBarProps &
+ AdditionalSearchInputProps &
+ AdditionalSearchResultsProps &
+ AdditionalChannelSearchProps &
+ ChannelSearchControllerParams;
const UnMemoizedChannelSearch = <
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
@@ -78,140 +36,73 @@ const UnMemoizedChannelSearch = <
props: ChannelSearchProps,
) => {
const {
- channelType = 'messaging',
- DropdownContainer,
- onSelectResult,
+ AppMenu,
+ ClearInputIcon,
+ ExitSearchIcon,
+ MenuIcon,
placeholder,
popupResults = false,
+ SearchBar = DefaultSearchBar,
SearchEmpty,
- searchForChannels = false,
- searchFunction,
SearchInput = DefaultSearchInput,
SearchLoading,
- searchQueryParams,
+ SearchInputIcon,
SearchResultItem,
+ SearchResultsList,
SearchResultsHeader,
+ ...channelSearchParams
} = props;
+ const { themeVersion } = useChatContext('ChannelSearch');
- const { client, setActiveChannel } = useChatContext('ChannelSearch');
-
- const [query, setQuery] = useState('');
- const [results, setResults] = useState>>([]);
- const [resultsOpen, setResultsOpen] = useState(false);
- const [searching, setSearching] = useState(false);
-
- const inputRef = useRef(null);
-
- const clearState = () => {
- setQuery('');
- setResults([]);
- setResultsOpen(false);
- setSearching(false);
- };
-
- useEffect(() => {
- const clickListener = (event: MouseEvent) => {
- if (resultsOpen && event.target instanceof HTMLElement) {
- const isInputClick = inputRef.current?.contains(event.target);
- if (!isInputClick) {
- clearState();
- }
- }
- };
-
- document.addEventListener('click', clickListener);
- return () => document.removeEventListener('click', clickListener);
- }, [resultsOpen]);
-
- const selectResult = async (result: ChannelOrUserResponse) => {
- if (!client.userID) return;
-
- if (isChannel(result)) {
- setActiveChannel(result);
- } else {
- const newChannel = client.channel(channelType, { members: [client.userID, result.id] });
- await newChannel.watch();
-
- setActiveChannel(newChannel);
- }
- clearState();
- };
-
- const getChannels = async (text: string) => {
- if (!text || searching) return;
- setSearching(true);
-
- try {
- const userResponse = await client.queryUsers(
- // @ts-expect-error
- {
- $or: [{ id: { $autocomplete: text } }, { name: { $autocomplete: text } }],
- id: { $ne: client.userID },
- ...searchQueryParams?.userFilters?.filters,
- },
- { id: 1, ...searchQueryParams?.userFilters?.sort },
- { limit: 8, ...searchQueryParams?.userFilters?.options },
- );
-
- if (searchForChannels) {
- const channelResponse = client.queryChannels(
- // @ts-expect-error
- {
- name: { $autocomplete: text },
- ...searchQueryParams?.channelFilters?.filters,
- },
- searchQueryParams?.channelFilters?.sort || {},
- { limit: 5, ...searchQueryParams?.channelFilters?.options },
- );
-
- const [channels, { users }] = await Promise.all([channelResponse, userResponse]);
-
- setResults([...channels, ...users]);
- setResultsOpen(true);
- setSearching(false);
- return;
- }
-
- const { users } = await Promise.resolve(userResponse);
-
- setResults(users);
- setResultsOpen(true);
- } catch (error) {
- clearState();
- console.error(error);
- }
-
- setSearching(false);
- };
-
- const getChannelsThrottled = throttle(getChannels, 200);
-
- const onSearch = (event: React.BaseSyntheticEvent) => {
- event.preventDefault();
- setQuery(event.target.value);
- getChannelsThrottled(event.target.value);
- };
-
- const channelSearchParams = {
- setQuery,
- setResults,
- setResultsOpen,
- setSearching,
- };
+ const {
+ activateSearch,
+ clearState,
+ exitSearch,
+ inputIsFocused,
+ inputRef,
+ onSearch,
+ query,
+ results,
+ searchBarRef,
+ searching,
+ selectResult,
+ } = useChannelSearch(channelSearchParams);
+
+ const showSearchBarV2 = themeVersion === '2';
return (
-
+ {showSearchBarV2 ? (
+
+ ) : (
+
+ )}
{query && (
)}
diff --git a/src/components/ChannelSearch/SearchBar.tsx b/src/components/ChannelSearch/SearchBar.tsx
new file mode 100644
index 000000000..83d679767
--- /dev/null
+++ b/src/components/ChannelSearch/SearchBar.tsx
@@ -0,0 +1,180 @@
+import React, {
+ MouseEventHandler,
+ PropsWithChildren,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
+import clsx from 'clsx';
+
+import {
+ MenuIcon as DefaultMenuIcon,
+ SearchIcon as DefaultSearchInputIcon,
+ ReturnIcon,
+ XIcon,
+} from './icons';
+import { SearchInput as DefaultSearchInput, SearchInputProps } from './SearchInput';
+
+type SearchBarButtonProps = {
+ className?: string;
+ onClick?: MouseEventHandler;
+};
+
+const SearchBarButton = ({
+ children,
+ className,
+ onClick,
+}: PropsWithChildren) => (
+
+ {children}
+
+);
+
+export type SearchBarController = {
+ /** Called on search input focus */
+ activateSearch: () => void;
+ /** Clears the search state, removes focus from the search input */
+ exitSearch: () => void;
+ /** Flag determining whether the search input is focused */
+ inputIsFocused: boolean;
+ /** Ref object for the input wrapper in the SearchBar */
+ searchBarRef: React.RefObject;
+};
+
+export type AdditionalSearchBarProps = {
+ /** Application menu to be displayed when clicked on MenuIcon */
+ AppMenu?: React.ComponentType;
+ /** Custom icon component used to clear the input value on click. Displayed within the search input wrapper. */
+ ClearInputIcon?: React.ComponentType;
+ /** Custom icon component used to terminate the search UI session on click. */
+ ExitSearchIcon?: React.ComponentType;
+ /** Custom icon component used to invoke context menu. */
+ MenuIcon?: React.ComponentType;
+ /** Custom UI component to display the search text input */
+ SearchInput?: React.ComponentType;
+ /** Custom icon used to indicate search input. */
+ SearchInputIcon?: React.ComponentType;
+};
+
+export type SearchBarProps = AdditionalSearchBarProps & SearchBarController & SearchInputProps;
+
+// todo: add context menu control logic
+export const SearchBar = (props: SearchBarProps) => {
+ const {
+ activateSearch,
+ AppMenu,
+ ClearInputIcon = XIcon,
+ exitSearch,
+ ExitSearchIcon = ReturnIcon,
+ inputIsFocused,
+ MenuIcon = DefaultMenuIcon,
+ searchBarRef,
+ SearchInput = DefaultSearchInput,
+ SearchInputIcon = DefaultSearchInputIcon,
+ ...inputProps
+ } = props;
+
+ const [menuIsOpen, setMenuIsOpen] = useState(false);
+ const appMenuRef = useRef(null);
+
+ useEffect(() => {
+ if (!appMenuRef.current) return;
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (menuIsOpen && event.key === 'Escape') {
+ setMenuIsOpen(false);
+ }
+ };
+
+ const clickListener = (e: MouseEvent) => {
+ if (
+ !(e.target instanceof HTMLElement) ||
+ !menuIsOpen ||
+ appMenuRef.current?.contains(e.target)
+ )
+ return;
+ setMenuIsOpen(false);
+ };
+
+ document.addEventListener('keydown', handleKeyDown);
+ document.addEventListener('click', clickListener);
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown);
+ document.removeEventListener('click', clickListener);
+ };
+ }, [menuIsOpen]);
+
+ useEffect(() => {
+ if (!props.inputRef.current) return;
+
+ const handleFocus = () => {
+ activateSearch();
+ };
+
+ const handleBlur = (e: Event) => {
+ e.stopPropagation(); // handle blur/focus state with React state
+ };
+
+ props.inputRef.current.addEventListener('focus', handleFocus);
+ props.inputRef.current.addEventListener('blur', handleBlur);
+ return () => {
+ props.inputRef.current?.removeEventListener('focus', handleFocus);
+ props.inputRef.current?.addEventListener('blur', handleBlur);
+ };
+ }, []);
+
+ const handleClearClick = useCallback(() => {
+ exitSearch();
+ inputProps.inputRef.current?.focus();
+ }, []);
+
+ return (
+
+ {inputIsFocused ? (
+
+
+
+ ) : AppMenu ? (
+
setMenuIsOpen((prev) => !prev)}
+ >
+
+
+ ) : null}
+
+
+ {menuIsOpen && AppMenu && (
+
+ )}
+
+ );
+};
diff --git a/src/components/ChannelSearch/SearchInput.tsx b/src/components/ChannelSearch/SearchInput.tsx
index d5dd7f1e2..93d90d97f 100644
--- a/src/components/ChannelSearch/SearchInput.tsx
+++ b/src/components/ChannelSearch/SearchInput.tsx
@@ -2,56 +2,36 @@ import React from 'react';
import { useTranslationContext } from '../../context/TranslationContext';
-import type { ChannelOrUserResponse } from './utils';
-
-import type { DefaultStreamChatGenerics } from '../../types/types';
-
-export type ChannelSearchFunctionParams<
- StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
-> = {
- setQuery: React.Dispatch>;
- setResults: React.Dispatch<
- React.SetStateAction>>
- >;
- setResultsOpen: React.Dispatch>;
- setSearching: React.Dispatch>;
-};
-
-export type SearchInputProps<
- StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
-> = {
- channelSearchParams: {
- setQuery: React.Dispatch>;
- setResults: React.Dispatch[]>>;
- setResultsOpen: React.Dispatch>;
- setSearching: React.Dispatch>;
- };
+export type SearchInputController = {
+ /** Clears the channel search state */
+ clearState: () => void;
inputRef: React.RefObject;
- onSearch: (event: React.BaseSyntheticEvent) => void;
+ /** Search input change handler */
+ onSearch: React.ChangeEventHandler;
+ /** Current search string */
query: string;
+};
+
+export type AdditionalSearchInputProps = {
+ /** Sets the input element into disabled state */
+ disabled?: boolean;
/** Custom placeholder text to be displayed in the search input */
placeholder?: string;
- searchFunction?: (
- params: ChannelSearchFunctionParams,
- event: React.BaseSyntheticEvent,
- ) => Promise | void;
};
-export const SearchInput = <
- StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
->(
- props: SearchInputProps,
-) => {
- const { channelSearchParams, inputRef, onSearch, placeholder, query, searchFunction } = props;
+export type SearchInputProps = AdditionalSearchInputProps & SearchInputController;
+
+export const SearchInput = (props: SearchInputProps) => {
+ const { disabled, inputRef, onSearch, placeholder, query } = props;
const { t } = useTranslationContext('SearchInput');
return (
- searchFunction ? searchFunction(channelSearchParams, event) : onSearch(event)
- }
+ data-testid='search-input'
+ disabled={disabled}
+ onChange={onSearch}
placeholder={placeholder ?? t('Search')}
ref={inputRef}
type='text'
diff --git a/src/components/ChannelSearch/SearchResults.tsx b/src/components/ChannelSearch/SearchResults.tsx
index fd7a8f117..04c2e04ec 100644
--- a/src/components/ChannelSearch/SearchResults.tsx
+++ b/src/components/ChannelSearch/SearchResults.tsx
@@ -1,27 +1,60 @@
-import React, { useCallback, useEffect, useState } from 'react';
+import React, { PropsWithChildren, useCallback, useEffect, useState } from 'react';
+import clsx from 'clsx';
+import { SearchIcon } from './icons';
+import { ChannelPreview } from '../ChannelPreview';
import { ChannelOrUserResponse, isChannel } from './utils';
+import { Avatar } from '../Avatar';
-import { Avatar } from '../Avatar/Avatar';
-import { useBreakpoint } from '../Message/hooks/useBreakpoint';
+import { useChatContext, useTranslationContext } from '../../context';
-import { useTranslationContext } from '../../context/TranslationContext';
+import type { DefaultStreamChatGenerics } from '../../types/types';
-import type { DefaultStreamChatGenerics, PropsWithChildrenOnly } from '../../types/types';
+const DefaultSearchEmpty = () => {
+ const { t } = useTranslationContext('SearchResults');
+ return (
+
+
+ {t('No results found')}
+
+ );
+};
-export type DropdownContainerProps<
+export type SearchResultsHeaderProps<
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
-> = {
- results: ChannelOrUserResponse[];
- SearchResultItem: React.ComponentType>;
- selectResult: (result: ChannelOrUserResponse) => Promise | void;
+> = Pick, 'results'>;
+
+const DefaultSearchResultsHeader = <
+ StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
+>({
+ results,
+}: SearchResultsHeaderProps) => {
+ const { t } = useTranslationContext('SearchResultsHeader');
+ return (
+
+ {t('searchResultsCount', {
+ count: results.length,
+ })}
+
+ );
+};
+
+export type SearchResultsListProps<
+ StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
+> = Pick<
+ SearchResultsProps,
+ 'results' | 'SearchResultItem' | 'selectResult'
+> & {
focusedUser?: number;
};
-const DefaultDropdownContainer = <
+const DefaultSearchResultsList = <
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
>(
- props: DropdownContainerProps,
+ props: SearchResultsListProps,
) => {
const { focusedUser, results, SearchResultItem = DefaultSearchResultItem, selectResult } = props;
@@ -42,10 +75,9 @@ const DefaultDropdownContainer = <
export type SearchResultItemProps<
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
-> = {
+> = Pick, 'selectResult'> & {
index: number;
result: ChannelOrUserResponse;
- selectResult: (result: ChannelOrUserResponse) => Promise | void;
focusedUser?: number;
};
@@ -55,17 +87,30 @@ const DefaultSearchResultItem = <
props: SearchResultItemProps,
) => {
const { focusedUser, index, result, selectResult } = props;
-
const focused = focusedUser === index;
+ const { themeVersion } = useChatContext();
+
+ const className = clsx(
+ 'str-chat__channel-search-result',
+ focused && 'str-chat__channel-search-result--focused focused',
+ );
if (isChannel(result)) {
const channel = result;
- return (
+ return themeVersion === '2' ? (
+ selectResult(channel)}
+ />
+ ) : (
selectResult(channel)}
+ role='option'
>
#
{channel.data?.name}
@@ -75,70 +120,100 @@ const DefaultSearchResultItem = <
return (
selectResult(result)}
+ role='option'
>
-
- {result.name || result.id}
+
+
+ {result.name || result.id}
+
);
}
};
-export type SearchResultsProps<
+const ResultsContainer = ({
+ children,
+ popupResults,
+}: PropsWithChildren<{ popupResults?: boolean }>) => (
+
+ {children}
+
+);
+
+export type SearchResultsController<
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
> = {
results: Array> | [];
searching: boolean;
selectResult: (result: ChannelOrUserResponse) => Promise | void;
- DropdownContainer?: React.ComponentType>;
+};
+
+export type AdditionalSearchResultsProps<
+ StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
+> = {
+ /** Display search results as an absolutely positioned popup, defaults to false and shows inline */
popupResults?: boolean;
+ /** Custom UI component to display empty search results */
SearchEmpty?: React.ComponentType;
+ /** Custom UI component to display the search loading state */
SearchLoading?: React.ComponentType;
+ /** Custom UI component to display a search result list item, defaults to and accepts the same props as: [DefaultSearchResultItem](https://github.com/GetStream/stream-chat-react/blob/master/src/components/ChannelSearch/SearchResults.tsx) */
SearchResultItem?: React.ComponentType>;
+ /** Custom UI component to display the search results header */
SearchResultsHeader?: React.ComponentType;
+ /** Custom UI component to display all the search results, defaults to and accepts the same props as: [DefaultSearchResultsList](https://github.com/GetStream/stream-chat-react/blob/master/src/components/ChannelSearch/SearchResults.tsx) */
+ SearchResultsList?: React.ComponentType>;
};
+export type SearchResultsProps<
+ StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
+> = AdditionalSearchResultsProps & SearchResultsController;
+
export const SearchResults = <
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
>(
props: SearchResultsProps,
) => {
const {
- DropdownContainer = DefaultDropdownContainer,
popupResults,
results,
searching,
- SearchEmpty,
- SearchResultsHeader,
+ SearchEmpty = DefaultSearchEmpty,
+ SearchResultsHeader = DefaultSearchResultsHeader,
SearchLoading,
SearchResultItem = DefaultSearchResultItem,
+ SearchResultsList = DefaultSearchResultsList,
selectResult,
} = props;
const { t } = useTranslationContext('SearchResults');
-
- const [focusedUser, setFocusedUser] = useState();
-
- const { device } = useBreakpoint();
-
- const containerStyle = popupResults && device === 'full' ? 'popup' : 'inline';
-
- const ResultsContainer = ({ children }: PropsWithChildrenOnly) => (
- {children}
- );
+ const [focusedResult, setFocusedResult] = useState();
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (event.key === 'ArrowUp') {
- setFocusedUser((prevFocused) => {
+ setFocusedResult((prevFocused) => {
if (prevFocused === undefined) return 0;
return prevFocused === 0 ? results.length - 1 : prevFocused - 1;
});
}
if (event.key === 'ArrowDown') {
- setFocusedUser((prevFocused) => {
+ setFocusedResult((prevFocused) => {
if (prevFocused === undefined) return 0;
return prevFocused === results.length - 1 ? 0 : prevFocused + 1;
});
@@ -146,13 +221,13 @@ export const SearchResults = <
if (event.key === 'Enter') {
event.preventDefault();
- if (focusedUser !== undefined) {
- selectResult(results[focusedUser]);
- return setFocusedUser(undefined);
+ if (focusedResult !== undefined) {
+ selectResult(results[focusedResult]);
+ return setFocusedResult(undefined);
}
}
},
- [focusedUser],
+ [focusedResult],
);
useEffect(() => {
@@ -162,11 +237,14 @@ export const SearchResults = <
if (searching) {
return (
-
+
{SearchLoading ? (
) : (
-
+
{t('Searching...')}
)}
@@ -176,23 +254,17 @@ export const SearchResults = <
if (!results.length) {
return (
-
- {SearchEmpty ? (
-
- ) : (
-
- {t('No results found')}
-
- )}
+
+
);
}
return (
-
- {SearchResultsHeader && }
-
+
+ {
>
{
>
AppMenu
;
+const ClearInputIcon = () => CustomClearInputIcon
;
+const MenuIcon = () => CustomMenuIcon
;
+const SearchInputIcon = () => CustomSearchInputIcon
;
+
+const SearchContainer = ({ props = {}, searchParams }) => {
+ const controller = useChannelSearch(searchParams);
+ return ;
+};
+
+const renderComponent = ({ client, props = {}, searchParams }) =>
+ render(
+
+
+ ,
+ );
+
+describe('SearchBar', () => {
+ beforeEach(async () => {
+ const user = generateUser();
+ client = await getTestClientWithUser({ id: user.id });
+ useMockedApis(client, [queryUsersApi([user])]); // eslint-disable-line react-hooks/rules-of-hooks
+ });
+
+ it.each([
+ ['enable', false, 'xxxxxxxxxx', 'xxxxxxxxxx'],
+ ['disable', true, 'xxxxxxxxxx', ''],
+ ])('should %s typing', async (_, disabled, inputText, expectedValue) => {
+ await renderComponent({ client, searchParams: { disabled } });
+
+ const input = screen.queryByTestId('search-input');
+
+ await act(() => {
+ fireEvent.change(input, {
+ target: {
+ value: inputText,
+ },
+ });
+ });
+
+ await waitFor(() => {
+ expect(input).toHaveValue(expectedValue);
+ });
+ });
+ it('should render default layout', () => {
+ expect(
+ renderer
+ .create(
+ ,
+ )
+ .toJSON(),
+ ).toMatchSnapshot();
+ });
+ it.each([
+ ['should not render', undefined],
+ ['should render', AppMenu],
+ ])('%s menu icon', async (_, AppMenu) => {
+ await render(
+ ,
+ );
+ await waitFor(() => {
+ if (!AppMenu) {
+ expect(screen.queryByTestId('menu-icon')).not.toBeInTheDocument();
+ } else {
+ expect(screen.queryByTestId('menu-icon')).toBeInTheDocument();
+ }
+ });
+ });
+ it('should render custom icons', async () => {
+ await render(
+ ,
+ );
+ expect(screen.queryByText('CustomClearInputIcon')).toBeInTheDocument();
+ expect(screen.queryByText('CustomMenuIcon')).toBeInTheDocument();
+ expect(screen.queryByText('CustomSearchInputIcon')).toBeInTheDocument();
+ });
+
+ it('should not render ExitSearchIcon if input is not focused', async () => {
+ await act(() => {
+ renderComponent({ client, searchParams: { disabled: false } });
+ });
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('return-icon')).not.toBeInTheDocument();
+ });
+ });
+
+ it('should render ExitSearchIcon on input focus', async () => {
+ await renderComponent({ client, searchParams: { disabled: false } });
+
+ const input = screen.queryByTestId('search-input');
+
+ await act(() => {
+ fireEvent.focus(input);
+ });
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('return-icon')).toBeInTheDocument();
+ });
+ });
+ it('should render custom ExitSearchIcon', async () => {
+ const ExitSearchIcon = () => CustomExitSearchIcon
;
+ await renderComponent({
+ client,
+ props: { ExitSearchIcon },
+ searchParams: { disabled: false },
+ });
+
+ const input = screen.queryByTestId('search-input');
+
+ await act(() => {
+ fireEvent.focus(input);
+ });
+
+ await waitFor(() => {
+ expect(screen.queryByText('CustomExitSearchIcon')).toBeInTheDocument();
+ });
+ });
+ it('should render custom input placeholder', async () => {
+ const placeholder = 'Type and search xxxx';
+ await act(() => {
+ renderComponent({
+ client,
+ props: { placeholder },
+ searchParams: { disabled: false },
+ });
+ });
+
+ await waitFor(() => {
+ expect(screen.queryByPlaceholderText(placeholder)).toBeInTheDocument();
+ });
+ });
+ it('should clear input', async () => {
+ renderComponent({ client, searchParams: { disabled: false } });
+
+ const input = screen.queryByTestId('search-input');
+
+ await act(() => {
+ input.focus();
+ fireEvent.change(input, {
+ target: {
+ value: inputText,
+ },
+ });
+ });
+
+ await waitFor(() => {
+ expect(input).toHaveValue(inputText);
+ });
+
+ const clearButton = screen.queryByTestId('clear-input-button');
+
+ await act(() => {
+ fireEvent.click(clearButton);
+ });
+
+ await waitFor(() => {
+ expect(input).toHaveValue('');
+ expect(input).toHaveFocus();
+ expect(screen.queryByTestId('return-icon')).toBeInTheDocument();
+ });
+ });
+
+ it.each([
+ [
+ 'on return button click',
+ (target) => {
+ fireEvent.click(target);
+ },
+ ],
+ [
+ 'on Escape key down',
+ (target) => {
+ fireEvent.keyDown(target, { key: 'Escape' });
+ },
+ ],
+ ])('should exit search UI %s', async (_case, doExitAction) => {
+ await renderComponent({ client, searchParams: { disabled: false } });
+
+ const input = screen.queryByTestId('search-input');
+
+ await act(() => {
+ input.focus();
+ fireEvent.change(input, {
+ target: {
+ value: inputText,
+ },
+ });
+ });
+
+ await waitFor(() => {
+ expect(input).toHaveValue(inputText);
+ });
+
+ const returnButton = screen.queryByTestId('search-bar-button');
+
+ await act(() => {
+ const target = _case === 'on return button click' ? returnButton : input;
+ doExitAction(target);
+ });
+
+ await waitFor(() => {
+ expect(input).toHaveValue('');
+ expect(input).not.toHaveFocus();
+ expect(screen.queryByTestId('return-icon')).not.toBeInTheDocument();
+ });
+ });
+
+ it('should render custom SearchInput', async () => {
+ const SearchInput = () => CustomSearchInput
;
+ await act(() => {
+ renderComponent({
+ client,
+ props: { SearchInput },
+ searchParams: { disabled: false },
+ });
+ });
+
+ await waitFor(() => {
+ expect(screen.queryByText('CustomSearchInput')).toBeInTheDocument();
+ expect(screen.queryByTestId('search-input')).not.toBeInTheDocument();
+ });
+ });
+
+ it('should toggle app menu render with menu icon click', async () => {
+ await act(() => {
+ renderComponent({
+ client,
+ props: { AppMenu },
+ searchParams: { disabled: false },
+ });
+ });
+ const menuIcon = screen.queryByTestId('menu-icon');
+ await act(() => {
+ fireEvent.click(menuIcon);
+ });
+ await waitFor(() => {
+ expect(screen.queryByText('AppMenu')).toBeInTheDocument();
+ });
+ await act(() => {
+ fireEvent.click(menuIcon);
+ });
+ await waitFor(() => {
+ expect(screen.queryByText('AppMenu')).not.toBeInTheDocument();
+ });
+ });
+
+ it.each([
+ [
+ 'on click outside',
+ (target) => {
+ fireEvent.click(target);
+ },
+ ],
+ [
+ 'on Escape key down',
+ (target) => {
+ fireEvent.keyDown(target, { key: 'Escape' });
+ },
+ ],
+ ])('should close app menu %s', async (_, doCloseAction) => {
+ await act(() => {
+ renderComponent({
+ client,
+ props: { AppMenu },
+ searchParams: { disabled: false },
+ });
+ });
+ const menuIcon = screen.queryByTestId('menu-icon');
+ const searchBar = screen.queryByTestId('search-bar');
+ await act(() => {
+ fireEvent.click(menuIcon);
+ });
+ await waitFor(() => {
+ expect(screen.queryByText('AppMenu')).toBeInTheDocument();
+ });
+
+ await act(() => {
+ doCloseAction(searchBar);
+ });
+
+ await waitFor(() => {
+ expect(screen.queryByText('AppMenu')).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/components/ChannelSearch/__tests__/SearchResults.test.js b/src/components/ChannelSearch/__tests__/SearchResults.test.js
new file mode 100644
index 000000000..fbf5ad5c2
--- /dev/null
+++ b/src/components/ChannelSearch/__tests__/SearchResults.test.js
@@ -0,0 +1,123 @@
+import React from 'react';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
+
+import { SearchResults } from '../SearchResults';
+
+import { ChatProvider } from '../../../context/ChatContext';
+
+import { createClientWithChannel, generateChannel, generateUser } from '../../../mock-builders';
+
+const SEARCH_RESULT_LIST_SELECTOR = '.str-chat__channel-search-result-list';
+
+const renderComponent = (props = {}, chatContext = { themeVersion: '2' }) =>
+ render(
+
+
+ ,
+ );
+
+describe('SearchResults', () => {
+ describe.each([['1'], ['2']])('version %s', (themeVersion) => {
+ it('should render loading indicator', () => {
+ renderComponent({ searching: true }, { themeVersion });
+ expect(screen.queryByTestId('search-in-progress-indicator')).toBeInTheDocument();
+ });
+
+ it('should not render loading indicator if search not in progress', () => {
+ renderComponent({ results: [] }, { themeVersion });
+ expect(screen.queryByTestId('search-in-progress-indicator')).not.toBeInTheDocument();
+ });
+
+ it('should render custom loading indicator if search in progress', () => {
+ const SearchLoading = () => CustomSearchLoading
;
+ renderComponent({ searching: true, SearchLoading }, { themeVersion });
+ expect(screen.queryByTestId('search-in-progress-indicator')).not.toBeInTheDocument();
+ expect(screen.queryByText('CustomSearchLoading')).toBeInTheDocument();
+ });
+
+ it('should not render custom loading indicator if search not in progress', () => {
+ const SearchLoading = () => CustomSearchLoading
;
+ renderComponent({ results: [], SearchLoading }, { themeVersion });
+ expect(screen.queryByTestId('search-in-progress-indicator')).not.toBeInTheDocument();
+ expect(screen.queryByText('CustomSearchLoading')).not.toBeInTheDocument();
+ });
+
+ it('should render empty search result indicator', () => {
+ renderComponent({ results: [] }, { themeVersion });
+ expect(screen.queryByText('No results found')).toBeInTheDocument();
+ });
+
+ it('should render custom empty search result indicator', () => {
+ const SearchEmpty = () => CustomSearchEmpty
;
+ renderComponent({ results: [], SearchEmpty }, { themeVersion });
+ expect(screen.queryByText('No results found')).not.toBeInTheDocument();
+ expect(screen.queryByText('CustomSearchEmpty')).toBeInTheDocument();
+ });
+ it('should render search results header', () => {
+ renderComponent({ results: [generateChannel()] }, { themeVersion });
+ expect(screen.queryByTestId('channel-search-results-header')).toBeInTheDocument();
+ });
+ it('should render custom search results header', () => {
+ const SearchResultsHeader = () => CustomSearchResultsHeader
;
+ renderComponent({ results: [generateChannel()], SearchResultsHeader }, { themeVersion });
+ expect(screen.queryByText('CustomSearchResultsHeader')).toBeInTheDocument();
+ });
+ it(`should render channel search result`, async () => {
+ const { channel, client } = await createClientWithChannel();
+ renderComponent({ results: [channel] }, { client, themeVersion });
+ expect(
+ screen.queryByTestId(
+ themeVersion === '1' ? 'channel-search-result-channel' : 'channel-preview-button',
+ ),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByTestId(
+ themeVersion === '1' ? 'channel-preview-button' : 'channel-search-result-channel',
+ ),
+ ).not.toBeInTheDocument();
+ });
+ it(`should render non-channel search result`, async () => {
+ const user = generateUser();
+ const { client } = await createClientWithChannel();
+ renderComponent({ results: [user] }, { client, themeVersion });
+ expect(screen.queryByTestId('channel-search-result-user')).toBeInTheDocument();
+ });
+ it('should render custom search results list', () => {
+ const SearchResultsList = () => CustomSearchResultsList
;
+ renderComponent({ results: [generateChannel()], SearchResultsList });
+ expect(screen.queryByText('CustomSearchResultsList')).toBeInTheDocument();
+ });
+ it('should render custom search results items', () => {
+ const SearchResultItem = () => CustomSearchResultItem
;
+ renderComponent({ results: [generateChannel()], SearchResultItem });
+ expect(screen.queryByText('CustomSearchResultItem')).toBeInTheDocument();
+ });
+
+ it('should allow to navigate results with arrow keys', async () => {
+ const { channel, client } = await createClientWithChannel();
+ const { container } = renderComponent({ results: [channel] }, { client });
+ const searchResultList = container.querySelector(SEARCH_RESULT_LIST_SELECTOR);
+ searchResultList.focus();
+ await act(() => {
+ fireEvent.keyDown(searchResultList, { key: 'ArrowDown' });
+ });
+ await act(() => {
+ fireEvent.keyDown(searchResultList, { key: 'ArrowDown' });
+ });
+ expect(searchResultList.children[1]).toHaveClass('focused');
+ });
+
+ it('should add class "inline" to the results list root by default', () => {
+ const { container } = renderComponent({ results: [] }, { themeVersion });
+ const searchResultList = container.querySelector(SEARCH_RESULT_LIST_SELECTOR);
+ expect(searchResultList).toHaveClass('inline');
+ });
+
+ it('should add popup class to the results list root', () => {
+ const { container } = renderComponent({ popupResults: true, results: [] }, { themeVersion });
+ const searchResultList = container.querySelector(SEARCH_RESULT_LIST_SELECTOR);
+ expect(searchResultList).toHaveClass('popup');
+ });
+ });
+});
diff --git a/src/components/ChannelSearch/__tests__/__snapshots__/SearchBar.test.js.snap b/src/components/ChannelSearch/__tests__/__snapshots__/SearchBar.test.js.snap
new file mode 100644
index 000000000..7adfd27fe
--- /dev/null
+++ b/src/components/ChannelSearch/__tests__/__snapshots__/SearchBar.test.js.snap
@@ -0,0 +1,56 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`SearchBar should render default layout 1`] = `
+
+`;
diff --git a/src/components/ChannelSearch/hooks/useChannelSearch.ts b/src/components/ChannelSearch/hooks/useChannelSearch.ts
new file mode 100644
index 000000000..055703010
--- /dev/null
+++ b/src/components/ChannelSearch/hooks/useChannelSearch.ts
@@ -0,0 +1,283 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import throttle from 'lodash.throttle';
+import uniqBy from 'lodash.uniqby';
+
+import { ChannelOrUserResponse, isChannel } from '../utils';
+
+import { useChatContext } from '../../../context/ChatContext';
+
+import type {
+ ChannelFilters,
+ ChannelOptions,
+ ChannelSort,
+ UserFilters,
+ UserOptions,
+ 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';
+import type { DefaultStreamChatGenerics } from '../../../types/types';
+
+export type ChannelSearchFunctionParams<
+ StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
+> = {
+ setQuery: React.Dispatch>;
+ setResults: React.Dispatch[]>>;
+ setSearching: React.Dispatch>;
+};
+
+export type SearchController<
+ StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
+> = SearchInputController & SearchBarController & SearchResultsController;
+
+export type SearchQueryParams<
+ StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
+> = {
+ channelFilters?: {
+ filters?: ChannelFilters;
+ options?: ChannelOptions;
+ sort?: ChannelSort;
+ };
+ userFilters?: {
+ filters?: UserFilters;
+ options?: UserOptions;
+ sort?: UserSort;
+ };
+};
+
+export type ChannelSearchParams<
+ StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
+> = {
+ /** The type of channel to create on user result select, defaults to `messaging` */
+ channelType?: string;
+ /** Clear search state / results on every click outside the search input, defaults to true */
+ clearSearchOnClickOutside?: boolean;
+ /** Disables execution of the search queries, defaults to false */
+ disabled?: boolean;
+ /** Callback invoked with every search input change handler */
+ onSearch?: SearchInputController['onSearch'];
+ /** Callback invoked when the search UI is deactivated */
+ onSearchExit?: () => void;
+ /** Custom handler function to run on search result item selection */
+ onSelectResult?: (
+ params: ChannelSearchFunctionParams,
+ result: ChannelOrUserResponse,
+ ) => Promise | void;
+ /** 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 */
+ searchFunction?: (
+ params: ChannelSearchFunctionParams,
+ event: React.BaseSyntheticEvent,
+ ) => Promise | void;
+ /** Object containing filters/sort/options overrides for user / channel search */
+ searchQueryParams?: SearchQueryParams;
+};
+
+export type ChannelSearchControllerParams<
+ StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
+> = ChannelSearchParams & {
+ /** Set the array of channels displayed in the ChannelList */
+ setChannels: React.Dispatch>>>;
+};
+
+export const useChannelSearch = <
+ StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
+>({
+ channelType = 'messaging',
+ clearSearchOnClickOutside = true,
+ disabled = false,
+ onSearch: onSearchCallback,
+ onSearchExit,
+ onSelectResult,
+ searchForChannels = false,
+ searchFunction,
+ searchQueryParams,
+ setChannels,
+}: ChannelSearchControllerParams): SearchController => {
+ const { client, navOpen, setActiveChannel, themeVersion } = useChatContext(
+ 'useChannelSearch',
+ );
+
+ const [inputIsFocused, setInputIsFocused] = useState(false);
+ const [query, setQuery] = useState('');
+ const [results, setResults] = useState>>([]);
+ const [searching, setSearching] = useState(false);
+
+ const inputRef = useRef(null);
+ const searchBarRef = useRef(null);
+
+ const clearState = useCallback(() => {
+ setQuery('');
+ setResults([]);
+ setSearching(false);
+ }, []);
+
+ const activateSearch = useCallback(() => {
+ setInputIsFocused(true);
+ }, []);
+
+ const exitSearch = useCallback(() => {
+ setInputIsFocused(false);
+ inputRef.current?.blur();
+ clearState();
+ onSearchExit?.();
+ }, [clearState, onSearchExit]);
+
+ useEffect(() => {
+ if (disabled) return;
+
+ const clickListener = (event: MouseEvent) => {
+ if (!(event.target instanceof HTMLElement)) return;
+ const isInputClick =
+ themeVersion === '2'
+ ? searchBarRef.current?.contains(event.target)
+ : inputRef.current?.contains(event.target);
+
+ if (isInputClick) return;
+
+ if ((inputIsFocused && (!query || navOpen)) || clearSearchOnClickOutside) {
+ exitSearch();
+ }
+ };
+
+ document.addEventListener('click', clickListener);
+ return () => document.removeEventListener('click', clickListener);
+ }, [disabled, inputIsFocused]);
+
+ useEffect(() => {
+ if (!inputRef.current || disabled) return;
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') return exitSearch();
+ };
+ inputRef.current.addEventListener('keydown', handleKeyDown);
+
+ return () => {
+ inputRef.current?.removeEventListener('keydown', handleKeyDown);
+ };
+ }, []);
+
+ const selectResult = useCallback(
+ async (result: ChannelOrUserResponse) => {
+ if (!client.userID) return;
+ if (onSelectResult) {
+ await onSelectResult(
+ {
+ setQuery,
+ setResults,
+ setSearching,
+ },
+ result,
+ );
+ return;
+ }
+ let selectedChannel: Channel;
+ if (isChannel(result)) {
+ setActiveChannel(result);
+ selectedChannel = result;
+ } else {
+ const newChannel = client.channel(channelType, { members: [client.userID, result.id] });
+ await newChannel.watch();
+
+ setActiveChannel(newChannel);
+ selectedChannel = newChannel;
+ }
+ setChannels((channels) => uniqBy([selectedChannel, ...channels], 'cid'));
+ if (clearSearchOnClickOutside) {
+ exitSearch();
+ }
+ },
+ [clearSearchOnClickOutside, client, exitSearch, onSelectResult],
+ );
+
+ const getChannels = useCallback(
+ async (text: string) => {
+ if (!text || searching) return;
+ setSearching(true);
+
+ try {
+ const userResponse = await client.queryUsers(
+ // @ts-expect-error
+ {
+ $or: [{ id: { $autocomplete: text } }, { name: { $autocomplete: text } }],
+ id: { $ne: client.userID },
+ ...searchQueryParams?.userFilters?.filters,
+ },
+ { id: 1, ...searchQueryParams?.userFilters?.sort },
+ { limit: 8, ...searchQueryParams?.userFilters?.options },
+ );
+
+ if (searchForChannels) {
+ const channelResponse = client.queryChannels(
+ // @ts-expect-error
+ {
+ name: { $autocomplete: text },
+ ...searchQueryParams?.channelFilters?.filters,
+ },
+ searchQueryParams?.channelFilters?.sort || {},
+ { limit: 5, ...searchQueryParams?.channelFilters?.options },
+ );
+
+ const [channels, { users }] = await Promise.all([channelResponse, userResponse]);
+
+ setResults([...channels, ...users]);
+ setSearching(false);
+ return;
+ }
+
+ const { users } = await Promise.resolve(userResponse);
+
+ setResults(users);
+ } catch (error) {
+ clearState();
+ console.error(error);
+ }
+
+ setSearching(false);
+ },
+ [client, searching],
+ );
+
+ const getChannelsThrottled = throttle(getChannels, 200);
+
+ const onSearch = useCallback(
+ (event: React.ChangeEvent) => {
+ event.preventDefault();
+ if (disabled) return;
+
+ if (searchFunction) {
+ searchFunction(
+ {
+ setQuery,
+ setResults,
+ setSearching,
+ },
+ event,
+ );
+ } else {
+ setQuery(event.target.value);
+ getChannelsThrottled(event.target.value);
+ }
+ onSearchCallback?.(event);
+ },
+ [disabled, getChannelsThrottled, searchFunction],
+ );
+
+ return {
+ activateSearch,
+ clearState,
+ exitSearch,
+ inputIsFocused,
+ inputRef,
+ onSearch,
+ query,
+ results,
+ searchBarRef,
+ searching,
+ selectResult,
+ };
+};
diff --git a/src/components/ChannelSearch/icons.tsx b/src/components/ChannelSearch/icons.tsx
new file mode 100644
index 000000000..20fab9a29
--- /dev/null
+++ b/src/components/ChannelSearch/icons.tsx
@@ -0,0 +1,61 @@
+import React from 'react';
+import type { IconProps } from '../../types/types';
+
+export const MenuIcon = () => (
+
+
+
+);
+
+export const ReturnIcon = () => (
+
+
+
+);
+
+export const XIcon = () => (
+
+
+
+);
+
+export const SearchIcon = ({ className }: IconProps) => (
+
+
+
+);
diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx
index 2ea0e4c27..631890fdd 100644
--- a/src/components/Chat/Chat.tsx
+++ b/src/components/Chat/Chat.tsx
@@ -5,7 +5,7 @@ import { useCreateChatContext } from './hooks/useCreateChatContext';
import { useChannelsQueryState } from './hooks/useChannelsQueryState';
import { CustomStyles, darkModeTheme, useCustomStyles } from './hooks/useCustomStyles';
-import { ChatProvider, CustomClasses } from '../../context/ChatContext';
+import { ChatProvider, CustomClasses, ThemeVersion } from '../../context/ChatContext';
import { SupportedTranslations, TranslationProvider } from '../../context/TranslationContext';
import type { StreamChat } from 'stream-chat';
@@ -86,6 +86,9 @@ export const Chat = <
} = useChat({ client, defaultLanguage, i18nInstance, initialNavOpen });
const channelsQueryState = useChannelsQueryState();
+ const themeVersion = (getComputedStyle(document.documentElement)
+ .getPropertyValue('--str-chat__theme-version')
+ .replace(' ', '') || '1') as ThemeVersion;
useCustomStyles(darkMode ? darkModeTheme : customStyles);
@@ -102,6 +105,7 @@ export const Chat = <
openMobileNav,
setActiveChannel,
theme,
+ themeVersion,
useImageFlagEmojisOnWindows,
});
diff --git a/src/components/Chat/hooks/useCreateChatContext.ts b/src/components/Chat/hooks/useCreateChatContext.ts
index 74c00b815..3986ff253 100644
--- a/src/components/Chat/hooks/useCreateChatContext.ts
+++ b/src/components/Chat/hooks/useCreateChatContext.ts
@@ -21,6 +21,7 @@ export const useCreateChatContext = <
openMobileNav,
setActiveChannel,
theme,
+ themeVersion,
useImageFlagEmojisOnWindows,
} = value;
@@ -47,6 +48,7 @@ export const useCreateChatContext = <
openMobileNav,
setActiveChannel,
theme,
+ themeVersion,
useImageFlagEmojisOnWindows,
}),
[
diff --git a/src/components/ChatAutoComplete/ChatAutoComplete.tsx b/src/components/ChatAutoComplete/ChatAutoComplete.tsx
index 9a6f9a7a3..ba4e8978a 100644
--- a/src/components/ChatAutoComplete/ChatAutoComplete.tsx
+++ b/src/components/ChatAutoComplete/ChatAutoComplete.tsx
@@ -149,10 +149,10 @@ const UnMemoizedChatAutoComplete = <
{
@@ -12,10 +13,34 @@ const UnMemoizedEmptyStateIndicator = (props: EmptyStateIndicatorProps) => {
const { t } = useTranslationContext('EmptyStateIndicator');
- if (listType === 'channel')
- return {t('You have no channels currently')}
;
-
- if (listType === 'message') return null;
+ if (listType === 'thread') return null;
+
+ if (listType === 'channel') {
+ const text = t('You have no channels currently');
+ return (
+ <>
+
+
+ {text}
+
+ >
+ );
+ }
+
+ if (listType === 'message') {
+ const text = t('No chats here yet…');
+ return (
+
+ );
+ }
return No items exist
;
};
diff --git a/src/components/EmptyStateIndicator/__tests__/EmptyStateIndicator.test.js b/src/components/EmptyStateIndicator/__tests__/EmptyStateIndicator.test.js
index 165bc44d0..6dc5d514c 100644
--- a/src/components/EmptyStateIndicator/__tests__/EmptyStateIndicator.test.js
+++ b/src/components/EmptyStateIndicator/__tests__/EmptyStateIndicator.test.js
@@ -1,6 +1,6 @@
import React from 'react';
import renderer from 'react-test-renderer';
-import { cleanup, render } from '@testing-library/react';
+import { cleanup, render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { EmptyStateIndicator } from '../EmptyStateIndicator';
@@ -18,19 +18,25 @@ describe('EmptyStateIndicator', () => {
`);
});
- it('should return null if listType is message', () => {
- const { container } = render( );
- expect(container).toBeEmptyDOMElement();
+ it('should display correct text when listType is message', () => {
+ render( );
+ expect(screen.queryByText('No chats here yet…')).toBeInTheDocument();
});
it('should display correct text when listType is channel', () => {
- const { getByText } = render( );
- expect(getByText('You have no channels currently')).toBeInTheDocument();
+ render( );
+ // rendering the same text twice for backwards compatibility with css styling v1
+ expect(screen.queryAllByText('You have no channels currently')).toHaveLength(2);
+ });
+
+ it('should return null if listType is thread', () => {
+ const { container } = render( );
+ expect(container).toBeEmptyDOMElement();
});
it('should display correct text when no listType is provided', () => {
jest.spyOn(console, 'error').mockImplementationOnce(() => null);
- const { getByText } = render( );
- expect(getByText('No items exist')).toBeInTheDocument();
+ render( );
+ expect(screen.getByText('No items exist')).toBeInTheDocument();
});
});
diff --git a/src/components/EmptyStateIndicator/icons.tsx b/src/components/EmptyStateIndicator/icons.tsx
new file mode 100644
index 000000000..b6abb4cc9
--- /dev/null
+++ b/src/components/EmptyStateIndicator/icons.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+
+export const ChatBubble = () => (
+
+
+
+);
diff --git a/src/components/Gallery/Gallery.tsx b/src/components/Gallery/Gallery.tsx
index 7597ecd9a..ae0629b30 100644
--- a/src/components/Gallery/Gallery.tsx
+++ b/src/components/Gallery/Gallery.tsx
@@ -1,7 +1,10 @@
-import React, { useMemo, useState } from 'react';
+import React, { useState } from 'react';
+import clsx from 'clsx';
-import { ModalComponent as ModalWrapper } from './ModalWrapper';
+import { Modal } from '../Modal';
+import { ModalGallery as DefaultModalGallery } from './ModalGallery';
+import { useComponentContext } from '../../context/ComponentContext';
import { useTranslationContext } from '../../context/TranslationContext';
import type { Attachment } from 'stream-chat';
@@ -29,6 +32,7 @@ const UnMemoizedGallery = <
const [index, setIndex] = useState(0);
const [modalOpen, setModalOpen] = useState(false);
+ const { ModalGallery = DefaultModalGallery } = useComponentContext('Gallery');
const { t } = useTranslationContext('Gallery');
const countImagesDisplayedInPreview = 4;
@@ -43,16 +47,6 @@ const UnMemoizedGallery = <
}
};
- const formattedArray = useMemo(
- () =>
- images.map((image) => ({
- original: image.image_url || image.thumb_url || '',
- originalAlt: 'User uploaded content',
- source: image.image_url || image.thumb_url || '',
- })),
- [images],
- );
-
const renderImages = images.slice(0, countImagesDisplayedInPreview).map((image, i) =>
i === lastImageIndexInPreview && images.length > countImagesDisplayedInPreview ? (
lastImageIndexInPreview,
+ 'str-chat__gallery-two-rows': images.length > 2,
+ });
+
return (
- lastImageIndexInPreview ? 'str-chat__gallery--square' : ''
- }`}
- >
+
{renderImages}
- setModalOpen(!modalOpen)}
- />
+ setModalOpen((modalOpen) => !modalOpen)} open={modalOpen}>
+
+
);
};
diff --git a/src/components/Gallery/Image.tsx b/src/components/Gallery/Image.tsx
index 7b246293b..99bff59ef 100644
--- a/src/components/Gallery/Image.tsx
+++ b/src/components/Gallery/Image.tsx
@@ -1,30 +1,43 @@
import React, { useState } from 'react';
import { sanitizeUrl } from '@braintree/sanitize-url';
-import { ModalComponent as ModalWrapper } from './ModalWrapper';
-
-export type ImageProps = {
- /** The text fallback for the image */
- fallback?: string;
- /** The full size image url */
- image_url?: string;
- /** The thumb url */
- thumb_url?: string;
-};
+import { Modal } from '../Modal';
+import { ModalGallery as DefaultModalGallery } from './ModalGallery';
+import { useComponentContext } from '../../context';
+
+import type { Attachment } from 'stream-chat';
+import type { DefaultStreamChatGenerics, Dimensions } from '../../types/types';
+
+export type ImageProps<
+ StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
+> = { dimensions?: Dimensions } & (
+ | {
+ /** The text fallback for the image */
+ fallback?: string;
+ /** The full size image url */
+ image_url?: string;
+ /** The thumb url */
+ thumb_url?: string;
+ }
+ | Attachment
+);
/**
* A simple component that displays an image.
*/
-export const ImageComponent = (props: ImageProps) => {
+export const ImageComponent = <
+ StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
+>(
+ props: ImageProps,
+) => {
+ const { dimensions = {}, fallback, image_url, thumb_url } = props;
+
const [modalIsOpen, setModalIsOpen] = useState(false);
+ const { ModalGallery = DefaultModalGallery } = useComponentContext('ImageComponent');
- const { fallback, image_url, thumb_url } = props;
const imageSrc = sanitizeUrl(image_url || thumb_url);
- const formattedArray = [
- { original: imageSrc, originalAlt: 'User uploaded content', source: imageSrc },
- ];
- const toggleModal = () => setModalIsOpen(!modalIsOpen);
+ const toggleModal = () => setModalIsOpen((modalIsOpen) => !modalIsOpen);
return (
<>
@@ -33,17 +46,13 @@ export const ImageComponent = (props: ImageProps) => {
className='str-chat__message-attachment--img'
data-testid='image-test'
onClick={toggleModal}
- onKeyPress={toggleModal}
src={imageSrc}
tabIndex={0}
+ {...dimensions}
/>
-
-
+
+
+
>
);
};
diff --git a/src/components/Gallery/ModalGallery.tsx b/src/components/Gallery/ModalGallery.tsx
new file mode 100644
index 000000000..bec923548
--- /dev/null
+++ b/src/components/Gallery/ModalGallery.tsx
@@ -0,0 +1,45 @@
+import React, { useMemo } from 'react';
+import ImageGallery from 'react-image-gallery';
+
+import type { Attachment } from 'stream-chat';
+import type { DefaultStreamChatGenerics } from '../../types/types';
+
+export type ModalGalleryProps<
+ StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
+> = {
+ /** The images for the Carousel component */
+ images: Attachment[];
+ /** The index for the component */
+ index?: number;
+};
+
+export const ModalGallery = <
+ StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
+>(
+ props: ModalGalleryProps,
+) => {
+ const { images, index } = props;
+
+ const formattedArray = useMemo(
+ () =>
+ images.map((image) => {
+ const imageSrc = image.image_url || image.thumb_url || '';
+ return {
+ original: imageSrc,
+ originalAlt: 'User uploaded content',
+ source: imageSrc,
+ };
+ }),
+ [images],
+ );
+
+ return (
+
+ );
+};
diff --git a/src/components/Gallery/ModalWrapper.tsx b/src/components/Gallery/ModalWrapper.tsx
deleted file mode 100644
index 6c7aab49a..000000000
--- a/src/components/Gallery/ModalWrapper.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import React from 'react';
-import ImageGallery, { ReactImageGalleryItem } from 'react-image-gallery';
-
-import { Modal } from '../Modal';
-
-export type ModalWrapperProps = {
- /** The images for the Carousel component */
- images: ReactImageGalleryItem[];
- /** Boolean for if modal is open*/
- modalIsOpen: boolean;
- /** click event handler for toggling modal */
- toggleModal: () => void | ((event?: React.BaseSyntheticEvent) => void);
- /** The index for the component */
- index?: number;
-};
-
-export const ModalComponent = (props: ModalWrapperProps) => {
- const { images, index, modalIsOpen, toggleModal } = props;
-
- return (
-
-
-
- );
-};
diff --git a/src/components/Gallery/__tests__/Gallery.test.js b/src/components/Gallery/__tests__/Gallery.test.js
index 9120c27a6..5c6ed41e1 100644
--- a/src/components/Gallery/__tests__/Gallery.test.js
+++ b/src/components/Gallery/__tests__/Gallery.test.js
@@ -1,4 +1,5 @@
import React from 'react';
+import { nanoid } from 'nanoid';
import renderer from 'react-test-renderer';
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
@@ -8,6 +9,8 @@ import { getTestClientWithUser } from '../../../mock-builders';
import { Chat } from '../../Chat';
import { Gallery } from '../Gallery';
+import { ComponentProvider } from '../../../context/ComponentContext';
+
let chatClient;
const mockGalleryAssets = [
@@ -38,33 +41,62 @@ const mockGalleryAssets = [
},
];
-afterEach(cleanup); // eslint-disable-line
-
describe('Gallery', () => {
+ afterEach(cleanup);
+
it('should render component with default props', () => {
- const tree = renderer.create( ).toJSON();
+ const tree = renderer
+ .create(
+
+
+ ,
+ )
+ .toJSON();
expect(tree).toMatchSnapshot();
});
it('should render component with 3 images', () => {
- const tree = renderer.create( ).toJSON();
+ const tree = renderer
+ .create(
+
+
+ ,
+ )
+ .toJSON();
expect(tree).toMatchSnapshot();
});
it('should render component with 4 images', () => {
- const tree = renderer.create( ).toJSON();
+ const tree = renderer
+ .create(
+
+
+ ,
+ )
+ .toJSON();
expect(tree).toMatchSnapshot();
});
it('should render component with 5 images', () => {
- const tree = renderer.create( ).toJSON();
+ const tree = renderer
+ .create(
+
+
+ ,
+ )
+ .toJSON();
expect(tree).toMatchSnapshot();
});
it('should open modal on image click', async () => {
jest.spyOn(console, 'warn').mockImplementation(() => null);
- const { getByTestId, getByTitle } = render( );
+ const { getByTestId, getByTitle } = render(
+
+
+ ,
+ );
+
fireEvent.click(getByTestId('gallery-image'));
await waitFor(() => {
@@ -76,7 +108,9 @@ describe('Gallery', () => {
chatClient = await getTestClientWithUser({ id: 'test' });
const { getByText } = await render(
- ,
+
+
+
,
);
await waitFor(() => {
@@ -88,7 +122,9 @@ describe('Gallery', () => {
chatClient = await getTestClientWithUser({ id: 'test' });
const { container, getByText } = render(
- ,
+
+
+
,
);
@@ -101,4 +137,20 @@ describe('Gallery', () => {
expect(container.querySelector('.image-gallery-index')).toHaveTextContent('4 / 5');
});
});
+
+ it('should render custom ModalGallery component from context', async () => {
+ const galleryContent = nanoid();
+ const CustomGallery = () => {galleryContent}
;
+ const { getAllByTestId, getByText } = render(
+
+
+ ,
+ );
+
+ fireEvent.click(getAllByTestId('gallery-image')[0]);
+
+ await waitFor(() => {
+ expect(getByText(galleryContent)).toBeInTheDocument();
+ });
+ });
});
diff --git a/src/components/Gallery/__tests__/Image.test.js b/src/components/Gallery/__tests__/Image.test.js
index dd2f5ef73..0ede92d63 100644
--- a/src/components/Gallery/__tests__/Image.test.js
+++ b/src/components/Gallery/__tests__/Image.test.js
@@ -6,13 +6,21 @@ import '@testing-library/jest-dom';
import { ImageComponent } from '../Image';
-const mockImageAssets = 'https://placeimg.com/640/480/any';
+import { ComponentProvider } from '../../../context/ComponentContext';
-afterEach(cleanup); // eslint-disable-line
+const mockImageAssets = 'https://placeimg.com/640/480/any';
describe('Image', () => {
+ afterEach(cleanup);
+
it('should render component with default props', () => {
- const tree = renderer.create( ).toJSON();
+ const tree = renderer
+ .create(
+
+
+ ,
+ )
+ .toJSON();
expect(tree).toMatchSnapshot();
});
@@ -20,30 +28,50 @@ describe('Image', () => {
it('should prevent javascript protocol in image src', () => {
// eslint-disable-next-line no-script-url
const xssJavascriptUri = 'javascript:alert("p0wn3d")';
- const { getByTestId } = render( );
+ const { getByTestId } = render(
+
+
+ ,
+ );
expect(getByTestId('image-test')).not.toHaveAttribute('src', xssJavascriptUri);
});
it('should prevent javascript protocol in thumbnail src', () => {
// eslint-disable-next-line no-script-url
const xssJavascriptUri = 'javascript:alert("p0wn3d")';
- const { getByTestId } = render( );
+ const { getByTestId } = render(
+
+
+ ,
+ );
expect(getByTestId('image-test')).not.toHaveAttribute('src', xssJavascriptUri);
});
it('should prevent dataUris in image src', () => {
const xssDataUri = 'data:image/svg+xml;base64,DANGEROUSENCODEDSVG';
- const { getByTestId } = render( );
+ const { getByTestId } = render(
+
+
+ ,
+ );
expect(getByTestId('image-test')).not.toHaveAttribute('src', xssDataUri);
});
it('should prevent dataUris in thumb src', () => {
const xssDataUri = 'data:image/svg+xml;base64,DANGEROUSENCODEDSVG';
- const { getByTestId } = render( );
+ const { getByTestId } = render(
+
+
+ ,
+ );
expect(getByTestId('image-test')).not.toHaveAttribute('src', xssDataUri);
});
});
it('should open modal on image click', async () => {
jest.spyOn(console, 'warn').mockImplementation(() => null);
- const { getByTestId, getByTitle } = render( );
+ const { getByTestId, getByTitle } = render(
+
+
+ ,
+ );
fireEvent.click(getByTestId('image-test'));
await waitFor(() => {
diff --git a/src/components/Gallery/__tests__/__snapshots__/Gallery.test.js.snap b/src/components/Gallery/__tests__/__snapshots__/Gallery.test.js.snap
index f68d054fe..1219639bd 100644
--- a/src/components/Gallery/__tests__/__snapshots__/Gallery.test.js.snap
+++ b/src/components/Gallery/__tests__/__snapshots__/Gallery.test.js.snap
@@ -2,7 +2,7 @@
exports[`Gallery should render component with 3 images 1`] = `
diff --git a/src/components/Gallery/index.tsx b/src/components/Gallery/index.tsx
index 3b4f7854f..e07598642 100644
--- a/src/components/Gallery/index.tsx
+++ b/src/components/Gallery/index.tsx
@@ -1,3 +1,3 @@
export * from './Gallery';
export * from './Image';
-export * from './ModalWrapper';
+export * from './ModalGallery';
diff --git a/src/components/InfiniteScrollPaginator/InfiniteScroll.tsx b/src/components/InfiniteScrollPaginator/InfiniteScroll.tsx
index 54586c0e9..cd2e77c5c 100644
--- a/src/components/InfiniteScrollPaginator/InfiniteScroll.tsx
+++ b/src/components/InfiniteScrollPaginator/InfiniteScroll.tsx
@@ -15,6 +15,8 @@ export type InfiniteScrollProps = {
element?: React.ElementType;
hasMore?: boolean;
hasMoreNewer?: boolean;
+ /** Element to be rendered at the top of the thread message list. By default Message and ThreadStart components */
+ head?: React.ReactNode;
initialLoad?: boolean;
isLoading?: boolean;
listenToScroll?: (offset: number, reverseOffset: number, threshold: number) => void;
@@ -33,6 +35,7 @@ export const InfiniteScroll = (props: PropsWithChildren) =>
element = 'div',
hasMore = false,
hasMoreNewer = false,
+ head,
initialLoad = true,
isLoading = false,
listenToScroll,
@@ -108,5 +111,9 @@ export const InfiniteScroll = (props: PropsWithChildren) =>
const childrenArray = [loader, children];
+ if (head) {
+ childrenArray.unshift(head);
+ }
+
return React.createElement(element, attributes, childrenArray);
};
diff --git a/src/components/LoadMore/LoadMoreButton.tsx b/src/components/LoadMore/LoadMoreButton.tsx
index 98a710681..c3393c69a 100644
--- a/src/components/LoadMore/LoadMoreButton.tsx
+++ b/src/components/LoadMore/LoadMoreButton.tsx
@@ -1,5 +1,5 @@
import React, { PropsWithChildren } from 'react';
-import { LoadingIndicator } from 'react-file-utils';
+import { LoadingIndicator } from '../Loading';
export type LoadMoreButtonProps = {
/** onClick handler load more button. Pagination logic should be executed in this handler. */
@@ -15,7 +15,7 @@ const UnMemoizedLoadMoreButton = (props: PropsWithChildren)
{
>
{
fireEvent.click(getByTestId('load-more-button'));
expect(onClickMock).not.toHaveBeenCalledTimes(1);
const loadingIndicator = getByTestId('load-more-button').querySelector(
- '.rfu-loading-indicator__spinner',
+ '.str-chat__loading-indicator',
);
expect(loadingIndicator).toBeInTheDocument();
});
diff --git a/src/components/Loading/LoadingChannels.tsx b/src/components/Loading/LoadingChannels.tsx
index 737fa1791..1aae3ab26 100644
--- a/src/components/Loading/LoadingChannels.tsx
+++ b/src/components/Loading/LoadingChannels.tsx
@@ -1,9 +1,9 @@
import React from 'react';
const LoadingItems = () => (
-
+
-
+
diff --git a/src/components/Loading/__tests__/__snapshots__/LoadingChannels.test.js.snap b/src/components/Loading/__tests__/__snapshots__/LoadingChannels.test.js.snap
index 8ecfba990..4f2cdc715 100644
--- a/src/components/Loading/__tests__/__snapshots__/LoadingChannels.test.js.snap
+++ b/src/components/Loading/__tests__/__snapshots__/LoadingChannels.test.js.snap
@@ -5,13 +5,13 @@ exports[`LoadingChannels should render component with default props 1`] = `
className="str-chat__loading-channels"
>
= MessageContextValue
& {
- isReactionEnabled: boolean;
- onReactionListClick: ReactEventHandler;
- reactionSelectorRef: React.MutableRefObject;
- showDetailedReactions: boolean;
-};
-
-const MessageCommerceWithContext = <
- StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
->(
- props: MessageCommerceWithContextProps,
-) => {
- const {
- groupStyles,
- handleAction,
- handleOpenThread,
- isMyMessage,
- isReactionEnabled,
- message,
- onUserClick,
- onUserHover,
- reactionSelectorRef,
- showDetailedReactions,
- threadList,
- } = props;
-
- const {
- Attachment,
- Avatar = DefaultAvatar,
- MessageDeleted = DefaultMessageDeleted,
- MessageRepliesCountButton = DefaultMessageRepliesCountButton,
- MessageOptions = DefaultMessageOptions,
- MessageTimestamp = DefaultMessageTimestamp,
- ReactionSelector = DefaultReactionSelector,
- ReactionsList = DefaultReactionsList,
- } = useComponentContext('MessageCommerce');
-
- const hasAttachment = messageHasAttachments(message);
- const hasReactions = messageHasReactions(message);
-
- const firstGroupStyle = groupStyles ? groupStyles[0] : 'single';
-
- const messageClasses = `str-chat__message-commerce str-chat__message-commerce--${
- isMyMessage() ? 'right' : 'left'
- }`;
-
- if (message.deleted_at) {
- return ;
- }
-
- if (message.customType === CUSTOM_MESSAGE_TYPE.date) {
- return null;
- }
-
- return (
-
- {(firstGroupStyle === 'bottom' || firstGroupStyle === 'single') && (
-
- )}
-
- <>
- {
}
- {hasReactions && !showDetailedReactions && isReactionEnabled &&
}
- {showDetailedReactions && isReactionEnabled && (
-
- )}
- >
- {message.attachments?.length ? (
-
- ) : null}
- {message.mml && (
-
- )}
- {message.text && (
-
- )}
- {!threadList && (
-
-
-
- )}
-
- {!isMyMessage() ? (
-
- {message.user?.name || message.user?.id}
-
- ) : null}
-
-
-
-
- );
-};
-
-const MemoizedMessageCommerce = React.memo(
- MessageCommerceWithContext,
- areMessageUIPropsEqual,
-) as typeof MessageCommerceWithContext;
-
-/**
- * @deprecated - This UI component will be removed in the next major release.
- *
- * UI component that renders a message and receives functionality from the Message/MessageList components
- */
-export const MessageCommerce = <
- StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
->(
- props: MessageUIComponentProps,
-) => {
- const messageContext = useMessageContext('MessageCommerce');
-
- return ;
-};
diff --git a/src/components/Message/MessageDeleted.tsx b/src/components/Message/MessageDeleted.tsx
index e1c700824..9b5a73d60 100644
--- a/src/components/Message/MessageDeleted.tsx
+++ b/src/components/Message/MessageDeleted.tsx
@@ -27,7 +27,7 @@ export const MessageDeleted = <
const messageClasses = isMyMessage
? 'str-chat__message str-chat__message--me str-chat__message-simple str-chat__message-simple--me'
- : 'str-chat__message str-chat__message-simple';
+ : 'str-chat__message str-chat__message-simple str-chat__message--other';
return (
= MessageContextValue
& {
- isReactionEnabled: boolean;
- messageWrapperRef: React.MutableRefObject;
- onReactionListClick: ReactEventHandler;
- reactionSelectorRef: React.MutableRefObject;
- showDetailedReactions: boolean;
-};
-
-const MessageLivestreamWithContext = <
- StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
->(
- props: MessageLivestreamWithContextProps,
-) => {
- const {
- clearEditingState,
- editing,
- groupStyles,
- handleAction,
- handleOpenThread,
- handleRetry,
- initialMessage,
- isReactionEnabled,
- message,
- messageWrapperRef,
- onMentionsClickMessage,
- onMentionsHoverMessage,
- onReactionListClick,
- onUserClick,
- onUserHover,
- reactionSelectorRef,
- renderText = defaultRenderText,
- showDetailedReactions,
- unsafeHTML,
- } = props;
-
- const {
- Attachment,
- Avatar = DefaultAvatar,
- EditMessageInput = DefaultEditMessageForm,
- MessageDeleted = DefaultMessageDeleted,
- MessageRepliesCountButton = DefaultMessageRepliesCountButton,
- PinIndicator = DefaultPinIndicator,
- QuotedMessage = DefaultQuotedMessage,
- ReactionsList = DefaultReactionsList,
- ReactionSelector = DefaultReactionSelector,
- } = useComponentContext('MessageLivestream');
- const { t, userLanguage } = useTranslationContext('MessageLivestream');
-
- const messageTextToRender =
- message.i18n?.[`${userLanguage}_text` as `${TranslationLanguages}_text`] || message.text;
-
- const messageText = useMemo(() => renderText(messageTextToRender, message.mentioned_users), [
- message.mentioned_users,
- messageTextToRender,
- ]);
-
- const firstGroupStyle = groupStyles ? groupStyles[0] : 'single';
-
- if (message.customType === CUSTOM_MESSAGE_TYPE.date) {
- return null;
- }
-
- if (message.deleted_at) {
- return ;
- }
-
- if (editing) {
- return (
-
- {(firstGroupStyle === 'top' || firstGroupStyle === 'single') && (
-
- )}
-
-
- );
- }
-
- return (
- <>
- {message.pinned && (
-
- )}
-
- {showDetailedReactions && isReactionEnabled && (
-
- )}
-
-
-
-
-
-
{message.user?.name || message.user?.id}
- {message.type === 'error' && (
-
- {t('Only visible to you')}
-
- )}
-
-
- {message.quoted_message && (
-
-
-
- )}
- {message.type !== 'error' &&
- message.status !== 'failed' &&
- !unsafeHTML &&
- messageText}
- {message.type !== 'error' &&
- message.status !== 'failed' &&
- unsafeHTML &&
- !!message.html &&
}
- {message.type === 'error' && !message.command && (
-
-
- {message.text}
-
- )}
- {message.type === 'error' && message.command && (
-
-
- {/* TODO: Translate following sentence */}
- /{message.command} is not a valid command
-
- )}
- {message.status === 'failed' && (
-
handleRetry(message) : undefined}
- >
-
- {message.errorStatusCode !== 403
- ? t('Message Failed · Click to try again')
- : t('Message Failed · Unauthorized')}
-
- )}
-
- {message.attachments?.length ? (
-
- ) : null}
- {isReactionEnabled &&
}
- {!initialMessage && (
-
- )}
-
-
-
- >
- );
-};
-
-export type MessageLivestreamActionsProps = {
- messageWrapperRef: React.RefObject;
- onReactionListClick: ReactEventHandler;
-};
-
-const MessageLivestreamActions = <
- StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
->(
- props: MessageLivestreamActionsProps,
-) => {
- const { messageWrapperRef, onReactionListClick } = props;
-
- const { MessageTimestamp = DefaultTimestamp } = useComponentContext(
- 'MessageLivestream',
- );
-
- const {
- getMessageActions,
- handleOpenThread,
- initialMessage,
- message,
- threadList,
- } = useMessageContext('MessageLivestream');
-
- const [actionsBoxOpen, setActionsBoxOpen] = useState(false);
-
- const hideOptions = useCallback(() => setActionsBoxOpen(false), []);
- const messageDeletedAt = !!message.deleted_at;
- const messageWrapper = messageWrapperRef?.current;
-
- const messageActions = getMessageActions();
- const showActionsBox = showMessageActionsBox(messageActions);
-
- const shouldShowReactions = messageActions.indexOf(MESSAGE_ACTIONS.react) > -1;
- const shouldShowReplies = messageActions.indexOf(MESSAGE_ACTIONS.reply) > -1 && !threadList;
-
- useEffect(() => {
- if (messageWrapper) {
- messageWrapper.addEventListener('mouseleave', hideOptions);
- }
-
- return () => {
- if (messageWrapper) {
- messageWrapper.removeEventListener('mouseleave', hideOptions);
- }
- };
- }, [messageWrapper, hideOptions]);
-
- useEffect(() => {
- if (messageDeletedAt) {
- document.removeEventListener('click', hideOptions);
- }
- }, [messageDeletedAt, hideOptions]);
-
- useEffect(() => {
- if (actionsBoxOpen) {
- document.addEventListener('click', hideOptions);
- } else {
- document.removeEventListener('click', hideOptions);
- }
- return () => {
- document.removeEventListener('click', hideOptions);
- };
- }, [actionsBoxOpen, hideOptions]);
-
- if (
- initialMessage ||
- !message ||
- message.type === 'error' ||
- message.type === 'system' ||
- message.type === 'ephemeral' ||
- message.status === 'failed' ||
- message.status === 'sending'
- ) {
- return null;
- }
-
- return (
-
-
- {shouldShowReactions && (
-
-
-
-
-
- )}
- {shouldShowReplies && (
-
-
-
- )}
- {showActionsBox && }
-
- );
-};
-
-const MemoizedMessageLivestream = React.memo(
- MessageLivestreamWithContext,
- areMessageUIPropsEqual,
-) as typeof MessageLivestreamWithContext;
-
-/**
- * @deprecated - This UI component will be removed in the next major release.
- *
- * Handles the rendering of a message and depends on the Message component for all the logic.
- * Implements the look and feel for a livestream use case.
- */
-export const MessageLivestream = <
- StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
->(
- props: MessageUIComponentProps,
-) => {
- const messageContext = useMessageContext('MessageLivestream');
-
- const messageWrapperRef = useRef(null);
- const reactionSelectorRef = useRef(null);
-
- const message = props.message || messageContext.message;
-
- const { isReactionEnabled, onReactionListClick, showDetailedReactions } = useReactionClick(
- message,
- reactionSelectorRef,
- messageWrapperRef,
- );
-
- return (
-
- );
-};
diff --git a/src/components/Message/MessageOptions.tsx b/src/components/Message/MessageOptions.tsx
index 3a6858c9b..d72011484 100644
--- a/src/components/Message/MessageOptions.tsx
+++ b/src/components/Message/MessageOptions.tsx
@@ -17,6 +17,7 @@ export type MessageOptionsProps<
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
> = Partial, 'handleOpenThread'>> & {
ActionsIcon?: React.FunctionComponent;
+ /** @deprecated: use CSS to style the order of the contents */
displayLeft?: boolean;
displayReplies?: boolean;
messageWrapperRef?: React.RefObject;
@@ -32,7 +33,6 @@ const UnMemoizedMessageOptions = <
) => {
const {
ActionsIcon = DefaultActionsIcon,
- displayLeft = true,
displayReplies = true,
handleOpenThread: propHandleOpenThread,
messageWrapperRef,
@@ -46,7 +46,6 @@ const UnMemoizedMessageOptions = <
getMessageActions,
handleOpenThread: contextHandleOpenThread,
initialMessage,
- isMyMessage,
message,
onReactionListClick,
threadList,
@@ -74,60 +73,32 @@ const UnMemoizedMessageOptions = <
return null;
}
- if (isMyMessage() && displayLeft) {
- return (
-
- {showActionsBox && (
-
- )}
- {shouldShowReplies && (
-
-
-
- )}
- {shouldShowReactions && (
-
-
-
- )}
-
- );
- }
+ const rootClassName = `str-chat__message-${theme}__actions str-chat__message-options`;
return (
-
- {shouldShowReactions && (
-
-
-
+
+ {showActionsBox && (
+
)}
{shouldShowReplies && (
-
+
)}
- {showActionsBox && (
-
+ {shouldShowReactions && (
+
+
+
)}
);
diff --git a/src/components/Message/MessageRepliesCountButton.tsx b/src/components/Message/MessageRepliesCountButton.tsx
index bd4b9a8dc..5c3dc4673 100644
--- a/src/components/Message/MessageRepliesCountButton.tsx
+++ b/src/components/Message/MessageRepliesCountButton.tsx
@@ -2,6 +2,7 @@ import React, { MouseEventHandler } from 'react';
import { ReplyIcon } from './icons';
+import { useChatContext } from '../../context/ChatContext';
import { useTranslationContext } from '../../context/TranslationContext';
export type MessageRepliesCountButtonProps = {
@@ -15,6 +16,7 @@ const UnMemoizedMessageRepliesCountButton = (props: MessageRepliesCountButtonPro
const { labelPlural, labelSingle, onClick, reply_count = 0 } = props;
const { t } = useTranslationContext('MessageRepliesCountButton');
+ const { themeVersion } = useChatContext('MessageRepliesCountButton');
if (!reply_count) return null;
@@ -27,14 +29,16 @@ const UnMemoizedMessageRepliesCountButton = (props: MessageRepliesCountButtonPro
}
return (
-
-
- {replyCountText}
-
+
+
+ {themeVersion === '1' && }
+ {replyCountText}
+
+
);
};
diff --git a/src/components/Message/MessageSimple.tsx b/src/components/Message/MessageSimple.tsx
index 4a8c882c1..f1502d8ac 100644
--- a/src/components/Message/MessageSimple.tsx
+++ b/src/components/Message/MessageSimple.tsx
@@ -1,5 +1,7 @@
import React from 'react';
+import clsx from 'clsx';
+import { MessageErrorIcon } from './icons';
import { MessageDeleted as DefaultMessageDeleted } from './MessageDeleted';
import { MessageOptions as DefaultMessageOptions } from './MessageOptions';
import { MessageRepliesCountButton as DefaultMessageRepliesCountButton } from './MessageRepliesCountButton';
@@ -18,6 +20,7 @@ import {
ReactionSelector as DefaultReactionSelector,
} from '../Reactions';
+import { useChatContext } from '../../context/ChatContext';
import { useComponentContext } from '../../context/ComponentContext';
import { MessageContextValue, useMessageContext } from '../../context/MessageContext';
@@ -67,14 +70,11 @@ const MessageSimpleWithContext = <
ReactionSelector = DefaultReactionSelector,
ReactionsList = DefaultReactionList,
} = useComponentContext
('MessageSimple');
+ const { themeVersion } = useChatContext('MessageSimple');
const hasAttachment = messageHasAttachments(message);
const hasReactions = messageHasReactions(message);
- const messageClasses = isMyMessage()
- ? 'str-chat__message str-chat__message--me str-chat__message-simple str-chat__message-simple--me'
- : 'str-chat__message str-chat__message-simple';
-
if (message.customType === CUSTOM_MESSAGE_TYPE.date) {
return null;
}
@@ -83,12 +83,39 @@ const MessageSimpleWithContext = <
return ;
}
+ const showMetadata = !groupedByUser || endOfGroup;
+ const showReplyCountButton = !threadList && !!message.reply_count;
+ const allowRetry = message.status === 'failed' && message.errorStatusCode !== 403;
+
+ const rootClassName = clsx(
+ 'str-chat__message str-chat__message-simple',
+ `str-chat__message--${message.type}`,
+ `str-chat__message--${message.status}`,
+ isMyMessage()
+ ? 'str-chat__message--me str-chat__message-simple--me'
+ : 'str-chat__message--other',
+ message.text ? 'str-chat__message--has-text' : 'has-no-text',
+ {
+ 'pinned-message': message.pinned,
+ 'str-chat__message--has-attachment': hasAttachment,
+ 'str-chat__message--highlighted': highlighted,
+ 'str-chat__message--with-reactions str-chat__message-with-thread-link':
+ hasReactions && isReactionEnabled,
+ 'str-chat__message-send-can-be-retried':
+ message?.status === 'failed' && message?.errorStatusCode !== 403,
+ 'str-chat__virtual-message__wrapper--end': endOfGroup,
+ 'str-chat__virtual-message__wrapper--first': firstOfGroup,
+ 'str-chat__virtual-message__wrapper--group': groupedByUser,
+ },
+ );
+
return (
<>
{editing && (
)}
{
-
-
+
+ {themeVersion === '1' &&
}
{message.user && (
)}
handleRetry(message)
- : undefined
- }
- onKeyPress={
- message.status === 'failed' && message.errorStatusCode !== 403
- ? () => handleRetry(message)
- : undefined
- }
+ onClick={allowRetry ? () => handleRetry(message) : undefined}
+ onKeyUp={allowRetry ? () => handleRetry(message) : undefined}
>
- <>
-