Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions src/components/Avatar/ChannelAvatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,28 @@ import React from 'react';

import { Avatar, GroupAvatar } from './';
import type { AvatarProps, GroupAvatarProps } from './';
import type { GroupAvatarMember } from './GroupAvatar';

export type ChannelAvatarProps = Partial<Omit<GroupAvatarProps & AvatarProps, 'size'>> & {
size: GroupAvatarProps['size'];
/** When set with length >= 2, GroupAvatar is used. */
displayMembers?: GroupAvatarMember[];
overflowCount?: number;
};

export const ChannelAvatar = ({
groupChannelDisplayInfo,
displayMembers,
imageUrl,
overflowCount,
size,
userName,
...sharedProps
}: ChannelAvatarProps) => {
if (groupChannelDisplayInfo) {
if ((displayMembers?.length ?? 0) >= 2) {
return (
<GroupAvatar
groupChannelDisplayInfo={groupChannelDisplayInfo}
displayMembers={displayMembers}
overflowCount={overflowCount}
size={size}
{...sharedProps}
/>
Expand Down
25 changes: 15 additions & 10 deletions src/components/Avatar/GroupAvatar.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,38 @@
import clsx from 'clsx';
import React, { type ComponentPropsWithoutRef } from 'react';
import { Avatar, type AvatarProps } from './Avatar';
import type { GroupChannelDisplayInfo } from '../ChannelPreview';

export type GroupAvatarMember = {
imageUrl?: string;
userName?: string;
};

export type GroupAvatarProps = ComponentPropsWithoutRef<'div'> & {
/** Mapping of image URLs to names which initials will be used as fallbacks in case image assets fail to load. */
groupChannelDisplayInfo: GroupChannelDisplayInfo;
/** List of members to show as avatars; at most 2 when overflowCount is set, otherwise 4. Defaults to [] when omitted. */
displayMembers?: GroupAvatarMember[];
/** Optional count for the "+N" badge when there are more members than shown. */
overflowCount?: number;
size: '2xl' | 'xl' | 'lg' | null;
isOnline?: boolean;
overflowCount?: number;
};

/**
* Avatar component to display multiple users' avatars in a group channel, with a maximum of 4 avatars shown.
* Renders a single Avatar if only one user is provided.
* Avatar component to display multiple users' avatars in a group.
* Renders a single Avatar if fewer than 2 members. Otherwise, renders up to 2 avatars (when overflowCount is set) or 4, plus an optional +N badge.
*/
// TODO: rename to AvatarGroup
export const GroupAvatar = ({
className,
groupChannelDisplayInfo,
displayMembers = [],
isOnline,
overflowCount,
size,
...rest
}: GroupAvatarProps) => {
const displayCountBadge = typeof overflowCount === 'number' && overflowCount > 0;

if (!groupChannelDisplayInfo || groupChannelDisplayInfo.length < 2) {
const [firstUser] = groupChannelDisplayInfo || [];
if (displayMembers.length < 2) {
const firstUser = displayMembers[0];

return (
<Avatar
Expand Down Expand Up @@ -64,7 +69,7 @@ export const GroupAvatar = ({
role='button'
{...rest}
>
{groupChannelDisplayInfo
{displayMembers
.slice(0, displayCountBadge ? 2 : 4)
.map(({ imageUrl, userName }, index) => (
<Avatar
Expand Down
8 changes: 4 additions & 4 deletions src/components/ChannelHeader/ChannelHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import React from 'react';

import { IconLayoutAlignLeft } from '../Icons/icons';
import type { ChannelAvatarProps } from '../Avatar';
import { Avatar as DefaultAvatar } from '../Avatar';
import { type ChannelAvatarProps, ChannelAvatar as DefaultAvatar } from '../Avatar';
import { useChannelHeaderOnlineStatus } from './hooks/useChannelHeaderOnlineStatus';
import { useChannelPreviewInfo } from '../ChannelPreview/hooks/useChannelPreviewInfo';
import { useChannelStateContext } from '../../context/ChannelStateContext';
Expand Down Expand Up @@ -33,7 +32,7 @@ export const ChannelHeader = (props: ChannelHeaderProps) => {
} = props;

const { channel } = useChannelStateContext();
const { navOpen } = useChatContext('ChannelHeader');
const { navOpen } = useChatContext();
const { displayImage, displayTitle, groupChannelDisplayInfo } = useChannelPreviewInfo({
channel,
overrideImage,
Expand All @@ -60,8 +59,9 @@ export const ChannelHeader = (props: ChannelHeaderProps) => {
</div>
<Avatar
className='str-chat__avatar--channel-header'
groupChannelDisplayInfo={groupChannelDisplayInfo}
displayMembers={groupChannelDisplayInfo?.members}
imageUrl={displayImage}
overflowCount={groupChannelDisplayInfo?.overflowCount}
size='lg'
userName={displayTitle}
/>
Expand Down
3 changes: 2 additions & 1 deletion src/components/ChannelPreview/ChannelPreviewMessenger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,9 @@ const UnMemoizedChannelPreviewMessenger = (props: ChannelPreviewUIComponentProps
role='option'
>
<Avatar
groupChannelDisplayInfo={groupChannelDisplayInfo}
displayMembers={groupChannelDisplayInfo?.members}
imageUrl={displayImage}
overflowCount={groupChannelDisplayInfo?.overflowCount}
size='xl'
userName={avatarName}
/>
Expand Down
13 changes: 7 additions & 6 deletions src/components/ChannelPreview/__tests__/utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
useMockedApis,
} from 'mock-builders';

import { getDisplayImage, getDisplayTitle, getLatestMessagePreview } from '../utils';
import { getLatestMessagePreview } from '../utils';
import { generateStaticLocationResponse } from '../../../mock-builders';
import { render } from '@testing-library/react';

Expand Down Expand Up @@ -107,7 +107,7 @@ describe('ChannelPreview utils', () => {
generateChannel({ channel: { name } }),
);

expect(getDisplayTitle(channel, chatClient.user)).toBe(name);
expect(channel.getDisplayName()).toBe(name);
});

it('should return name of other member of conversation if only 2 members and channel name doesnot exist', async () => {
Expand All @@ -120,7 +120,7 @@ describe('ChannelPreview utils', () => {
],
}),
);
expect(getDisplayTitle(channel, chatClient.user)).toBe(otherUser.name);
expect(channel.getDisplayName()).toBe(otherUser.name);
});
});

Expand All @@ -131,10 +131,10 @@ describe('ChannelPreview utils', () => {
generateChannel({ channel: { image } }),
);

expect(getDisplayImage(channel, chatClient.user)).toBe(image);
expect(channel.getDisplayImage()).toBe(image);
});

it('should return picture of other member of conversation if only 2 members and channel name doesnot exist', async () => {
it('should return null when no image is available (image fallback removed)', async () => {
const otherUser = generateUser();
const channel = await getQueriedChannelInstance(
generateChannel({
Expand All @@ -144,7 +144,8 @@ describe('ChannelPreview utils', () => {
],
}),
);
expect(getDisplayImage(channel, chatClient.user)).toBe(otherUser.image);
// getDisplayImage no longer falls back to member image, only channel.data.image
expect(channel.getDisplayImage()).toBeNull();
});
});
});
110 changes: 73 additions & 37 deletions src/components/ChannelPreview/hooks/useChannelPreviewInfo.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,90 @@
import { useEffect, useState } from 'react';
import type { Channel } from 'stream-chat';
import { useMemo } from 'react';
import type { Channel, StateStore } from 'stream-chat';

import { getDisplayImage, getDisplayTitle, getGroupChannelDisplayInfo } from '../utils';
import { useChatContext } from '../../../context';
import { useStateStore } from '../../../store';
import type { GroupChannelDisplayInfo } from '../utils';

export type ChannelPreviewInfoParams = {
channel: Channel;
/** Channel to read display info from; when undefined, returns undefined display title/image */
channel?: Channel;
/** Manually set the image to render, defaults to the Channel image */
overrideImage?: string;
/** Set title manually */
overrideTitle?: string;
};

export const useChannelPreviewInfo = (props: ChannelPreviewInfoParams) => {
const { channel, overrideImage, overrideTitle } = props;
/** ChannelState with reactive display and members stores (stream-chat-js) */
type ChannelStateWithStores = {
displayStore?: {
getLatestValue(): { displayName: string | null; displayImage: string | null };
};
membersStore?: {
getLatestValue(): {
members: Record<string, { user?: { name?: string; image?: string } }>;
};
};
};

const { client } = useChatContext('useChannelPreviewInfo');
const [displayTitle, setDisplayTitle] = useState(
() => overrideTitle || getDisplayTitle(channel, client.user),
);
const [displayImage, setDisplayImage] = useState(
() => overrideImage || getDisplayImage(channel, client.user),
);
const displayStoreSelector = (s: {
displayName: string | null;
displayImage: string | null;
}) => ({
displayImage: s.displayImage,
displayName: s.displayName,
});

type MembersState = {
members: Record<string, { user?: { name?: string; image?: string } }>;
};

function buildGroupChannelDisplayInfo(
s: MembersState | undefined,
): GroupChannelDisplayInfo {
if (!s?.members) return { members: [], overflowCount: undefined };
const memberList = (Object.values(s.members) as MembersState['members'][string][])
.filter((m) => m.user?.name || m.user?.image)
.map((m) => ({ imageUrl: m.user?.image, userName: m.user?.name }));
if (memberList.length <= 2) return { members: [], overflowCount: undefined };
return {
members: memberList,
overflowCount: memberList.length > 4 ? memberList.length - 2 : undefined,
};
}

const [groupChannelDisplayInfo, setGroupDisplayChannelInfo] = useState<
ReturnType<typeof getGroupChannelDisplayInfo>
>(() => getGroupChannelDisplayInfo(channel));
export const useChannelPreviewInfo = (props: ChannelPreviewInfoParams) => {
const { channel, overrideImage, overrideTitle } = props;

useEffect(() => {
if (overrideTitle && overrideImage) return;
const channelState = (channel?.state ?? undefined) as
| ChannelStateWithStores
| undefined;

const updateInfo = () => {
if (!overrideTitle) setDisplayTitle(getDisplayTitle(channel, client.user));
if (!overrideImage) {
setDisplayImage(getDisplayImage(channel, client.user));
setGroupDisplayChannelInfo(getGroupChannelDisplayInfo(channel));
}
};
const displayFromStore = useStateStore(
(channelState?.displayStore ?? undefined) as
| StateStore<{ displayName: string | null; displayImage: string | null }>
| undefined,
displayStoreSelector,
);

updateInfo();
const groupChannelDisplayInfo = useStateStore(
(channelState?.membersStore ?? undefined) as
| StateStore<{
members: Record<string, { user?: { name?: string; image?: string } }>;
}>
| undefined,
buildGroupChannelDisplayInfo,
);

client.on('user.updated', updateInfo);
return () => {
client.off('user.updated', updateInfo);
};
}, [channel, channel.data, client, overrideImage, overrideTitle]);
const displayTitleResolved =
overrideTitle ?? displayFromStore?.displayName ?? undefined;
const displayImageResolved =
overrideImage ?? displayFromStore?.displayImage ?? undefined;

return {
displayImage: overrideImage || displayImage,
displayTitle: overrideTitle || displayTitle,
groupChannelDisplayInfo,
};
return useMemo(
() => ({
displayImage: displayImageResolved,
displayTitle: displayTitleResolved,
groupChannelDisplayInfo,
}),
[displayImageResolved, displayTitleResolved, groupChannelDisplayInfo],
);
};
43 changes: 17 additions & 26 deletions src/components/ChannelPreview/utils.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { ReactNode } from 'react';
import React from 'react';
import ReactMarkdown from 'react-markdown';
import type { Channel, PollVote, UserResponse } from 'stream-chat';
import type { Channel, PollVote } from 'stream-chat';

import type { ChatContextValue } from '../../context';
import { getTranslatedMessageText } from '../../context/MessageTranslationViewContext';
Expand Down Expand Up @@ -108,40 +108,31 @@ export const getLatestMessagePreview = (
return t('Empty message...');
};

export type GroupChannelDisplayInfo = { imageUrl?: string; userName?: string }[];
export type GroupChannelDisplayInfoMember = {
imageUrl?: string;
userName?: string;
};

export type GroupChannelDisplayInfo = {
members: GroupChannelDisplayInfoMember[];
/** When members.length > 4, count for the "+N" badge (members.length - 2). */
overflowCount?: number;
};

export const getGroupChannelDisplayInfo = (
channel: Channel,
): GroupChannelDisplayInfo | undefined => {
const members = Object.values(channel.state.members);
if (members.length <= 2) return;

const data: GroupChannelDisplayInfo = [];
const memberList: GroupChannelDisplayInfoMember[] = [];
for (const member of members) {
const { user } = member;
if (!user?.name && !user?.image) continue;
data.push({ imageUrl: user.image, userName: user.name });
if (data.length === 4) break;
memberList.push({ imageUrl: user.image, userName: user.name });
}
return data;
return {
members: memberList,
overflowCount: memberList.length > 4 ? memberList.length - 2 : undefined,
};
};

const getChannelDisplayInfo = (
info: 'name' | 'image',
channel: Channel,
currentUser?: UserResponse,
) => {
if (channel.data?.[info]) return channel.data[info];
const members = Object.values(channel.state.members);
if (members.length !== 2) return;
const otherMember = members.find((member) => member.user?.id !== currentUser?.id);
return (
otherMember?.user?.[info] || (info === 'name' ? otherMember?.user?.id : undefined)
);
};

export const getDisplayTitle = (channel: Channel, currentUser?: UserResponse) =>
getChannelDisplayInfo('name', channel, currentUser);

export const getDisplayImage = (channel: Channel, currentUser?: UserResponse) =>
getChannelDisplayInfo('image', channel, currentUser);
14 changes: 7 additions & 7 deletions src/components/Poll/styling/PollAnswerList.scss
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,13 @@
}

.str-chat__poll-answer {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-xxs);
align-self: stretch;
border-radius: var(--radius-lg);
background: var(--background-core-surface-card);
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-xxs);
align-self: stretch;
border-radius: var(--radius-lg);
background: var(--background-core-surface-card);

.str-chat__poll-answer__data {
display: flex;
Expand Down
Loading
Loading