From 056b8954a709d759a91192917e5928609f99e86c Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Thu, 16 Jul 2026 17:14:37 +0200 Subject: [PATCH 1/4] Introduce extractDisplayInfo and apply it to AvatarStack --- src/components/Avatar/Avatar.tsx | 13 +++++++--- src/components/Avatar/utils.ts | 9 +++++++ src/components/ChannelListItem/utils.tsx | 5 +++- .../Message/MessageRepliesCountButton.tsx | 16 +++++------- src/components/Poll/PollOptionSelector.tsx | 14 +++++----- .../Threads/ThreadList/ThreadListItemUI.tsx | 26 ++++++++++++------- .../__tests__/ThreadListItemUI.test.tsx | 1 + .../TypingIndicator/TypingIndicator.tsx | 20 +++++++------- .../hooks/useDebouncedTypingActive.ts | 6 ++--- src/context/ComponentContext.tsx | 4 +++ 10 files changed, 69 insertions(+), 45 deletions(-) create mode 100644 src/components/Avatar/utils.ts diff --git a/src/components/Avatar/Avatar.tsx b/src/components/Avatar/Avatar.tsx index af2fa6b76c..330336a7cb 100644 --- a/src/components/Avatar/Avatar.tsx +++ b/src/components/Avatar/Avatar.tsx @@ -13,8 +13,13 @@ export type AvatarProps = { FallbackIcon?: ComponentType>; /** URL of the avatar image */ imageUrl?: string; - /** Name of the user, used for avatar image alt text and title fallback */ + /** Name of the user (first and last name), used for avatar image alt text and title fallback */ userName?: string; + /** + * Initials that are used directly instead of generating intials from `userName` property. + * @note Providing more than two characters will break the default UI. + */ + initials?: string; /** Online status indicator, not rendered if not of type boolean */ isOnline?: boolean; size: '2xl' | 'xl' | 'lg' | 'md' | 'sm' | 'xs' | (string & {}) | null; @@ -57,18 +62,18 @@ export const Avatar = ({ useEffect(() => () => setError(false), [imageUrl]); - const nameString = userName?.toString() || ''; + const nameString = userName?.toString().trim() || ''; const avatarImageAlt = nameString.trim(); const sizeAwareInitials = useMemo(() => { - const initials = getInitials(nameString); + const initials = rest.initials || getInitials(nameString); if (size === 'sm' || size === 'xs') { return initials.charAt(0); } return initials; - }, [nameString, size]); + }, [nameString, size, rest.initials]); const showImage = typeof imageUrl === 'string' && imageUrl && !error; diff --git a/src/components/Avatar/utils.ts b/src/components/Avatar/utils.ts new file mode 100644 index 0000000000..2e3147feff --- /dev/null +++ b/src/components/Avatar/utils.ts @@ -0,0 +1,9 @@ +import type { ComponentContextValue } from '../../context'; + +export const extractDisplayInfo: NonNullable< + ComponentContextValue['extractDisplayInfo'] +> = ({ user }) => ({ + id: user?.id, + imageUrl: user?.image, + userName: user?.name, // || user?.id, +}); diff --git a/src/components/ChannelListItem/utils.tsx b/src/components/ChannelListItem/utils.tsx index ac97499535..e496459ac2 100644 --- a/src/components/ChannelListItem/utils.tsx +++ b/src/components/ChannelListItem/utils.tsx @@ -13,6 +13,7 @@ import type { PluggableList } from 'unified'; import { htmlToTextPlugin, imageToLink, plusPlusToEmphasis } from '../Message'; import { isMessageDeleted } from '../Message/utils'; import remarkGfm from 'remark-gfm'; +import { extractDisplayInfo } from '../Avatar/utils'; const remarkPlugins: PluggableList = [ htmlToTextPlugin, @@ -355,8 +356,10 @@ export const getGroupChannelDisplayInfo = ( const memberList: GroupChannelDisplayInfoMember[] = []; for (const member of members) { const { user } = member; + if (!user?.name && !user?.image) continue; - memberList.push({ imageUrl: user.image, userName: user.name }); + + memberList.push(extractDisplayInfo(member)); } return { members: memberList, diff --git a/src/components/Message/MessageRepliesCountButton.tsx b/src/components/Message/MessageRepliesCountButton.tsx index 8cdd1f10b5..a735221875 100644 --- a/src/components/Message/MessageRepliesCountButton.tsx +++ b/src/components/Message/MessageRepliesCountButton.tsx @@ -5,6 +5,7 @@ import React, { useMemo } from 'react'; import { useTranslationContext } from '../../context/TranslationContext'; import { useChannelStateContext, useComponentContext } from '../../context'; import { AvatarStack as DefaultAvatarStack } from '../Avatar'; +import { extractDisplayInfo as defaultExtractDisplayInfo } from '../Avatar/utils'; export type MessageRepliesCountButtonProps = { /* If supplied, adds custom text to the end of a multiple replies message */ @@ -19,9 +20,10 @@ export type MessageRepliesCountButtonProps = { }; function UnMemoizedMessageRepliesCountButton(props: MessageRepliesCountButtonProps) { - const { AvatarStack = DefaultAvatarStack } = useComponentContext( - MessageRepliesCountButton.name, - ); + const { + AvatarStack = DefaultAvatarStack, + extractDisplayInfo = defaultExtractDisplayInfo, + } = useComponentContext(MessageRepliesCountButton.name); const { labelPlural, labelSingle, @@ -34,12 +36,8 @@ function UnMemoizedMessageRepliesCountButton(props: MessageRepliesCountButtonPro const { t } = useTranslationContext('MessageRepliesCountButton'); const avatarStackDisplayInfo = useMemo( - () => - threadParticipants.slice(0, 3).map((participant) => ({ - imageUrl: participant.image, - userName: participant.name || participant.id, - })), - [threadParticipants], + () => threadParticipants.slice(0, 3).map((user) => extractDisplayInfo({ user })), + [extractDisplayInfo, threadParticipants], ); if (!replyCount) return null; diff --git a/src/components/Poll/PollOptionSelector.tsx b/src/components/Poll/PollOptionSelector.tsx index 189c104d6f..d7ee9a95ab 100644 --- a/src/components/Poll/PollOptionSelector.tsx +++ b/src/components/Poll/PollOptionSelector.tsx @@ -4,6 +4,7 @@ import React, { useMemo } from 'react'; import type { PollOption, PollState, PollVote, VotingVisibility } from 'stream-chat'; import { isVoteAnswer } from 'stream-chat'; import { AvatarStack as DefaultAvatarStack } from '../Avatar'; +import { extractDisplayInfo as defaultExtractDisplayInfo } from '../Avatar/utils'; import { useChannelStateContext, useComponentContext, @@ -63,7 +64,10 @@ export const PollOptionSelector = ({ const { t } = useTranslationContext(); const { channelCapabilities = {} } = useChannelStateContext('PollOptionsShortlist'); const { message } = useMessageContext(); - const { AvatarStack = DefaultAvatarStack } = useComponentContext(); + const { + AvatarStack = DefaultAvatarStack, + extractDisplayInfo = defaultExtractDisplayInfo, + } = useComponentContext(); const { poll } = usePollContext(); const { is_closed, @@ -99,12 +103,8 @@ export const PollOptionSelector = ({ (latest_votes_by_option[option.id] as PollVote[]) .filter((vote) => !!vote.user && !isVoteAnswer(vote)) .slice(0, displayAvatarCount) - .map(({ user }) => ({ - id: user!.id, // eslint-disable-line @typescript-eslint/no-non-null-assertion - imageUrl: user!.image, // eslint-disable-line @typescript-eslint/no-non-null-assertion - userName: user!.name, // eslint-disable-line @typescript-eslint/no-non-null-assertion - })), - [displayAvatarCount, latest_votes_by_option, option.id], + .map(extractDisplayInfo), + [displayAvatarCount, extractDisplayInfo, latest_votes_by_option, option.id], ); return ( diff --git a/src/components/Threads/ThreadList/ThreadListItemUI.tsx b/src/components/Threads/ThreadList/ThreadListItemUI.tsx index 6fcf0f700e..b18209f77a 100644 --- a/src/components/Threads/ThreadList/ThreadListItemUI.tsx +++ b/src/components/Threads/ThreadList/ThreadListItemUI.tsx @@ -5,10 +5,18 @@ import type { ThreadState } from 'stream-chat'; import type { ComponentPropsWithoutRef } from 'react'; import { Timestamp } from '../../Message/Timestamp'; -import { Avatar, type AvatarProps, AvatarStack } from '../../Avatar'; +import { + Avatar, + type AvatarProps, + AvatarStack as DefaultAvatarStack, +} from '../../Avatar'; import { useInteractionAnnouncements } from '../../Accessibility'; import { useChannelPreviewInfo } from '../../ChannelListItem'; -import { useChatContext, useTranslationContext } from '../../../context'; +import { + useChatContext, + useComponentContext, + useTranslationContext, +} from '../../../context'; import { useThreadsViewContext } from '../../ChatView'; import { useThreadListItemContext } from './ThreadListItem'; import { useStateStore } from '../../../store'; @@ -21,6 +29,7 @@ import { composeThreadListItemAccessibleLabel, type ThreadListItemLabelConfig, } from './utils.a11y'; +import { extractDisplayInfo as defaultExtractDisplayInfo } from '../../Avatar/utils'; export type ThreadListItemUIProps = ComponentPropsWithoutRef<'button'> & { /** @@ -39,6 +48,10 @@ export const ThreadListItemUI = ({ const { client, isMessageAIGenerated } = useChatContext(); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const thread = useThreadListItemContext()!; + const { + AvatarStack = DefaultAvatarStack, + extractDisplayInfo = defaultExtractDisplayInfo, + } = useComponentContext(); const selector = useCallback( (nextValue: ThreadState) => ({ @@ -141,13 +154,8 @@ export const ThreadListItemUI = ({ const displayInfo = useMemo(() => { if (!participants) return []; - return participants.slice(0, 3).map((participant) => ({ - id: participant.user?.id ?? undefined, - imageUrl: participant.user?.image, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - userName: participant.user?.name || participant.user!.id, - })); - }, [participants]); + return participants.slice(0, 3).map(extractDisplayInfo); + }, [extractDisplayInfo, participants]); useEffect(() => { if (!resetHighlighting) return; diff --git a/src/components/Threads/ThreadList/__tests__/ThreadListItemUI.test.tsx b/src/components/Threads/ThreadList/__tests__/ThreadListItemUI.test.tsx index 0f7ab4bf48..c6e8198799 100644 --- a/src/components/Threads/ThreadList/__tests__/ThreadListItemUI.test.tsx +++ b/src/components/Threads/ThreadList/__tests__/ThreadListItemUI.test.tsx @@ -22,6 +22,7 @@ const mockUseChannelPreviewInfo = vi.fn(); vi.mock('../../../../context', () => ({ useChatContext: () => mockUseChatContext(), + useComponentContext: () => ({}), useTranslationContext: () => mockUseTranslationContext(), })); diff --git a/src/components/TypingIndicator/TypingIndicator.tsx b/src/components/TypingIndicator/TypingIndicator.tsx index f310945b63..ef40cbacc6 100644 --- a/src/components/TypingIndicator/TypingIndicator.tsx +++ b/src/components/TypingIndicator/TypingIndicator.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo } from 'react'; import clsx from 'clsx'; -import { AvatarStack } from '../Avatar'; +import { AvatarStack as DefaultAvatarStack } from '../Avatar'; import { TypingIndicatorDots } from './TypingIndicatorDots'; import { useChannelStateContext } from '../../context/ChannelStateContext'; import { useChatContext } from '../../context/ChatContext'; @@ -12,6 +12,8 @@ import { VisuallyHidden } from '../VisuallyHidden'; import { useDebouncedTypingActive } from './hooks/useDebouncedTypingActive'; import { getTypingStatusMessage } from './utils/getTypingStatusMessage'; +import { useComponentContext } from '../../context'; +import { extractDisplayInfo as defaultExtractDisplayInfo } from '../Avatar/utils'; export type TypingIndicatorProps = { /** When false, the indicator is not rendered (e.g. when list is not scrolled to bottom). Omit or true to show when typing. */ @@ -29,6 +31,10 @@ export type TypingIndicatorProps = { const UnMemoizedTypingIndicator = (props: TypingIndicatorProps) => { const { isMessageListScrolledToBottom = true, scrollToBottom, threadList } = props; + const { + AvatarStack = DefaultAvatarStack, + extractDisplayInfo = defaultExtractDisplayInfo, + } = useComponentContext(); const { channelConfig, thread } = useChannelStateContext('TypingIndicator'); const threadInstance = useThreadContext(); const parentId = threadInstance?.id ?? thread?.id; @@ -57,16 +63,8 @@ const UnMemoizedTypingIndicator = (props: TypingIndicatorProps) => { ); const displayInfo = useMemo( - () => - displayUsers.map( - ({ user }) => - ({ - id: user?.id, - imageUrl: user?.image, - userName: user?.name, - }) as const, - ), - [displayUsers], + () => displayUsers.map(extractDisplayInfo), + [displayUsers, extractDisplayInfo], ); useEffect(() => { diff --git a/src/components/TypingIndicator/hooks/useDebouncedTypingActive.ts b/src/components/TypingIndicator/hooks/useDebouncedTypingActive.ts index 960167bc95..f49a714237 100644 --- a/src/components/TypingIndicator/hooks/useDebouncedTypingActive.ts +++ b/src/components/TypingIndicator/hooks/useDebouncedTypingActive.ts @@ -1,11 +1,9 @@ import { useEffect, useMemo, useRef, useState } from 'react'; +import type { ChannelState } from 'stream-chat'; const DEFAULT_HIDE_DELAY_MS = 2000; -export type TypingEntry = { - user?: { id?: string; name?: string; image?: string }; - parent_id?: string; -}; +export type TypingEntry = ChannelState['typing'][string]; /** * Derive a stable key from typing users so that the effect only runs when the diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index ae6c5fb70e..d30f05082c 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -1,5 +1,6 @@ import type { ComponentProps, PropsWithChildren } from 'react'; import React, { useContext } from 'react'; +import type { UserResponse } from 'stream-chat'; import { type AttachmentPreviewListProps, @@ -103,6 +104,9 @@ export type ComponentContextValue = { AutocompleteSuggestionItem?: React.ComponentType; /** Optional UI component to override the default List component that displays suggestions, defaults to and accepts same props as: [List](https://github.com/GetStream/stream-chat-react/blob/master/src/components/AutoCompleteTextarea/List.js) */ AutocompleteSuggestionList?: React.ComponentType; + extractDisplayInfo?: (_: { + user?: Partial; + }) => NonNullable[number]; /** 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 a list of avatars stacked in a row, defaults to and accepts same props as: [AvatarStack](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Avatar/AvatarStack.tsx) */ From 615010ac6df40e27b45259db79b271c3ac4749dc Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Thu, 16 Jul 2026 18:55:24 +0200 Subject: [PATCH 2/4] Apply to Avatar too --- .../Dialog/components/ContextMenu.tsx | 39 ++++++++++--------- src/components/Message/MessageUI.tsx | 5 ++- src/components/Poll/PollVote.tsx | 14 +++++-- .../Reactions/MessageReactionsDetail.tsx | 5 ++- .../Search/SearchResults/SearchResultItem.tsx | 9 +++-- .../SuggestionList/MentionItem/UserItem.tsx | 20 +++++----- .../ChannelMediaView/ChannelMediaView.tsx | 8 ++-- .../ChannelMembersAddView.tsx | 23 +++++------ .../ChannelMembersBrowseView.tsx | 29 +++++++------- .../PinnedMessagesView/PinnedMessagesView.tsx | 11 ++++-- 10 files changed, 90 insertions(+), 73 deletions(-) diff --git a/src/components/Dialog/components/ContextMenu.tsx b/src/components/Dialog/components/ContextMenu.tsx index 5543686753..959a209955 100644 --- a/src/components/Dialog/components/ContextMenu.tsx +++ b/src/components/Dialog/components/ContextMenu.tsx @@ -12,7 +12,7 @@ import React, { useRef, useState, } from 'react'; -import { Avatar, type AvatarProps } from '../../Avatar'; +import { type AvatarProps, Avatar as DefaultAvatar } from '../../Avatar'; import { IconChevronLeft, IconChevronRight } from '../../Icons'; import type { PopperLikePlacement } from '../hooks'; import { useDialogIsOpen, useDialogOnNearestManager } from '../hooks'; @@ -141,23 +141,26 @@ export const UserContextMenuButton = ({ role = 'menuitem', userName, ...props -}: UserContextMenuButtonProps) => ( - -); +}: UserContextMenuButtonProps) => { + const { Avatar = DefaultAvatar } = useComponentContext(); + return ( + + ); +}; export type EmojiContextMenuButtonProps = { emoji: string } & Pick< BaseContextMenuButtonProps, diff --git a/src/components/Message/MessageUI.tsx b/src/components/Message/MessageUI.tsx index 394b75d42f..9eaf390834 100644 --- a/src/components/Message/MessageUI.tsx +++ b/src/components/Message/MessageUI.tsx @@ -51,6 +51,7 @@ import { PinIndicator as DefaultPinIndicator } from './PinIndicator'; import { QuotedMessage as DefaultQuotedMessage } from './QuotedMessage'; import { MessageBubble } from './MessageBubble'; import { ErrorBadge } from '../Badge'; +import { extractDisplayInfo as defaultExtractDisplayInfo } from '../Avatar/utils'; type MessageUIWithContextProps = MessageContextValue; @@ -79,6 +80,7 @@ const MessageUIWithContext = ({ const { Attachment = DefaultAttachment, Avatar = DefaultAvatar, + extractDisplayInfo = defaultExtractDisplayInfo, MessageActions = DefaultMessageActions, MessageAlsoSentInChannelIndicator = DefaultMessageAlsoSentInChannelIndicator, MessageBlocked = DefaultMessageBlocked, @@ -210,12 +212,11 @@ const MessageUIWithContext = ({ {message.user && ( )}
{ const { t } = useTranslationContext(); const { client } = useChatContext(); + const { Avatar = DefaultAvatar, extractDisplayInfo = defaultExtractDisplayInfo } = + useComponentContext(); const displayName = client.user?.id && client.user.id === vote.user?.id ? t('You') @@ -50,11 +57,10 @@ const PollVoteAuthor = ({ vote }: PollVoteAuthor) => {
{vote.user && ( )}
{displayName}
diff --git a/src/components/Reactions/MessageReactionsDetail.tsx b/src/components/Reactions/MessageReactionsDetail.tsx index 689a899c41..7a713b6741 100644 --- a/src/components/Reactions/MessageReactionsDetail.tsx +++ b/src/components/Reactions/MessageReactionsDetail.tsx @@ -4,6 +4,7 @@ import type { ReactionSummary, ReactionType } from './types'; import { useFetchReactions } from './hooks/useFetchReactions'; import { Avatar as DefaultAvatar } from '../Avatar'; +import { extractDisplayInfo as defaultExtractDisplayInfo } from '../Avatar/utils'; import type { MessageContextValue } from '../../context'; import { useChatContext, @@ -66,6 +67,7 @@ export const MessageReactionsDetail: MessageReactionsDetailInterface = ({ const { client } = useChatContext(); const { Avatar = DefaultAvatar, + extractDisplayInfo = defaultExtractDisplayInfo, LoadingIndicator = MessageReactionsDetailLoadingIndicator, reactionOptions = defaultReactionOptions, ReactionSelectorExtendedList = ReactionSelector.ExtendedList, @@ -212,11 +214,10 @@ export const MessageReactionsDetail: MessageReactionsDetailInterface = ({ key={`${user?.id}-${type}`} >
{ const { setChannels } = useChannelListContext(); const { directMessagingChannelType } = useSearchContext(); const { t } = useTranslationContext(); + const { Avatar = DefaultAvatar, extractDisplayInfo = defaultExtractDisplayInfo } = + useComponentContext(); const onClick = useCallback(() => { const newChannel = client.channel(directMessagingChannelType, { @@ -121,10 +125,9 @@ export const UserSearchResultItem = ({ item }: UserSearchResultItemProps) => { role='option' >
diff --git a/src/components/TextareaComposer/SuggestionList/MentionItem/UserItem.tsx b/src/components/TextareaComposer/SuggestionList/MentionItem/UserItem.tsx index 2c8edc7061..9c112e558a 100644 --- a/src/components/TextareaComposer/SuggestionList/MentionItem/UserItem.tsx +++ b/src/components/TextareaComposer/SuggestionList/MentionItem/UserItem.tsx @@ -1,8 +1,10 @@ import clsx from 'clsx'; import React, { useMemo } from 'react'; -import type { UserSuggestion } from 'stream-chat'; -import { Avatar } from '../../../Avatar'; +import type { UserResponse, UserSuggestion } from 'stream-chat'; +import { Avatar as DefaultAvatar } from '../../../Avatar'; +import { extractDisplayInfo as defaultExtractDisplayInfo } from '../../../Avatar/utils'; import { ListItemLayout } from '../../../ListItemLayout'; +import { useComponentContext } from '../../../../context'; import type { MentionItemComponentProps } from './types'; import { type TokenizedSuggestionDisplayName, @@ -25,24 +27,20 @@ export type UserItemProps = MentionItemComponentProps< * UI component for mentions rendered in suggestion list */ export const UserItem = ({ entity, focused, ...buttonProps }: UserItemProps) => { + const { Avatar = DefaultAvatar, extractDisplayInfo = defaultExtractDisplayInfo } = + useComponentContext(); const hasEntity = !!Object.keys(entity).length; const titleAttribute = entity.name || entity.id || ''; + const displayInfo = extractDisplayInfo({ user: entity }); const LeadingSlot = useMemo( () => function UserItemAvatar() { // Decorative: the option's accessible name already carries the user's name (title + // tokenized display name), so the avatar's fallback initials/role would only add noise. - return ( - - ); + return ; }, - [entity.image, titleAttribute], + [Avatar, displayInfo], ); if (!hasEntity) return null; diff --git a/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.tsx b/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.tsx index 3e54e383a7..5e1257e958 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.tsx @@ -7,7 +7,8 @@ import { useTranslationContext, } from '../../../../context'; import { formatTime } from '../../../../components/AudioPlayback'; -import { Avatar } from '../../../../components/Avatar'; +import { Avatar as DefaultAvatar } from '../../../../components/Avatar'; +import { extractDisplayInfo as defaultExtractDisplayInfo } from '../../../../components/Avatar/utils'; import { Badge } from '../../../../components/Badge'; import { type BaseImageProps, @@ -51,6 +52,8 @@ const ChannelMediaGridItem = ({ onClick, }: ChannelMediaGridItemProps) => { const { t } = useTranslationContext('ChannelMediaView'); + const { Avatar = DefaultAvatar, extractDisplayInfo = defaultExtractDisplayInfo } = + useComponentContext(); const displayName = getUserDisplayName(item.user); const mediaSrc = item.type === 'video' @@ -84,11 +87,10 @@ const ChannelMediaGridItem = ({
)}