diff --git a/src/components/Avatar/Avatar.tsx b/src/components/Avatar/Avatar.tsx index af2fa6b76..79149a909 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; @@ -48,6 +53,7 @@ export const Avatar = ({ className, FallbackIcon = IconUser, imageUrl, + initials: customInitials, isOnline, size, userName, @@ -57,18 +63,17 @@ export const Avatar = ({ useEffect(() => () => setError(false), [imageUrl]); - const nameString = userName?.toString() || ''; - const avatarImageAlt = nameString.trim(); + const nameString = userName?.toString().trim() || ''; const sizeAwareInitials = useMemo(() => { - const initials = getInitials(nameString); + const initials = customInitials || getInitials(nameString); if (size === 'sm' || size === 'xs') { return initials.charAt(0); } return initials; - }, [nameString, size]); + }, [nameString, size, customInitials]); const showImage = typeof imageUrl === 'string' && imageUrl && !error; @@ -95,7 +100,7 @@ export const Avatar = ({ )} {showImage ? ( {avatarImageAlt} setError(true)} diff --git a/src/components/Avatar/utils.ts b/src/components/Avatar/utils.ts new file mode 100644 index 000000000..2e3147fef --- /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 ac9749953..e496459ac 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/Dialog/components/ContextMenu.tsx b/src/components/Dialog/components/ContextMenu.tsx index 554368675..959a20995 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/MessageRepliesCountButton.tsx b/src/components/Message/MessageRepliesCountButton.tsx index 8cdd1f10b..a73522187 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/Message/MessageUI.tsx b/src/components/Message/MessageUI.tsx index 394b75d42..9eaf39083 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 && ( )}
!!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/Poll/PollVote.tsx b/src/components/Poll/PollVote.tsx index f10cefdcf..1e15b4f25 100644 --- a/src/components/Poll/PollVote.tsx +++ b/src/components/Poll/PollVote.tsx @@ -1,8 +1,13 @@ import React, { useState } from 'react'; -import { Avatar } from '../Avatar'; +import { Avatar as DefaultAvatar } from '../Avatar'; +import { extractDisplayInfo as defaultExtractDisplayInfo } from '../Avatar/utils'; import { PopperTooltip } from '../Tooltip'; import { useEnterLeaveHandlers } from '../Tooltip/hooks'; -import { useChatContext, useTranslationContext } from '../../context'; +import { + useChatContext, + useComponentContext, + useTranslationContext, +} from '../../context'; import type { PollVote as PollVoteType } from 'stream-chat'; @@ -41,6 +46,8 @@ type PollVoteAuthor = PollVoteProps; const PollVoteAuthor = ({ vote }: PollVoteAuthor) => { 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 689a899c4..7a713b674 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 2c8edc706..1ff6fb2ac 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 { 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,25 +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 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 ( - - ); - }, - [entity.image, titleAttribute], - ); + const LeadingSlot = useMemo(() => { + const displayInfo = extractDisplayInfo({ user: entity }); + + return 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 ; + }; + }, [Avatar, entity, extractDisplayInfo]); if (!hasEntity) return null; diff --git a/src/components/Threads/ThreadList/ThreadListItemUI.tsx b/src/components/Threads/ThreadList/ThreadListItemUI.tsx index 6fcf0f700..b18209f77 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 0f7ab4bf4..c6e819879 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 f310945b6..ef40cbacc 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 960167bc9..f49a71423 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 ae6c5fb70..d30f05082 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) */ diff --git a/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.tsx b/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.tsx index 3e54e383a..5e1257e95 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 = ({
)}