From 398cc6d8ce50527b986041453d31fb965113e071 Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 7 Nov 2024 17:41:03 +0100 Subject: [PATCH] feat: add group avatar --- .../contexts/channel-list-context.mdx | 14 +- .../components/contexts/component-context.mdx | 8 + .../core-components/channel-list.mdx | 8 +- .../components/core-components/channel.mdx | 8 + .../components/utility-components/avatar.mdx | 78 +++- src/components/Avatar/Avatar.tsx | 6 +- src/components/Avatar/ChannelAvatar.tsx | 22 + src/components/Avatar/GroupAvatar.tsx | 39 ++ src/components/Avatar/index.ts | 2 + src/components/Channel/Channel.tsx | 3 + .../ChannelHeader/ChannelHeader.tsx | 9 +- .../__tests__/ChannelHeader.test.js | 222 +++++++++- src/components/ChannelList/ChannelList.tsx | 10 +- .../ChannelPreview/ChannelPreview.tsx | 13 +- .../ChannelPreviewMessenger.tsx | 7 +- .../__tests__/ChannelPreview.test.js | 380 ++++++++++++++---- .../hooks/useChannelPreviewInfo.ts | 24 +- src/components/ChannelPreview/utils.tsx | 20 + src/context/ComponentContext.tsx | 3 + 19 files changed, 727 insertions(+), 149 deletions(-) create mode 100644 src/components/Avatar/ChannelAvatar.tsx create mode 100644 src/components/Avatar/GroupAvatar.tsx diff --git a/docusaurus/docs/React/components/contexts/channel-list-context.mdx b/docusaurus/docs/React/components/contexts/channel-list-context.mdx index d2ddf659a7..23995e1fdf 100644 --- a/docusaurus/docs/React/components/contexts/channel-list-context.mdx +++ b/docusaurus/docs/React/components/contexts/channel-list-context.mdx @@ -5,7 +5,7 @@ title: ChannelListContext The context value is provided by `ChannelListContextProvider` which wraps the contents rendered by [`ChannelList`](../core-components/channel-list.mdx). It exposes API that the default and custom components rendered by `ChannelList` can take advantage of. The components that can consume the context are customizable via `ChannelListProps`: -- `Avatar` - component used to display channel image +- `ChannelAvatar` - component used to display channel image - `ChannelSearch` - renders channel search input and results - `EmptyStateIndicator` - rendered when the channels query returns and empty array - `LoadingErrorIndicator` - rendered when the channels query fails @@ -24,10 +24,10 @@ import { useChannelListContext } from 'stream-chat-react'; export const CustomComponent = () => { const { channels, setChannels } = useChannelListContext(); // component logic ... - return( - {/* rendered elements */} - ); -} + return { + /* rendered elements */ + }; +}; ``` ## Value @@ -37,7 +37,7 @@ export const CustomComponent = () => { State representing the array of loaded channels. Channels query is executed by default only within the [`ChannelList` component](../core-components/channel-list.mdx) in the SDK. | Type | -|-------------| +| ----------- | | `Channel[]` | ### setChannels @@ -109,5 +109,5 @@ const Sidebar = () => { ``` | Type | -|---------------------------------------| +| ------------------------------------- | | `Dispatch>` | diff --git a/docusaurus/docs/React/components/contexts/component-context.mdx b/docusaurus/docs/React/components/contexts/component-context.mdx index 7239e3a57b..9c3f77ecba 100644 --- a/docusaurus/docs/React/components/contexts/component-context.mdx +++ b/docusaurus/docs/React/components/contexts/component-context.mdx @@ -131,6 +131,14 @@ The [default `BaseImage` component](../../utility-components/base-image) tries t | --------- | ----------------------------------------------------------------- | | component | | +### ChannelAvatar + +Custom UI component to display avatar for a channel in ChannelHeader. + +| Type | Default | +| --------- | ------------------------------------------------------------------------ | +| component | | + ### CooldownTimer Custom UI component to display the slow mode cooldown timer. diff --git a/docusaurus/docs/React/components/core-components/channel-list.mdx b/docusaurus/docs/React/components/core-components/channel-list.mdx index 52b7e6110e..bf8a944324 100644 --- a/docusaurus/docs/React/components/core-components/channel-list.mdx +++ b/docusaurus/docs/React/components/core-components/channel-list.mdx @@ -187,11 +187,11 @@ list from incrementing the list. ### Avatar -Custom UI component to display the user's avatar. +Custom UI component to display the channel avatar. The default avatar component for `ChannelList` is `ChannelAvatar`. -| Type | Default | -| --------- | ---------------------------------------------------------- | -| component | | +| Type | Default | +| --------- | ----------------------------------------------------------------- | +| component | | ### channelRenderFilterFn diff --git a/docusaurus/docs/React/components/core-components/channel.mdx b/docusaurus/docs/React/components/core-components/channel.mdx index a8984f272c..b53b0d2e26 100644 --- a/docusaurus/docs/React/components/core-components/channel.mdx +++ b/docusaurus/docs/React/components/core-components/channel.mdx @@ -190,6 +190,14 @@ Custom UI component to display a user's avatar. | --------- | ---------------------------------------------------------- | | component | | +### ChannelAvatar + +Custom UI component to display avatar for a channel in ChannelHeader. + +| Type | Default | +| --------- | ------------------------------------------------------------------------ | +| component | | + ### channelQueryOptions Optional configuration parameters used for the initial channel query. Applied only if the value of `channel.initialized` is false. If the channel instance has already been initialized (channel has been queried), then the channel query will be skipped and channelQueryOptions will not be applied. diff --git a/docusaurus/docs/React/components/utility-components/avatar.mdx b/docusaurus/docs/React/components/utility-components/avatar.mdx index 3236cb522b..3c832aa019 100644 --- a/docusaurus/docs/React/components/utility-components/avatar.mdx +++ b/docusaurus/docs/React/components/utility-components/avatar.mdx @@ -3,44 +3,72 @@ id: avatar title: Avatar --- -The `Avatar` component displays an image, with fallback to the first letter of the optional name prop. +The SDK supports variety of avatar component types. Different approach is taken to display a channel avatar and an avatar of a message author. -## Basic Usage +The channel avatar accounts for the fact that the channel may contain more than two members and thus become a group channel. Therefore, it renders a `GroupAvatar` component in case of more than two channel members and `Avatar` in case of two or less channel members. +On the other hand, messages use the avatars for a specific single user and thus using the `Avatar` component exclusively. -A typical use case for the `Avatar` component would be to import and use in your custom components that will completely override a header component, preview component, or similar. +The `Avatar` component displays an image, with fallback to the first letter of the optional name prop. The `GroupAvatar` displays up to four `Avatar` components in a 2x2 grid. + +## Customizing Avatar component + +The SDK's default `Avatar` component is used by the following components: + +- `ChannelSearch` results for users +- `Message` +- `QuotesMessage` +- `MesageStatus` +- `QuotedMessagePreview` in message composer +- Suggestion items for user mentions +- `Poll` +- Message `Reactions` +- `ThreadList` + +Passing your custom avatar component to `Channel` prop `Avatar` overrides the avatar for all the above components. Here's an example of using the `Avatar` component within a custom preview component: ```tsx -import { Avatar } from 'stream-chat-react'; - -const YourCustomChannelPreview = (props) => { - return ( -
- -
Other channel info needed in the preview
-
- ); +import { Channel } from 'stream-chat-react'; +import type { AvatarProps } from 'stream-chat-react'; + +const Avatar = (props: AvatarProps) => { + return
Custom avatar UI
; }; -; +; ``` -## UI Customization +## Customizing Channel Avatar You can also take advantage of the `Avatar` prop on the `ChannelHeader` and `ChannelList` components to override just that aspect of these components specifically, see the example below. -An example of overriding just the `Avatar` component in the default `ChannelPreviewMessenger` component. +An example of overriding just the `Avatar` component in the default `ChannelPreview` component. + +```tsx +import type { ChannelAvatarProps } from 'stream-chat-react'; + +const CustomChannelAvatar = (props: ChannelAvatarProps) => { + return
Custom Channel Avatar
; +}; + +; +``` + +To override channel avatar in `ChannelHeader` we need to provide our component to `Channel` prop `ChannelAvatar`: ```tsx -const CustomAvatar = (props) => { - return ; +import { Channel } from 'stream-chat-react'; +import type { ChannelAvatarProps } from 'stream-chat-react'; + +const CustomChannelAvatar = (props: ChannelAvatarProps) => { + return
Custom Channel Avatar
; }; - } />; +; ``` -## Props +## Avatar Props ### className @@ -89,3 +117,15 @@ The entire user object for the chat user represented by the Avatar component. Th | Type | | ------ | | Object | + +## ChannelAvatar Props + +Besides the `Avatar` props listed above, the `ChannelAvatar` component accepts the following props. + +### groupChannelDisplayInfo + +Mapping of image URLs to names which initials will be used as fallbacks in case image assets fail to load. + +| Type | +| ------------------------------------- | +| `{ image?: string; name?: string }[]` | diff --git a/src/components/Avatar/Avatar.tsx b/src/components/Avatar/Avatar.tsx index ce89f127ee..4c8ab20059 100644 --- a/src/components/Avatar/Avatar.tsx +++ b/src/components/Avatar/Avatar.tsx @@ -11,15 +11,15 @@ import type { DefaultStreamChatGenerics } from '../../types/types'; export type AvatarProps< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics > = { - /** Custom class that will be merged with the default class */ + /** Custom root element class that will be merged with the default class */ className?: string; /** Image URL or default is an image of the first initial of the name if there is one */ image?: string | null; /** Name of the image, used for title tag fallback */ name?: string; - /** click event handler */ + /** click event handler attached to the component root element */ onClick?: (event: React.BaseSyntheticEvent) => void; - /** mouseOver event handler */ + /** mouseOver event handler attached to the component root element */ onMouseOver?: (event: React.BaseSyntheticEvent) => void; /** The entire user object for the chat user displayed in the component */ user?: UserResponse; diff --git a/src/components/Avatar/ChannelAvatar.tsx b/src/components/Avatar/ChannelAvatar.tsx new file mode 100644 index 0000000000..eab6b088f9 --- /dev/null +++ b/src/components/Avatar/ChannelAvatar.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { Avatar, AvatarProps, GroupAvatar, GroupAvatarProps } from './index'; +import type { DefaultStreamChatGenerics } from '../../types'; + +export type ChannelAvatarProps< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +> = Partial & AvatarProps; + +export const ChannelAvatar = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>({ + groupChannelDisplayInfo, + image, + name, + user, + ...sharedProps +}: ChannelAvatarProps) => { + if (groupChannelDisplayInfo) { + return ; + } + return ; +}; diff --git a/src/components/Avatar/GroupAvatar.tsx b/src/components/Avatar/GroupAvatar.tsx new file mode 100644 index 0000000000..89a127d880 --- /dev/null +++ b/src/components/Avatar/GroupAvatar.tsx @@ -0,0 +1,39 @@ +import clsx from 'clsx'; +import React from 'react'; +import { Avatar, AvatarProps } from './Avatar'; +import { GroupChannelDisplayInfo } from '../ChannelPreview'; + +export type GroupAvatarProps = Pick & { + /** Mapping of image URLs to names which initials will be used as fallbacks in case image assets fail to load. */ + groupChannelDisplayInfo: GroupChannelDisplayInfo; +}; + +export const GroupAvatar = ({ + className, + groupChannelDisplayInfo, + onClick, + onMouseOver, +}: GroupAvatarProps) => ( +
+ {groupChannelDisplayInfo.slice(0, 4).map(({ image, name }, i) => ( + + ))} +
+); diff --git a/src/components/Avatar/index.ts b/src/components/Avatar/index.ts index 27700fe3f3..705e7dd41b 100644 --- a/src/components/Avatar/index.ts +++ b/src/components/Avatar/index.ts @@ -1 +1,3 @@ export * from './Avatar'; +export * from './ChannelAvatar'; +export * from './GroupAvatar'; diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index 419663eed6..2cdd673a42 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -112,6 +112,7 @@ type ChannelPropsForwardedToComponentContext< | 'AutocompleteSuggestionList' | 'Avatar' | 'BaseImage' + | 'ChannelAvatar' | 'CooldownTimer' | 'CustomMessageActionsList' | 'DateSeparator' @@ -1234,6 +1235,7 @@ const ChannelInner = < AutocompleteSuggestionList: props.AutocompleteSuggestionList, Avatar: props.Avatar, BaseImage: props.BaseImage, + ChannelAvatar: props.ChannelAvatar, CooldownTimer: props.CooldownTimer, CustomMessageActionsList: props.CustomMessageActionsList, DateSeparator: props.DateSeparator, @@ -1293,6 +1295,7 @@ const ChannelInner = < props.AutocompleteSuggestionList, props.Avatar, props.BaseImage, + props.ChannelAvatar, props.CooldownTimer, props.CustomMessageActionsList, props.DateSeparator, diff --git a/src/components/ChannelHeader/ChannelHeader.tsx b/src/components/ChannelHeader/ChannelHeader.tsx index 4313e04456..d00516540d 100644 --- a/src/components/ChannelHeader/ChannelHeader.tsx +++ b/src/components/ChannelHeader/ChannelHeader.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { MenuIcon as DefaultMenuIcon } from './icons'; -import { AvatarProps, Avatar as DefaultAvatar } from '../Avatar'; +import { ChannelAvatar, ChannelAvatarProps } from '../Avatar'; import { useChannelPreviewInfo } from '../ChannelPreview/hooks/useChannelPreviewInfo'; import { useChannelStateContext } from '../../context/ChannelStateContext'; @@ -13,7 +13,7 @@ import type { DefaultStreamChatGenerics } from '../../types/types'; export type ChannelHeaderProps = { /** UI component to display a user's avatar, defaults to and accepts same props as: [Avatar](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Avatar/Avatar.tsx) */ - Avatar?: React.ComponentType; + Avatar?: React.ComponentType; /** Manually set the image to render, defaults to the Channel image */ image?: string; /** Show a little indicator that the Channel is live right now */ @@ -33,7 +33,7 @@ export const ChannelHeader = < props: ChannelHeaderProps, ) => { const { - Avatar = DefaultAvatar, + Avatar = ChannelAvatar, MenuIcon = DefaultMenuIcon, image: overrideImage, live, @@ -43,7 +43,7 @@ export const ChannelHeader = < const { channel, watcher_count } = useChannelStateContext('ChannelHeader'); const { openMobileNav } = useChatContext('ChannelHeader'); const { t } = useTranslationContext('ChannelHeader'); - const { displayImage, displayTitle } = useChannelPreviewInfo({ + const { displayImage, displayTitle, groupChannelDisplayInfo } = useChannelPreviewInfo({ channel, overrideImage, overrideTitle, @@ -62,6 +62,7 @@ export const ChannelHeader = < diff --git a/src/components/ChannelHeader/__tests__/ChannelHeader.test.js b/src/components/ChannelHeader/__tests__/ChannelHeader.test.js index eb5040a28f..dbdb853649 100644 --- a/src/components/ChannelHeader/__tests__/ChannelHeader.test.js +++ b/src/components/ChannelHeader/__tests__/ChannelHeader.test.js @@ -11,15 +11,19 @@ import { dispatchUserUpdatedEvent, generateChannel, generateMember, + generateMessage, generateUser, getOrCreateChannelApi, getTestClientWithUser, + initClientWithChannels, useMockedApis, } from '../../../mock-builders'; import { toHaveNoViolations } from 'jest-axe'; import { axe } from '../../../../axe-helper'; expect.extend(toHaveNoViolations); +const AVATAR_IMG_TEST_ID = 'avatar-img'; + const user1 = generateUser(); const user2 = generateUser({ image: null }); let testChannel1; @@ -29,16 +33,11 @@ const CustomMenuIcon = () =>
Custom Menu Icon
; const defaultChannelState = { members: [generateMember({ user: user1 }), generateMember({ user: user2 })], }; -async function renderComponent(props, channelData, channelType = 'messaging') { - const t = jest.fn((key) => key); - client = await getTestClientWithUser(user1); - testChannel1 = generateChannel({ ...defaultChannelState, channel: channelData }); - /* eslint-disable-next-line react-hooks/rules-of-hooks */ - useMockedApis(client, [getOrCreateChannelApi(testChannel1)]); - const channel = client.channel(channelType, testChannel1.id, channelData); - await channel.query(); - return render( +const t = jest.fn((key) => key); + +const renderComponentBase = ({ channel, client, props }) => + render( @@ -47,6 +46,16 @@ async function renderComponent(props, channelData, channelType = 'messaging') { , ); + +async function renderComponent(props, channelData, channelType = 'messaging') { + client = await getTestClientWithUser(user1); + testChannel1 = generateChannel({ ...defaultChannelState, channel: channelData }); + /* eslint-disable-next-line react-hooks/rules-of-hooks */ + useMockedApis(client, [getOrCreateChannelApi(testChannel1)]); + const channel = client.channel(channelType, testChannel1.id, channelData); + await channel.query(); + + return renderComponentBase({ channel, client, props }); } afterEach(cleanup); // eslint-disable-line @@ -183,4 +192,199 @@ describe('ChannelHeader', () => { expect(screen.getByTestId('avatar-img')).toHaveAttribute('src', updatedAttribute.image), ); }); + + describe('group channel', () => { + const getChannelState = (memberCount, channelData) => { + const users = Array.from({ length: memberCount }, generateUser); + const members = users.map((user) => generateMember({ user })); + return generateChannel({ + members, + messages: users.map((user) => generateMessage({ user })), + ...channelData, + }); + }; + const channelName = 'channel-name'; + const channelState = getChannelState(3, { channel: { name: channelName } }); + + it('renders max 4 avatars in channel avatar', async () => { + const channelState = getChannelState(5); + const ownUser = channelState.members[0].user; + const { + channels: [channel], + client, + } = await initClientWithChannels({ + channelsData: [channelState], + customUser: ownUser, + }); + await renderComponentBase({ channel, client }); + await waitFor(() => { + const avatarImages = screen.getAllByTestId(AVATAR_IMG_TEST_ID); + expect(avatarImages).toHaveLength(4); + avatarImages.slice(0, 4).forEach((img, i) => { + expect(img).toHaveAttribute('src', channelState.members[i].user.image); + }); + }); + }); + + it.each([ + ['own user', channelState.members[0].user], + ['other user', channelState.members[2].user], + ])( + "should not update the direct messaging channel's preview title if %s's name has changed", + async (_, user) => { + const { + channels: [channel], + client, + } = await initClientWithChannels({ channelsData: [channelState] }); + const updatedAttribute = { name: 'new-name' }; + await renderComponentBase({ channel, client }); + + await waitFor(() => { + expect(screen.queryByText(updatedAttribute.name)).not.toBeInTheDocument(); + expect(screen.getByText(channelName)).toBeInTheDocument(); + }); + act(() => { + dispatchUserUpdatedEvent(client, { ...user, ...updatedAttribute }); + }); + await waitFor(() => { + expect(screen.queryByText(updatedAttribute.name)).not.toBeInTheDocument(); + expect(screen.getByText(channelName)).toBeInTheDocument(); + }); + }, + ); + + it("should update the direct messaging channel's preview image if own user's image has changed", async () => { + const ownUser = channelState.members[0].user; + const { + channels: [channel], + client, + } = await initClientWithChannels({ + channelsData: [channelState], + customUser: ownUser, + }); + const updatedAttribute = { image: 'new-image' }; + await renderComponentBase({ channel, client }); + await waitFor(() => { + const avatarImages = screen.getAllByTestId(AVATAR_IMG_TEST_ID); + expect(avatarImages).toHaveLength(3); + expect(avatarImages[0]).toHaveAttribute('src', ownUser.image); + expect(avatarImages[1]).toHaveAttribute('src', channelState.members[1].user.image); + expect(avatarImages[2]).toHaveAttribute('src', channelState.members[2].user.image); + }); + + act(() => { + dispatchUserUpdatedEvent(client, { ...ownUser, ...updatedAttribute }); + }); + + await waitFor(() => { + const avatarImages = screen.getAllByTestId(AVATAR_IMG_TEST_ID); + expect(avatarImages[0]).toHaveAttribute('src', updatedAttribute.image); + expect(avatarImages[1]).toHaveAttribute('src', channelState.members[1].user.image); + expect(avatarImages[2]).toHaveAttribute('src', channelState.members[2].user.image); + }); + }); + + it("should update the direct messaging channel's preview image if other user's image has changed", async () => { + const ownUser = channelState.members[0].user; + const otherUser = channelState.members[2].user; + const { + channels: [channel], + client, + } = await initClientWithChannels({ + channelsData: [channelState], + customUser: ownUser, + }); + const updatedAttribute = { image: 'new-image' }; + await renderComponentBase({ channel, client }); + await waitFor(() => { + const avatarImages = screen.getAllByTestId(AVATAR_IMG_TEST_ID); + expect(avatarImages).toHaveLength(3); + expect(avatarImages[0]).toHaveAttribute('src', ownUser.image); + expect(avatarImages[1]).toHaveAttribute('src', channelState.members[1].user.image); + expect(avatarImages[2]).toHaveAttribute('src', channelState.members[2].user.image); + }); + + act(() => { + dispatchUserUpdatedEvent(client, { ...otherUser, ...updatedAttribute }); + }); + + await waitFor(() => { + const avatarImages = screen.getAllByTestId(AVATAR_IMG_TEST_ID); + expect(avatarImages[0]).toHaveAttribute('src', ownUser.image); + expect(avatarImages[1]).toHaveAttribute('src', channelState.members[1].user.image); + expect(avatarImages[2]).toHaveAttribute('src', updatedAttribute.image); + }); + }); + + it("should not update the direct messaging channel's preview if other user's attribute than name or image has changed", async () => { + const ownUser = channelState.members[0].user; + const otherUser = channelState.members[2].user; + const { + channels: [channel], + client, + } = await initClientWithChannels({ + channelsData: [channelState], + customUser: ownUser, + }); + const updatedAttribute = { custom: 'new-custom' }; + await renderComponentBase({ channel, client }); + + await waitFor(() => { + expect(screen.queryByText(updatedAttribute.custom)).not.toBeInTheDocument(); + expect(screen.getByText(channelName)).toBeInTheDocument(); + const avatarImages = screen.getAllByTestId(AVATAR_IMG_TEST_ID); + avatarImages.forEach((img, i) => { + expect(img).toHaveAttribute('src', channelState.members[i].userimage); + }); + }); + + act(() => { + dispatchUserUpdatedEvent(client, { ...otherUser, ...updatedAttribute }); + }); + + await waitFor(() => { + expect(screen.queryByText(updatedAttribute.custom)).not.toBeInTheDocument(); + expect(screen.getByText(channelName)).toBeInTheDocument(); + const avatarImages = screen.getAllByTestId(AVATAR_IMG_TEST_ID); + avatarImages.forEach((img, i) => { + expect(img).toHaveAttribute('src', channelState.members[i].userimage); + }); + }); + }); + + it("should not update the direct messaging channel's preview if own user's attribute than name or image has changed", async () => { + const ownUser = channelState.members[0].user; + const { + channels: [channel], + client, + } = await initClientWithChannels({ + channelsData: [channelState], + customUser: ownUser, + }); + const updatedAttribute = { custom: 'new-custom' }; + await renderComponentBase({ channel, client }); + + await waitFor(() => { + expect(screen.queryByText(updatedAttribute.custom)).not.toBeInTheDocument(); + expect(screen.getByText(channelName)).toBeInTheDocument(); + const avatarImages = screen.getAllByTestId(AVATAR_IMG_TEST_ID); + avatarImages.forEach((img, i) => { + expect(img).toHaveAttribute('src', channelState.members[i].userimage); + }); + }); + + act(() => { + dispatchUserUpdatedEvent(client, { ...ownUser, ...updatedAttribute }); + }); + + await waitFor(() => { + expect(screen.queryByText(updatedAttribute.custom)).not.toBeInTheDocument(); + expect(screen.getByText(channelName)).toBeInTheDocument(); + const avatarImages = screen.getAllByTestId(AVATAR_IMG_TEST_ID); + avatarImages.forEach((img, i) => { + expect(img).toHaveAttribute('src', channelState.members[i].userimage); + }); + }); + }); + }); }); diff --git a/src/components/ChannelList/ChannelList.tsx b/src/components/ChannelList/ChannelList.tsx index 5f866da002..478cc9e660 100644 --- a/src/components/ChannelList/ChannelList.tsx +++ b/src/components/ChannelList/ChannelList.tsx @@ -16,8 +16,7 @@ import { useNotificationRemovedFromChannelListener } from './hooks/useNotificati import { CustomQueryChannelsFn, usePaginatedChannels } from './hooks/usePaginatedChannels'; import { useUserPresenceChangedListener } from './hooks/useUserPresenceChangedListener'; import { MAX_QUERY_CHANNELS_LIMIT, moveChannelUp } from './utils'; - -import { AvatarProps, Avatar as DefaultAvatar } from '../Avatar/Avatar'; +import { ChannelAvatar } from '../Avatar'; import { ChannelPreview, ChannelPreviewUIComponentProps } from '../ChannelPreview/ChannelPreview'; import { ChannelSearchProps, @@ -35,6 +34,7 @@ import { ChannelListContextProvider } from '../../context'; import { useChatContext } from '../../context/ChatContext'; import type { Channel, ChannelFilters, ChannelOptions, ChannelSort, Event } from 'stream-chat'; +import type { ChannelAvatarProps } from '../Avatar'; import type { TranslationContextValue } from '../../context/TranslationContext'; import type { DefaultStreamChatGenerics, PaginatorProps } from '../../types/types'; @@ -54,8 +54,8 @@ export type ChannelListProps< * to false, which will prevent channels not in the list from incrementing the list. The default is true. */ allowNewMessagesFromUnfilteredChannels?: boolean; - /** Custom UI component to display user avatar, defaults to and accepts same props as: [Avatar](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Avatar/Avatar.tsx) */ - Avatar?: React.ComponentType; + /** Custom UI component to display channel avatar, defaults to and accepts same props as: [ChannelAvatar](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Avatar/ChannelAvatar.tsx) */ + Avatar?: React.ComponentType; /** Optional function to filter channels prior to loading in the DOM. Do not use any complex or async logic that would delay the loading of the ChannelList. We recommend using a pure function with array methods like filter/sort/reduce. */ channelRenderFilterFn?: ( channels: Array>, @@ -166,7 +166,7 @@ const UnMemoizedChannelList = < ) => { const { additionalChannelSearchProps, - Avatar = DefaultAvatar, + Avatar = ChannelAvatar, allowNewMessagesFromUnfilteredChannels, channelRenderFilterFn, ChannelSearch = DefaultChannelSearch, diff --git a/src/components/ChannelPreview/ChannelPreview.tsx b/src/components/ChannelPreview/ChannelPreview.tsx index d368ae3a4a..39e88f5ae8 100644 --- a/src/components/ChannelPreview/ChannelPreview.tsx +++ b/src/components/ChannelPreview/ChannelPreview.tsx @@ -12,8 +12,8 @@ import { MessageDeliveryStatus, useMessageDeliveryStatus } from './hooks/useMess import type { Channel, Event } from 'stream-chat'; -import type { AvatarProps } from '../Avatar/Avatar'; - +import type { ChannelAvatarProps } from '../Avatar/ChannelAvatar'; +import type { GroupChannelDisplayInfo } from './utils'; import type { StreamMessage } from '../../context/ChannelStateContext'; import type { TranslationContextValue } from '../../context/TranslationContext'; import type { DefaultStreamChatGenerics } from '../../types/types'; @@ -27,6 +27,8 @@ export type ChannelPreviewUIComponentProps< displayImage?: string; /** Title of Channel to display */ displayTitle?: string; + /** Title of Channel to display */ + groupChannelDisplayInfo?: GroupChannelDisplayInfo; /** The last message received in a channel */ lastMessage?: StreamMessage; /** @deprecated Use latestMessagePreview prop instead. */ @@ -47,7 +49,7 @@ export type ChannelPreviewProps< /** Current selected channel object */ activeChannel?: Channel; /** Custom UI component to display user avatar, defaults to and accepts same props as: [Avatar](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Avatar/Avatar.tsx) */ - Avatar?: React.ComponentType; + Avatar?: React.ComponentType>; /** Forces the update of preview component on channel update */ channelUpdateCount?: number; /** Custom class for the channel preview root */ @@ -84,7 +86,9 @@ export const ChannelPreview = < 'ChannelPreview', ); const { t, userLanguage } = useTranslationContext('ChannelPreview'); - const { displayImage, displayTitle } = useChannelPreviewInfo({ channel }); + const { displayImage, displayTitle, groupChannelDisplayInfo } = useChannelPreviewInfo({ + channel, + }); const [lastMessage, setLastMessage] = useState>( channel.state.messages[channel.state.messages.length - 1], @@ -166,6 +170,7 @@ export const ChannelPreview = < active={isActive} displayImage={displayImage} displayTitle={displayTitle} + groupChannelDisplayInfo={groupChannelDisplayInfo} lastMessage={lastMessage} latestMessage={latestMessagePreview} latestMessagePreview={latestMessagePreview} diff --git a/src/components/ChannelPreview/ChannelPreviewMessenger.tsx b/src/components/ChannelPreview/ChannelPreviewMessenger.tsx index e0f2c36302..0792fbec77 100644 --- a/src/components/ChannelPreview/ChannelPreviewMessenger.tsx +++ b/src/components/ChannelPreview/ChannelPreviewMessenger.tsx @@ -1,9 +1,8 @@ import React, { useRef } from 'react'; import clsx from 'clsx'; +import { ChannelAvatar } from '../Avatar/ChannelAvatar'; -import { Avatar as DefaultAvatar } from '../Avatar'; import type { ChannelPreviewUIComponentProps } from './ChannelPreview'; - import type { DefaultStreamChatGenerics } from '../../types/types'; const UnMemoizedChannelPreviewMessenger = < @@ -13,11 +12,12 @@ const UnMemoizedChannelPreviewMessenger = < ) => { const { active, - Avatar = DefaultAvatar, + Avatar = ChannelAvatar, channel, className: customClassName = '', displayImage, displayTitle, + groupChannelDisplayInfo, latestMessagePreview, onSelect: customOnSelectChannel, setActiveChannel, @@ -59,6 +59,7 @@ const UnMemoizedChannelPreviewMessenger = <
diff --git a/src/components/ChannelPreview/__tests__/ChannelPreview.test.js b/src/components/ChannelPreview/__tests__/ChannelPreview.test.js index d78712bca4..1069e2e5f6 100644 --- a/src/components/ChannelPreview/__tests__/ChannelPreview.test.js +++ b/src/components/ChannelPreview/__tests__/ChannelPreview.test.js @@ -24,8 +24,10 @@ import { queryChannelsApi, useMockedApis, } from 'mock-builders'; +import { initClientWithChannels } from '../../../mock-builders'; const EMPTY_CHANNEL_PREVIEW_TEXT = 'Empty channel'; +const AVATAR_IMG_TEST_ID = 'avatar-img'; const PreviewUIComponent = (props) => ( <> @@ -332,7 +334,7 @@ describe('ChannelPreview', () => { ); describe('notification.mark_read', () => { - it('should set unread count to 0 for event missing CID', () => { + it('should set unread count to 0 for event missing CID', async () => { const unreadCount = getRandomInt(1, 10); c0.countUnread = () => unreadCount; renderComponent( @@ -343,11 +345,13 @@ describe('ChannelPreview', () => { render, ); expectUnreadCountToBe(screen.getByTestId, unreadCount); - dispatchNotificationMarkRead({ client }); + await act(() => { + dispatchNotificationMarkRead({ client }); + }); expectUnreadCountToBe(screen.getByTestId, 0); }); - it('should set unread count to 0 for current channel', () => { + it('should set unread count to 0 for current channel', async () => { const channelInPreview = c0; const unreadCount = getRandomInt(1, 10); c0.countUnread = () => unreadCount; @@ -359,11 +363,13 @@ describe('ChannelPreview', () => { render, ); expectUnreadCountToBe(screen.getByTestId, unreadCount); - dispatchNotificationMarkRead({ channel: channelInPreview, client }); + await act(() => { + dispatchNotificationMarkRead({ channel: channelInPreview, client }); + }); expectUnreadCountToBe(screen.getByTestId, 0); }); - it('should be ignored if not targeted for the current channel', () => { + it('should be ignored if not targeted for the current channel', async () => { const channelInPreview = c0; const activeChannel = c1; const unreadCount = getRandomInt(1, 10); @@ -376,13 +382,15 @@ describe('ChannelPreview', () => { render, ); expectUnreadCountToBe(screen.getByTestId, unreadCount); - dispatchNotificationMarkRead({ channel: activeChannel, client }); + await act(() => { + dispatchNotificationMarkRead({ channel: activeChannel, client }); + }); expectUnreadCountToBe(screen.getByTestId, unreadCount); }); }); describe('notification.mark_unread', () => { - it('should be ignored if not originated from the current user', () => { + it('should be ignored if not originated from the current user', async () => { const unreadCount = 0; const channelInPreview = c0; const activeChannel = c1; @@ -395,16 +403,18 @@ describe('ChannelPreview', () => { render, ); expectUnreadCountToBe(screen.getByTestId, unreadCount); - dispatchNotificationMarkUnread({ - channel: channelInPreview, - client, - payload: { unread_channels: 2, unread_messages: 5 }, - user: otherUser, + await act(() => { + dispatchNotificationMarkUnread({ + channel: channelInPreview, + client, + payload: { unread_channels: 2, unread_messages: 5 }, + user: otherUser, + }); }); expectUnreadCountToBe(screen.getByTestId, unreadCount); }); - it('should be ignored if not targeted for the current channel', () => { + it('should be ignored if not targeted for the current channel', async () => { const unreadCount = 0; const channelInPreview = c0; const activeChannel = c1; @@ -417,16 +427,18 @@ describe('ChannelPreview', () => { render, ); expectUnreadCountToBe(screen.getByTestId, unreadCount); - dispatchNotificationMarkUnread({ - channel: activeChannel, - client, - payload: { unread_channels: 2, unread_messages: 5 }, - user, + await act(() => { + dispatchNotificationMarkUnread({ + channel: activeChannel, + client, + payload: { unread_channels: 2, unread_messages: 5 }, + user, + }); }); expectUnreadCountToBe(screen.getByTestId, unreadCount); }); - it("should set unread count from client's unread count state for active channel", () => { + it("should set unread count from client's unread count state for active channel", async () => { const unreadCount = 0; const activeChannel = c1; activeChannel.countUnread = () => unreadCount; @@ -440,16 +452,18 @@ describe('ChannelPreview', () => { expectUnreadCountToBe(screen.getByTestId, unreadCount); const eventPayload = { unread_channels: 2, unread_messages: 5 }; - dispatchNotificationMarkUnread({ - channel: activeChannel, - client, - payload: { unread_channels: 2, unread_messages: 5 }, - user, + await act(() => { + dispatchNotificationMarkUnread({ + channel: activeChannel, + client, + payload: { unread_channels: 2, unread_messages: 5 }, + user, + }); }); expectUnreadCountToBe(screen.getByTestId, eventPayload.unread_messages); }); - it("should set unread count from client's unread count state for non-active channel", () => { + it("should set unread count from client's unread count state for non-active channel", async () => { const unreadCount = 0; const channelInPreview = c0; const activeChannel = c1; @@ -464,21 +478,43 @@ describe('ChannelPreview', () => { expectUnreadCountToBe(screen.getByTestId, unreadCount); const eventPayload = { unread_channels: 2, unread_messages: 5 }; - dispatchNotificationMarkUnread({ - channel: channelInPreview, - client, - payload: { unread_channels: 2, unread_messages: 5 }, - user, + await act(() => { + dispatchNotificationMarkUnread({ + channel: channelInPreview, + client, + payload: { unread_channels: 2, unread_messages: 5 }, + user, + }); }); expectUnreadCountToBe(screen.getByTestId, eventPayload.unread_messages); }); }); describe('user.updated', () => { - let chatClient; - let channels; - let channelState; - let otherUser; + const renderComponent = async ({ channel, channelPreviewProps, client }) => { + let result; + await act(() => { + result = render( + + + , + ); + }); + + return result; + }; + const getChannelState = (memberCount, channelData) => { + const users = Array.from({ length: memberCount }, generateUser); + const members = users.map((user) => generateMember({ user })); + return generateChannel({ + members, + messages: users.map((user) => generateMessage({ user })), + ...channelData, + }); + }; + + const channelState = getChannelState(2); + const MockAvatar = ({ image, name }) => ( <>
{name}
@@ -490,62 +526,50 @@ describe('ChannelPreview', () => { Avatar: MockAvatar, }; - beforeEach(async () => { - const activeUser = generateUser({ - custom: 'custom1', - id: 'id1', - image: 'image1', - name: 'name1', - }); - otherUser = generateUser({ - custom: 'custom2', - id: 'id2', - image: 'image2', - name: 'name2', - }); - channelState = generateChannel({ - members: [generateMember({ user: activeUser }), generateMember({ user: otherUser })], - messages: [generateMessage({ user: activeUser }), generateMessage({ user: otherUser })], - }); - chatClient = await getTestClientWithUser(activeUser); - useMockedApis(chatClient, [queryChannelsApi([channelState])]); - channels = await chatClient.queryChannels(); - }); - it("should update the direct messaging channel's preview if other user's name has changed", async () => { + const ownUser = channelState.members[0].user; + const otherUser = channelState.members[1].user; + const { + channels: [channel], + client, + } = await initClientWithChannels({ + channelsData: [channelState], + customUser: ownUser, + }); const updatedAttribute = { name: 'new-name' }; - const channel = channels[0]; - render( - - - , - ); + await renderComponent({ channel, client }); - await waitFor(() => - expect(screen.queryByText(updatedAttribute.name)).not.toBeInTheDocument(), - ); + await waitFor(() => { + expect(screen.queryByText(updatedAttribute.name)).not.toBeInTheDocument(); + expect(screen.getByText(otherUser.name)).toBeInTheDocument(); + }); act(() => { - dispatchUserUpdatedEvent(chatClient, { ...otherUser, ...updatedAttribute }); + dispatchUserUpdatedEvent(client, { ...otherUser, ...updatedAttribute }); + }); + await waitFor(() => { + expect(screen.queryAllByText(updatedAttribute.name).length).toBeGreaterThan(0); + expect(screen.queryByText(otherUser.name)).not.toBeInTheDocument(); }); - await waitFor(() => - expect(screen.queryAllByText(updatedAttribute.name).length).toBeGreaterThan(0), - ); }); it("should update the direct messaging channel's preview if other user's image has changed", async () => { + const ownUser = channelState.members[0].user; + const otherUser = channelState.members[1].user; + const { + channels: [channel], + client, + } = await initClientWithChannels({ + channelsData: [channelState], + customUser: ownUser, + }); const updatedAttribute = { image: 'new-image' }; - const channel = channels[0]; - render( - - - , - ); + await renderComponent({ channel, channelPreviewProps, client }); await waitFor(() => expect(screen.queryByText(updatedAttribute.image)).not.toBeInTheDocument(), ); act(() => { - dispatchUserUpdatedEvent(chatClient, { ...otherUser, ...updatedAttribute }); + dispatchUserUpdatedEvent(client, { ...otherUser, ...updatedAttribute }); }); await waitFor(() => expect(screen.queryAllByText(updatedAttribute.image).length).toBeGreaterThan(0), @@ -553,23 +577,213 @@ describe('ChannelPreview', () => { }); it("should not update the direct messaging channel's preview if other user attribute than name or image has changed", async () => { + const ownUser = channelState.members[0].user; + const otherUser = channelState.members[1].user; + const { + channels: [channel], + client, + } = await initClientWithChannels({ + channelsData: [channelState], + customUser: ownUser, + }); const updatedAttribute = { custom: 'new-custom' }; - const channel = channels[0]; - render( - - - , - ); + await renderComponent({ channel, channelPreviewProps, client }); await waitFor(() => expect(screen.queryByText(updatedAttribute.custom)).not.toBeInTheDocument(), ); act(() => { - dispatchUserUpdatedEvent(chatClient, { ...otherUser, ...updatedAttribute }); + dispatchUserUpdatedEvent(client, { ...otherUser, ...updatedAttribute }); }); await waitFor(() => expect(screen.queryByText(updatedAttribute.custom)).not.toBeInTheDocument(), ); }); + + describe('group channel', () => { + const channelName = 'channel-name'; + const channelState = getChannelState(3, { channel: { name: channelName } }); + + it('renders max 4 avatars in channel avatar', async () => { + const channelState = getChannelState(5); + const ownUser = channelState.members[0].user; + const { + channels: [channel], + client, + } = await initClientWithChannels({ + channelsData: [channelState], + customUser: ownUser, + }); + await renderComponent({ channel, client }); + await waitFor(() => { + const avatarImages = screen.getAllByTestId(AVATAR_IMG_TEST_ID); + expect(avatarImages).toHaveLength(4); + avatarImages.slice(0, 4).forEach((img, i) => { + expect(img).toHaveAttribute('src', channelState.members[i].user.image); + }); + }); + }); + + it.each([ + ['own user', channelState.members[0].user], + ['other user', channelState.members[2].user], + ])( + "should not update the direct messaging channel's preview title if %s's name has changed", + async (_, user) => { + const { + channels: [channel], + client, + } = await initClientWithChannels({ channelsData: [channelState] }); + const updatedAttribute = { name: 'new-name' }; + await renderComponent({ channel, client }); + + await waitFor(() => { + expect(screen.queryByText(updatedAttribute.name)).not.toBeInTheDocument(); + expect(screen.getByText(channelName)).toBeInTheDocument(); + }); + act(() => { + dispatchUserUpdatedEvent(client, { ...user, ...updatedAttribute }); + }); + await waitFor(() => { + expect(screen.queryByText(updatedAttribute.name)).not.toBeInTheDocument(); + expect(screen.getByText(channelName)).toBeInTheDocument(); + }); + }, + ); + + it("should update the direct messaging channel's preview image if own user's image has changed", async () => { + const ownUser = channelState.members[0].user; + const { + channels: [channel], + client, + } = await initClientWithChannels({ + channelsData: [channelState], + customUser: ownUser, + }); + const updatedAttribute = { image: 'new-image' }; + await renderComponent({ channel, client }); + await waitFor(() => { + const avatarImages = screen.getAllByTestId(AVATAR_IMG_TEST_ID); + expect(avatarImages).toHaveLength(3); + expect(avatarImages[0]).toHaveAttribute('src', ownUser.image); + expect(avatarImages[1]).toHaveAttribute('src', channelState.members[1].user.image); + expect(avatarImages[2]).toHaveAttribute('src', channelState.members[2].user.image); + }); + + act(() => { + dispatchUserUpdatedEvent(client, { ...ownUser, ...updatedAttribute }); + }); + + await waitFor(() => { + const avatarImages = screen.getAllByTestId(AVATAR_IMG_TEST_ID); + expect(avatarImages[0]).toHaveAttribute('src', updatedAttribute.image); + expect(avatarImages[1]).toHaveAttribute('src', channelState.members[1].user.image); + expect(avatarImages[2]).toHaveAttribute('src', channelState.members[2].user.image); + }); + }); + + it("should update the direct messaging channel's preview image if other user's image has changed", async () => { + const ownUser = channelState.members[0].user; + const otherUser = channelState.members[2].user; + const { + channels: [channel], + client, + } = await initClientWithChannels({ + channelsData: [channelState], + customUser: ownUser, + }); + const updatedAttribute = { image: 'new-image' }; + await renderComponent({ channel, client }); + await waitFor(() => { + const avatarImages = screen.getAllByTestId(AVATAR_IMG_TEST_ID); + expect(avatarImages).toHaveLength(3); + expect(avatarImages[0]).toHaveAttribute('src', ownUser.image); + expect(avatarImages[1]).toHaveAttribute('src', channelState.members[1].user.image); + expect(avatarImages[2]).toHaveAttribute('src', channelState.members[2].user.image); + }); + + act(() => { + dispatchUserUpdatedEvent(client, { ...otherUser, ...updatedAttribute }); + }); + + await waitFor(() => { + const avatarImages = screen.getAllByTestId(AVATAR_IMG_TEST_ID); + expect(avatarImages[0]).toHaveAttribute('src', ownUser.image); + expect(avatarImages[1]).toHaveAttribute('src', channelState.members[1].user.image); + expect(avatarImages[2]).toHaveAttribute('src', updatedAttribute.image); + }); + }); + + it("should not update the direct messaging channel's preview if other user's attribute than name or image has changed", async () => { + const ownUser = channelState.members[0].user; + const otherUser = channelState.members[2].user; + const { + channels: [channel], + client, + } = await initClientWithChannels({ + channelsData: [channelState], + customUser: ownUser, + }); + const updatedAttribute = { custom: 'new-custom' }; + await renderComponent({ channel, client }); + + await waitFor(() => { + expect(screen.queryByText(updatedAttribute.custom)).not.toBeInTheDocument(); + expect(screen.getByText(channelName)).toBeInTheDocument(); + const avatarImages = screen.getAllByTestId(AVATAR_IMG_TEST_ID); + avatarImages.forEach((img, i) => { + expect(img).toHaveAttribute('src', channelState.members[i].userimage); + }); + }); + + act(() => { + dispatchUserUpdatedEvent(client, { ...otherUser, ...updatedAttribute }); + }); + + await waitFor(() => { + expect(screen.queryByText(updatedAttribute.custom)).not.toBeInTheDocument(); + expect(screen.getByText(channelName)).toBeInTheDocument(); + const avatarImages = screen.getAllByTestId(AVATAR_IMG_TEST_ID); + avatarImages.forEach((img, i) => { + expect(img).toHaveAttribute('src', channelState.members[i].userimage); + }); + }); + }); + + it("should not update the direct messaging channel's preview if own user's attribute than name or image has changed", async () => { + const ownUser = channelState.members[0].user; + const { + channels: [channel], + client, + } = await initClientWithChannels({ + channelsData: [channelState], + customUser: ownUser, + }); + const updatedAttribute = { custom: 'new-custom' }; + await renderComponent({ channel, client }); + + await waitFor(() => { + expect(screen.queryByText(updatedAttribute.custom)).not.toBeInTheDocument(); + expect(screen.getByText(channelName)).toBeInTheDocument(); + const avatarImages = screen.getAllByTestId(AVATAR_IMG_TEST_ID); + avatarImages.forEach((img, i) => { + expect(img).toHaveAttribute('src', channelState.members[i].userimage); + }); + }); + + act(() => { + dispatchUserUpdatedEvent(client, { ...ownUser, ...updatedAttribute }); + }); + + await waitFor(() => { + expect(screen.queryByText(updatedAttribute.custom)).not.toBeInTheDocument(); + expect(screen.getByText(channelName)).toBeInTheDocument(); + const avatarImages = screen.getAllByTestId(AVATAR_IMG_TEST_ID); + avatarImages.forEach((img, i) => { + expect(img).toHaveAttribute('src', channelState.members[i].userimage); + }); + }); + }); + }); }); }); diff --git a/src/components/ChannelPreview/hooks/useChannelPreviewInfo.ts b/src/components/ChannelPreview/hooks/useChannelPreviewInfo.ts index aaa777245e..336b7eb768 100644 --- a/src/components/ChannelPreview/hooks/useChannelPreviewInfo.ts +++ b/src/components/ChannelPreview/hooks/useChannelPreviewInfo.ts @@ -1,11 +1,11 @@ import { useEffect, useState } from 'react'; import type { Channel } from 'stream-chat'; -import { getDisplayImage, getDisplayTitle } from '../utils'; -import type { DefaultStreamChatGenerics } from '../../../types/types'; - +import { getDisplayImage, getDisplayTitle, getGroupChannelDisplayInfo } from '../utils'; import { useChatContext } from '../../../context'; +import type { DefaultStreamChatGenerics } from '../../../types/types'; + export type ChannelPreviewInfoParams = { channel: Channel; /** Manually set the image to render, defaults to the Channel image */ @@ -29,24 +29,32 @@ export const useChannelPreviewInfo = < () => overrideImage || getDisplayImage(channel, client.user), ); + const [groupChannelDisplayInfo, setGroupDisplayChannelInfo] = useState< + ReturnType + >(() => getGroupChannelDisplayInfo(channel)); + useEffect(() => { if (overrideTitle && overrideImage) return; - const updateTitles = () => { + const updateInfo = () => { if (!overrideTitle) setDisplayTitle(getDisplayTitle(channel, client.user)); - if (!overrideImage) setDisplayImage(getDisplayImage(channel, client.user)); + if (!overrideImage) { + setDisplayImage(getDisplayImage(channel, client.user)); + setGroupDisplayChannelInfo(getGroupChannelDisplayInfo(channel)); + } }; - updateTitles(); + updateInfo(); - client.on('user.updated', updateTitles); + client.on('user.updated', updateInfo); return () => { - client.off('user.updated', updateTitles); + client.off('user.updated', updateInfo); }; }, [channel, channel.data, client, overrideImage, overrideTitle]); return { displayImage: overrideImage || displayImage, displayTitle: overrideTitle || displayTitle, + groupChannelDisplayInfo, }; }; diff --git a/src/components/ChannelPreview/utils.tsx b/src/components/ChannelPreview/utils.tsx index 527f4e62e4..06205e679f 100644 --- a/src/components/ChannelPreview/utils.tsx +++ b/src/components/ChannelPreview/utils.tsx @@ -91,6 +91,26 @@ export const getLatestMessagePreview = < return t('Empty message...'); }; +export type GroupChannelDisplayInfo = { image?: string; name?: string }[]; + +export const getGroupChannelDisplayInfo = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>( + channel: Channel, +): GroupChannelDisplayInfo | undefined => { + const members = Object.values(channel.state.members); + if (members.length <= 2) return; + + const info: GroupChannelDisplayInfo = []; + for (let i = 0; i < members.length; i++) { + const { user } = members[i]; + if (!user?.name && !user?.image) continue; + info.push({ image: user.image, name: user.name }); + if (info.length === 4) break; + } + return info; +}; + const getChannelDisplayInfo = < StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics >( diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index ed28c71249..b0d6eb6ff1 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -5,6 +5,7 @@ import { AttachmentProps, AvatarProps, BaseImageProps, + ChannelAvatarProps, CooldownTimerProps, CustomMessageActionsListProps, DateSeparatorProps, @@ -77,6 +78,8 @@ export type ComponentContextValue< Avatar?: React.ComponentType>; /** Custom UI component to display elements resp. a fallback in case of load error, defaults to and accepts same props as: [BaseImage](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Gallery/BaseImage.tsx) */ BaseImage?: React.ComponentType; + /** Custom UI component to display avatar for a channel in ChannelHeader: [ChannelAvatar](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Avatar/ChannelAvatar.tsx) */ + ChannelAvatar?: React.ComponentType; /** Custom UI component to display the slow mode cooldown timer, defaults to and accepts same props as: [CooldownTimer](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageInput/CooldownTimer.tsx) */ CooldownTimer?: React.ComponentType; /** Custom UI component to render set of buttons to be displayed in the MessageActionsBox, defaults to and accepts same props as: [CustomMessageActionsList](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageActions/CustomMessageActionsList.tsx) */