From 41b23db9c6ae99b0a4812ca0eca7f89998a3846a Mon Sep 17 00:00:00 2001 From: MartinCupela <32706194+MartinCupela@users.noreply.github.com> Date: Thu, 14 Nov 2024 09:16:10 +0100 Subject: [PATCH 1/2] feat: support custom member data in StreamChatGenerics (#2559) --- examples/typescript/src/App.tsx | 2 ++ examples/vite/src/App.tsx | 2 ++ package.json | 4 ++-- src/types/types.ts | 1 + yarn.lock | 8 ++++---- 5 files changed, 11 insertions(+), 6 deletions(-) diff --git a/examples/typescript/src/App.tsx b/examples/typescript/src/App.tsx index 36a957d6e..ee16cb311 100644 --- a/examples/typescript/src/App.tsx +++ b/examples/typescript/src/App.tsx @@ -27,6 +27,7 @@ type LocalAttachmentType = Record; type LocalChannelType = Record; type LocalCommandType = string; type LocalEventType = Record; +type LocalMemberType = Record; type LocalMessageType = Record; type LocalPollOptionType = Record; type LocalPollType = Record; @@ -38,6 +39,7 @@ type StreamChatGenerics = { channelType: LocalChannelType; commandType: LocalCommandType; eventType: LocalEventType; + memberType: LocalMemberType; messageType: LocalMessageType; pollOptionType: LocalPollOptionType; pollType: LocalPollType; diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index 3fbcdb6b7..748052c2c 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -41,6 +41,7 @@ type LocalAttachmentType = Record; type LocalChannelType = Record; type LocalCommandType = string; type LocalEventType = Record; +type LocalMemberType = Record; type LocalMessageType = Record; type LocalPollOptionType = Record; type LocalPollType = Record; @@ -52,6 +53,7 @@ type StreamChatGenerics = { channelType: LocalChannelType; commandType: LocalCommandType; eventType: LocalEventType; + memberType: LocalMemberType; messageType: LocalMessageType; pollOptionType: LocalPollOptionType; pollType: LocalPollType; diff --git a/package.json b/package.json index ad76321b4..171abe2ce 100644 --- a/package.json +++ b/package.json @@ -145,7 +145,7 @@ "emoji-mart": "^5.4.0", "react": "^18.0.0 || ^17.0.0 || ^16.8.0", "react-dom": "^18.0.0 || ^17.0.0 || ^16.8.0", - "stream-chat": "^8.42.0" + "stream-chat": "^8.44.0" }, "peerDependenciesMeta": { "@breezystack/lamejs": { @@ -255,7 +255,7 @@ "react-dom": "^18.1.0", "react-test-renderer": "^18.1.0", "semantic-release": "^19.0.5", - "stream-chat": "^8.42.0", + "stream-chat": "^8.44.0", "ts-jest": "^29.1.4", "typescript": "^5.4.5" }, diff --git a/src/types/types.ts b/src/types/types.ts index d83cf4ad1..1b4beaf00 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -68,6 +68,7 @@ export type DefaultStreamChatGenerics = ExtendableGenerics & { channelType: DefaultChannelType; commandType: LiteralStringForUnion; eventType: UnknownType; + memberType: UnknownType; messageType: DefaultMessageType; pollOptionType: UnknownType; pollType: UnknownType; diff --git a/yarn.lock b/yarn.lock index e69774044..e48a75e86 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12200,10 +12200,10 @@ statuses@2.0.1: resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== -stream-chat@^8.42.0: - version "8.42.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-8.42.0.tgz#124ea2c10c6e8f7990304e1101c66751daf63e6c" - integrity sha512-8xZz+fmdHSOa3L1rHUOC4Wah+ipvLvdiOmeOfGK6uXnLOKlSHMOblwmQErrOoFM4SKfX9Bea3V8viaKUu6bPng== +stream-chat@^8.44.0: + version "8.44.0" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-8.44.0.tgz#e48446cf91db786e80a2e7358ac4f1601e5e97f0" + integrity sha512-7HNtimD8sT/51rsFibGcD6uBgKj+vlKyYCZMDzjYQEaEsrLqyAg48dOyNM4L2FTF5aXgo9SlxZr21SPleeA2BA== dependencies: "@babel/runtime" "^7.16.3" "@types/jsonwebtoken" "~9.0.0" From 414745ddd84dc35a025bf046ffa589a288aadf9b Mon Sep 17 00:00:00 2001 From: MartinCupela <32706194+MartinCupela@users.noreply.github.com> Date: Thu, 14 Nov 2024 12:15:18 +0100 Subject: [PATCH 2/2] feat: add group avatar (#2556) --- .../contexts/channel-list-context.mdx | 12 +- .../core-components/channel-list.mdx | 2 +- .../components/utility-components/avatar.mdx | 70 +++- examples/vite/src/App.tsx | 12 +- package.json | 2 +- src/components/Avatar/Avatar.tsx | 6 +- src/components/Avatar/ChannelAvatar.tsx | 22 + src/components/Avatar/GroupAvatar.tsx | 39 ++ src/components/Avatar/index.ts | 2 + .../ChannelHeader/ChannelHeader.tsx | 9 +- .../__tests__/ChannelHeader.test.js | 227 ++++++++++- src/components/ChannelList/ChannelList.tsx | 8 +- .../ChannelPreview/ChannelPreview.tsx | 15 +- .../ChannelPreviewMessenger.tsx | 5 +- .../__tests__/ChannelPreview.test.js | 384 ++++++++++++++---- .../hooks/useChannelPreviewInfo.ts | 24 +- src/components/ChannelPreview/utils.tsx | 20 + yarn.lock | 8 +- 18 files changed, 716 insertions(+), 151 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 d2ddf659a..13e3dc491 100644 --- a/docusaurus/docs/React/components/contexts/channel-list-context.mdx +++ b/docusaurus/docs/React/components/contexts/channel-list-context.mdx @@ -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/core-components/channel-list.mdx b/docusaurus/docs/React/components/core-components/channel-list.mdx index 52b7e6110..5821365ad 100644 --- a/docusaurus/docs/React/components/core-components/channel-list.mdx +++ b/docusaurus/docs/React/components/core-components/channel-list.mdx @@ -187,7 +187,7 @@ 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 `Avatar`. | Type | Default | | --------- | ---------------------------------------------------------- | diff --git a/docusaurus/docs/React/components/utility-components/avatar.mdx b/docusaurus/docs/React/components/utility-components/avatar.mdx index 3236cb522..d9d67ba61 100644 --- a/docusaurus/docs/React/components/utility-components/avatar.mdx +++ b/docusaurus/docs/React/components/utility-components/avatar.mdx @@ -3,44 +3,64 @@ id: avatar title: Avatar --- -The `Avatar` component displays an image, with fallback to the first letter of the optional name prop. +Semantically we can speak about two types of avatars in the SDK. One type is the avatar that represents the channel and the other representing another user. The SDK exports the follwing avatar components: -## Basic Usage +- `Avatar` - displays single image or name initials in case image is not available +- `GroupAvatar` - displays images or name initials as a fallback in a 2x2 grid +- `ChannelAvatar` - renders `GroupAvatar` in case a channel has more than two members and `Avatar` otherwise -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. +By default, all the SDK components use `Avatar` to display channel resp. user avatar. However, it makes sense to override the default in `ChannelList` resp. `ChannelPreview` and `ChannelHeader` as those avatars may represent a group of users . + +## Customizing avatar component + +Passing your custom avatar component to `Channel` prop `Avatar` overrides the avatar for all the `Channel` component's children. 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 -const CustomAvatar = (props) => { - return ; +import { ChannelList } from 'stream-chat-react'; +import type { ChannelAvatarProps } from 'stream-chat-react'; + +const CustomChannelAvatar = (props: ChannelAvatarProps) => { + return
Custom Channel Avatar
; }; - } />; +; ``` -## Props +To override the channel avatar in `ChannelHeader` we need to provide it prop `Avatar`: + +```tsx +import { ChannelHeader } from 'stream-chat-react'; +import type { ChannelAvatarProps } from 'stream-chat-react'; + +const CustomChannelAvatar = (props: ChannelAvatarProps) => { + return
Custom Channel Avatar
; +}; + +; +``` + +Also, we can take the advantage of existing SDK's `ChannelAvatar` and pass it to both `ChannelHeader` and `ChannelList` as described above. + +## Avatar Props ### className @@ -89,3 +109,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/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index 748052c2c..4fd893c68 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -1,6 +1,7 @@ import { ChannelFilters, ChannelOptions, ChannelSort } from 'stream-chat'; import { Channel, + ChannelAvatar, ChannelHeader, ChannelList, Chat, @@ -75,10 +76,17 @@ const App = () => { - + - + diff --git a/package.json b/package.json index 171abe2ce..d73a5be90 100644 --- a/package.json +++ b/package.json @@ -186,7 +186,7 @@ "@semantic-release/changelog": "^6.0.2", "@semantic-release/git": "^10.0.1", "@stream-io/rollup-plugin-node-builtins": "^2.1.5", - "@stream-io/stream-chat-css": "^5.2.0", + "@stream-io/stream-chat-css": "^5.4.0", "@testing-library/jest-dom": "^6.1.4", "@testing-library/react": "^13.1.1", "@testing-library/react-hooks": "^8.0.0", diff --git a/src/components/Avatar/Avatar.tsx b/src/components/Avatar/Avatar.tsx index ce89f127e..4c8ab2005 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 000000000..eab6b088f --- /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 000000000..89a127d88 --- /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 27700fe3f..705e7dd41 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/ChannelHeader/ChannelHeader.tsx b/src/components/ChannelHeader/ChannelHeader.tsx index 4313e0445..05e56d9d9 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 { ChannelAvatarProps, Avatar as DefaultAvatar } from '../Avatar'; import { useChannelPreviewInfo } from '../ChannelPreview/hooks/useChannelPreviewInfo'; import { useChannelStateContext } from '../../context/ChannelStateContext'; @@ -12,8 +12,8 @@ import { useTranslationContext } from '../../context/TranslationContext'; 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; + /** UI component to display an avatar, defaults to [Avatar](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Avatar/Avatar.tsx) component and accepts the same props as: [ChannelAvatar](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Avatar/ChannelAvatar.tsx) */ + 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 */ @@ -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 eb5040a28..b6d10b749 100644 --- a/src/components/ChannelHeader/__tests__/ChannelHeader.test.js +++ b/src/components/ChannelHeader/__tests__/ChannelHeader.test.js @@ -11,15 +11,20 @@ import { dispatchUserUpdatedEvent, generateChannel, generateMember, + generateMessage, generateUser, getOrCreateChannelApi, getTestClientWithUser, + initClientWithChannels, useMockedApis, } from '../../../mock-builders'; import { toHaveNoViolations } from 'jest-axe'; import { axe } from '../../../../axe-helper'; +import { ChannelAvatar } from '../../Avatar'; expect.extend(toHaveNoViolations); +const AVATAR_IMG_TEST_ID = 'avatar-img'; + const user1 = generateUser(); const user2 = generateUser({ image: null }); let testChannel1; @@ -29,16 +34,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 +47,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 +193,203 @@ describe('ChannelHeader', () => { expect(screen.getByTestId('avatar-img')).toHaveAttribute('src', updatedAttribute.image), ); }); + + describe('group channel', () => { + const props = { + Avatar: ChannelAvatar, + }; + + 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, props }); + 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, props }); + + 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, props }); + 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, props }); + 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, props }); + + 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, props }); + + 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 5f866da00..6b7ba41eb 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 { Avatar as DefaultAvatar } 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; + /** UI component to display an avatar, defaults to [Avatar](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Avatar/Avatar.tsx) component and accepts the 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>, diff --git a/src/components/ChannelPreview/ChannelPreview.tsx b/src/components/ChannelPreview/ChannelPreview.tsx index d368ae3a4..836805935 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. */ @@ -46,8 +48,8 @@ export type ChannelPreviewProps< channel: Channel; /** 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; + /** UI component to display an avatar, defaults to [Avatar](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Avatar/Avatar.tsx) component and accepts the same props as: [ChannelAvatar](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Avatar/ChannelAvatar.tsx) */ + 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 e0f2c3630..4fb9c5435 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 { Avatar as DefaultAvatar } from '../Avatar'; -import type { ChannelPreviewUIComponentProps } from './ChannelPreview'; +import type { ChannelPreviewUIComponentProps } from './ChannelPreview'; import type { DefaultStreamChatGenerics } from '../../types/types'; const UnMemoizedChannelPreviewMessenger = < @@ -18,6 +17,7 @@ const UnMemoizedChannelPreviewMessenger = < 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 d78712bca..035ac4bb5 100644 --- a/src/components/ChannelPreview/__tests__/ChannelPreview.test.js +++ b/src/components/ChannelPreview/__tests__/ChannelPreview.test.js @@ -2,6 +2,7 @@ import React from 'react'; import { act, render, screen, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; +import { ChannelAvatar } from '../../Avatar'; import { ChannelPreview } from '../ChannelPreview'; import { Chat } from '../../Chat'; @@ -24,8 +25,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 +335,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 +346,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 +364,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 +383,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 +404,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 +428,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 +453,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 +479,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 +527,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 +578,216 @@ 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 channelPreviewProps = { + Avatar: ChannelAvatar, + }; + 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, channelPreviewProps, 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, channelPreviewProps, 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, channelPreviewProps, 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, channelPreviewProps, 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, channelPreviewProps, 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, channelPreviewProps, 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 aaa777245..336b7eb76 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 527f4e62e..06205e679 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/yarn.lock b/yarn.lock index e48a75e86..97b10f509 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2356,10 +2356,10 @@ crypto-browserify "^3.11.0" process-es6 "^0.11.2" -"@stream-io/stream-chat-css@^5.2.0": - version "5.2.0" - resolved "https://registry.yarnpkg.com/@stream-io/stream-chat-css/-/stream-chat-css-5.2.0.tgz#7ee959fb806b797842dac8fc3ab4f11581d430b9" - integrity sha512-/HxTwNlNj1yEYWidJqEu2C7Q+n0Nd/0l1UmkIb/wuTx4XMUUhCArJv/o3znHdHXaG/pJ5RUksFzgUpQ3kP2LuA== +"@stream-io/stream-chat-css@^5.4.0": + version "5.4.0" + resolved "https://registry.yarnpkg.com/@stream-io/stream-chat-css/-/stream-chat-css-5.4.0.tgz#c0da423bb62059cf5ff8820b057aaf8b073782dc" + integrity sha512-uVjksyycM999L9kMLOuLfSyjcSPpztF4Y4ZMTWEuPL87qLHCr1lRxytGc9EWtgcdYuohj3UcLtOj5hUkpWU06Q== "@stream-io/transliterate@^1.5.5": version "1.5.5"