Skip to content

Commit 1068e79

Browse files
feat: introduce extractDisplayInfo for Avatar/AvatarStack components (#3244)
### 🎯 Goal Unify how the Avatar/AvatarStack information is being constructed through the common and replaceable utility `extractDisplayInfo` function. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added an `initials` prop override for avatars. * Introduced a context-overridable display-info extractor to customize avatar rendering across messages, threads, polls, reactions, search results, typing indicators, and channel/member/pinned views. * **Bug Fixes** * Improved consistency for avatar display data by trimming usernames and using the trimmed name for avatar `alt` text, with the explicit `initials` override taking priority. * **Tests** * Updated component-context mocks to support the new context-driven avatar customization. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 20936d6 commit 1068e79

23 files changed

Lines changed: 192 additions & 128 deletions

File tree

src/components/Avatar/Avatar.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,13 @@ export type AvatarProps = {
1313
FallbackIcon?: ComponentType<ComponentPropsWithoutRef<'svg'>>;
1414
/** URL of the avatar image */
1515
imageUrl?: string;
16-
/** Name of the user, used for avatar image alt text and title fallback */
16+
/** Name of the user (first and last name), used for avatar image alt text and title fallback */
1717
userName?: string;
18+
/**
19+
* Initials that are used directly instead of generating intials from `userName` property.
20+
* @note Providing more than two characters will break the default UI.
21+
*/
22+
initials?: string;
1823
/** Online status indicator, not rendered if not of type boolean */
1924
isOnline?: boolean;
2025
size: '2xl' | 'xl' | 'lg' | 'md' | 'sm' | 'xs' | (string & {}) | null;
@@ -48,6 +53,7 @@ export const Avatar = ({
4853
className,
4954
FallbackIcon = IconUser,
5055
imageUrl,
56+
initials: customInitials,
5157
isOnline,
5258
size,
5359
userName,
@@ -57,18 +63,17 @@ export const Avatar = ({
5763

5864
useEffect(() => () => setError(false), [imageUrl]);
5965

60-
const nameString = userName?.toString() || '';
61-
const avatarImageAlt = nameString.trim();
66+
const nameString = userName?.toString().trim() || '';
6267

6368
const sizeAwareInitials = useMemo(() => {
64-
const initials = getInitials(nameString);
69+
const initials = customInitials || getInitials(nameString);
6570

6671
if (size === 'sm' || size === 'xs') {
6772
return initials.charAt(0);
6873
}
6974

7075
return initials;
71-
}, [nameString, size]);
76+
}, [nameString, size, customInitials]);
7277

7378
const showImage = typeof imageUrl === 'string' && imageUrl && !error;
7479

@@ -95,7 +100,7 @@ export const Avatar = ({
95100
)}
96101
{showImage ? (
97102
<img
98-
alt={avatarImageAlt}
103+
alt={nameString}
99104
className='str-chat__avatar-image'
100105
data-testid='avatar-img'
101106
onError={() => setError(true)}

src/components/Avatar/utils.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { ComponentContextValue } from '../../context';
2+
3+
export const extractDisplayInfo: NonNullable<
4+
ComponentContextValue['extractDisplayInfo']
5+
> = ({ user }) => ({
6+
id: user?.id,
7+
imageUrl: user?.image,
8+
userName: user?.name, // || user?.id,
9+
});

src/components/ChannelListItem/utils.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { PluggableList } from 'unified';
1313
import { htmlToTextPlugin, imageToLink, plusPlusToEmphasis } from '../Message';
1414
import { isMessageDeleted } from '../Message/utils';
1515
import remarkGfm from 'remark-gfm';
16+
import { extractDisplayInfo } from '../Avatar/utils';
1617

1718
const remarkPlugins: PluggableList = [
1819
htmlToTextPlugin,
@@ -355,8 +356,10 @@ export const getGroupChannelDisplayInfo = (
355356
const memberList: GroupChannelDisplayInfoMember[] = [];
356357
for (const member of members) {
357358
const { user } = member;
359+
358360
if (!user?.name && !user?.image) continue;
359-
memberList.push({ imageUrl: user.image, userName: user.name });
361+
362+
memberList.push(extractDisplayInfo(member));
360363
}
361364
return {
362365
members: memberList,

src/components/Dialog/components/ContextMenu.tsx

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import React, {
1212
useRef,
1313
useState,
1414
} from 'react';
15-
import { Avatar, type AvatarProps } from '../../Avatar';
15+
import { type AvatarProps, Avatar as DefaultAvatar } from '../../Avatar';
1616
import { IconChevronLeft, IconChevronRight } from '../../Icons';
1717
import type { PopperLikePlacement } from '../hooks';
1818
import { useDialogIsOpen, useDialogOnNearestManager } from '../hooks';
@@ -141,23 +141,26 @@ export const UserContextMenuButton = ({
141141
role = 'menuitem',
142142
userName,
143143
...props
144-
}: UserContextMenuButtonProps) => (
145-
<button
146-
{...props}
147-
className={clsx(
148-
'str-chat__context-menu__button str-chat__user-context-menu__button',
149-
className,
150-
)}
151-
role={role}
152-
type='button'
153-
>
154-
{/* The avatar is decorative here: the button already carries the user's name as its
155-
label, so exposing the avatar (its fallback initials, title, and role="button")
156-
to assistive tech only adds noise to the option's accessible name. */}
157-
<Avatar aria-hidden imageUrl={imageUrl} size='sm' userName={userName} />
158-
<div className='str-chat__context-menu__button__label'>{children ?? userName}</div>
159-
</button>
160-
);
144+
}: UserContextMenuButtonProps) => {
145+
const { Avatar = DefaultAvatar } = useComponentContext();
146+
return (
147+
<button
148+
{...props}
149+
className={clsx(
150+
'str-chat__context-menu__button str-chat__user-context-menu__button',
151+
className,
152+
)}
153+
role={role}
154+
type='button'
155+
>
156+
{/* The avatar is decorative here: the button already carries the user's name as its
157+
label, so exposing the avatar (its fallback initials, title, and role="button")
158+
to assistive tech only adds noise to the option's accessible name. */}
159+
<Avatar aria-hidden imageUrl={imageUrl} size='sm' userName={userName} />
160+
<div className='str-chat__context-menu__button__label'>{children ?? userName}</div>
161+
</button>
162+
);
163+
};
161164

162165
export type EmojiContextMenuButtonProps = { emoji: string } & Pick<
163166
BaseContextMenuButtonProps,

src/components/Message/MessageRepliesCountButton.tsx

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import React, { useMemo } from 'react';
55
import { useTranslationContext } from '../../context/TranslationContext';
66
import { useChannelStateContext, useComponentContext } from '../../context';
77
import { AvatarStack as DefaultAvatarStack } from '../Avatar';
8+
import { extractDisplayInfo as defaultExtractDisplayInfo } from '../Avatar/utils';
89

910
export type MessageRepliesCountButtonProps = {
1011
/* If supplied, adds custom text to the end of a multiple replies message */
@@ -19,9 +20,10 @@ export type MessageRepliesCountButtonProps = {
1920
};
2021

2122
function UnMemoizedMessageRepliesCountButton(props: MessageRepliesCountButtonProps) {
22-
const { AvatarStack = DefaultAvatarStack } = useComponentContext(
23-
MessageRepliesCountButton.name,
24-
);
23+
const {
24+
AvatarStack = DefaultAvatarStack,
25+
extractDisplayInfo = defaultExtractDisplayInfo,
26+
} = useComponentContext(MessageRepliesCountButton.name);
2527
const {
2628
labelPlural,
2729
labelSingle,
@@ -34,12 +36,8 @@ function UnMemoizedMessageRepliesCountButton(props: MessageRepliesCountButtonPro
3436
const { t } = useTranslationContext('MessageRepliesCountButton');
3537

3638
const avatarStackDisplayInfo = useMemo(
37-
() =>
38-
threadParticipants.slice(0, 3).map((participant) => ({
39-
imageUrl: participant.image,
40-
userName: participant.name || participant.id,
41-
})),
42-
[threadParticipants],
39+
() => threadParticipants.slice(0, 3).map((user) => extractDisplayInfo({ user })),
40+
[extractDisplayInfo, threadParticipants],
4341
);
4442

4543
if (!replyCount) return null;

src/components/Message/MessageUI.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import { PinIndicator as DefaultPinIndicator } from './PinIndicator';
5151
import { QuotedMessage as DefaultQuotedMessage } from './QuotedMessage';
5252
import { MessageBubble } from './MessageBubble';
5353
import { ErrorBadge } from '../Badge';
54+
import { extractDisplayInfo as defaultExtractDisplayInfo } from '../Avatar/utils';
5455

5556
type MessageUIWithContextProps = MessageContextValue;
5657

@@ -79,6 +80,7 @@ const MessageUIWithContext = ({
7980
const {
8081
Attachment = DefaultAttachment,
8182
Avatar = DefaultAvatar,
83+
extractDisplayInfo = defaultExtractDisplayInfo,
8284
MessageActions = DefaultMessageActions,
8385
MessageAlsoSentInChannelIndicator = DefaultMessageAlsoSentInChannelIndicator,
8486
MessageBlocked = DefaultMessageBlocked,
@@ -210,12 +212,11 @@ const MessageUIWithContext = ({
210212
<MessageTranslationIndicator message={message} />
211213
{message.user && (
212214
<Avatar
215+
{...extractDisplayInfo({ user: message.user ?? undefined })}
213216
className='str-chat__avatar--with-border'
214-
imageUrl={message.user.image}
215217
onClick={onUserClick}
216218
onMouseOver={onUserHover}
217219
size='md'
218-
userName={message.user.name || message.user.id}
219220
/>
220221
)}
221222
<div

src/components/Poll/PollOptionSelector.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import React, { useMemo } from 'react';
44
import type { PollOption, PollState, PollVote, VotingVisibility } from 'stream-chat';
55
import { isVoteAnswer } from 'stream-chat';
66
import { AvatarStack as DefaultAvatarStack } from '../Avatar';
7+
import { extractDisplayInfo as defaultExtractDisplayInfo } from '../Avatar/utils';
78
import {
89
useChannelStateContext,
910
useComponentContext,
@@ -63,7 +64,10 @@ export const PollOptionSelector = ({
6364
const { t } = useTranslationContext();
6465
const { channelCapabilities = {} } = useChannelStateContext('PollOptionsShortlist');
6566
const { message } = useMessageContext();
66-
const { AvatarStack = DefaultAvatarStack } = useComponentContext();
67+
const {
68+
AvatarStack = DefaultAvatarStack,
69+
extractDisplayInfo = defaultExtractDisplayInfo,
70+
} = useComponentContext();
6771
const { poll } = usePollContext();
6872
const {
6973
is_closed,
@@ -99,12 +103,8 @@ export const PollOptionSelector = ({
99103
(latest_votes_by_option[option.id] as PollVote[])
100104
.filter((vote) => !!vote.user && !isVoteAnswer(vote))
101105
.slice(0, displayAvatarCount)
102-
.map(({ user }) => ({
103-
id: user!.id, // eslint-disable-line @typescript-eslint/no-non-null-assertion
104-
imageUrl: user!.image, // eslint-disable-line @typescript-eslint/no-non-null-assertion
105-
userName: user!.name, // eslint-disable-line @typescript-eslint/no-non-null-assertion
106-
})),
107-
[displayAvatarCount, latest_votes_by_option, option.id],
106+
.map(extractDisplayInfo),
107+
[displayAvatarCount, extractDisplayInfo, latest_votes_by_option, option.id],
108108
);
109109

110110
return (

src/components/Poll/PollVote.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import React, { useState } from 'react';
2-
import { Avatar } from '../Avatar';
2+
import { Avatar as DefaultAvatar } from '../Avatar';
3+
import { extractDisplayInfo as defaultExtractDisplayInfo } from '../Avatar/utils';
34
import { PopperTooltip } from '../Tooltip';
45
import { useEnterLeaveHandlers } from '../Tooltip/hooks';
5-
import { useChatContext, useTranslationContext } from '../../context';
6+
import {
7+
useChatContext,
8+
useComponentContext,
9+
useTranslationContext,
10+
} from '../../context';
611

712
import type { PollVote as PollVoteType } from 'stream-chat';
813

@@ -41,6 +46,8 @@ type PollVoteAuthor = PollVoteProps;
4146
const PollVoteAuthor = ({ vote }: PollVoteAuthor) => {
4247
const { t } = useTranslationContext();
4348
const { client } = useChatContext();
49+
const { Avatar = DefaultAvatar, extractDisplayInfo = defaultExtractDisplayInfo } =
50+
useComponentContext();
4451
const displayName =
4552
client.user?.id && client.user.id === vote.user?.id
4653
? t('You')
@@ -50,11 +57,10 @@ const PollVoteAuthor = ({ vote }: PollVoteAuthor) => {
5057
<div className='str-chat__poll-vote__author'>
5158
{vote.user && (
5259
<Avatar
60+
{...extractDisplayInfo({ user: vote.user })}
5361
className='str-chat__avatar--poll-vote-author'
54-
imageUrl={vote.user.image}
5562
key={`poll-vote-${vote.id}-avatar-${vote.user.id}`}
5663
size='md'
57-
userName={vote.user.name}
5864
/>
5965
)}
6066
<div className='str-chat__poll-vote__author__name'>{displayName}</div>

src/components/Reactions/MessageReactionsDetail.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { ReactionSummary, ReactionType } from './types';
44

55
import { useFetchReactions } from './hooks/useFetchReactions';
66
import { Avatar as DefaultAvatar } from '../Avatar';
7+
import { extractDisplayInfo as defaultExtractDisplayInfo } from '../Avatar/utils';
78
import type { MessageContextValue } from '../../context';
89
import {
910
useChatContext,
@@ -66,6 +67,7 @@ export const MessageReactionsDetail: MessageReactionsDetailInterface = ({
6667
const { client } = useChatContext();
6768
const {
6869
Avatar = DefaultAvatar,
70+
extractDisplayInfo = defaultExtractDisplayInfo,
6971
LoadingIndicator = MessageReactionsDetailLoadingIndicator,
7072
reactionOptions = defaultReactionOptions,
7173
ReactionSelectorExtendedList = ReactionSelector.ExtendedList,
@@ -212,11 +214,10 @@ export const MessageReactionsDetail: MessageReactionsDetailInterface = ({
212214
key={`${user?.id}-${type}`}
213215
>
214216
<Avatar
217+
{...extractDisplayInfo({ user: user ?? undefined })}
215218
className='str-chat__avatar--with-border'
216219
data-testid='avatar'
217-
imageUrl={user?.image as string | undefined}
218220
size='md'
219-
userName={user?.name || user?.id}
220221
/>
221222
<div className='str-chat__message-reactions-detail__user-list-item-info'>
222223
<span

src/components/Search/SearchResults/SearchResultItem.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import type { ComponentType } from 'react';
44
import type { Channel, MessageResponse, User } from 'stream-chat';
55

66
import { useSearchContext } from '../SearchContext';
7-
import { Avatar } from '../../../components/Avatar';
7+
import { Avatar as DefaultAvatar } from '../../../components/Avatar';
8+
import { extractDisplayInfo as defaultExtractDisplayInfo } from '../../../components/Avatar/utils';
89
import { ChannelListItem } from '../../../components/ChannelListItem';
910
import {
1011
useChannelListContext,
1112
useChatContext,
13+
useComponentContext,
1214
useTranslationContext,
1315
} from '../../../context';
1416
import { DEFAULT_JUMP_TO_PAGE_SIZE } from '../../../constants/limits';
@@ -99,6 +101,8 @@ export const UserSearchResultItem = ({ item }: UserSearchResultItemProps) => {
99101
const { setChannels } = useChannelListContext();
100102
const { directMessagingChannelType } = useSearchContext();
101103
const { t } = useTranslationContext();
104+
const { Avatar = DefaultAvatar, extractDisplayInfo = defaultExtractDisplayInfo } =
105+
useComponentContext();
102106

103107
const onClick = useCallback(() => {
104108
const newChannel = client.channel(directMessagingChannelType, {
@@ -121,10 +125,9 @@ export const UserSearchResultItem = ({ item }: UserSearchResultItemProps) => {
121125
role='option'
122126
>
123127
<Avatar
124-
imageUrl={item.image}
128+
{...extractDisplayInfo({ user: item })}
125129
isOnline={item.online}
126130
size='xl'
127-
userName={item.name || item.id}
128131
/>
129132
<div className='str-chat__search-result-data'>
130133
<div className='str-chat__search-result__display-name'>

0 commit comments

Comments
 (0)