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
17 changes: 11 additions & 6 deletions src/components/Avatar/Avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,13 @@ export type AvatarProps = {
FallbackIcon?: ComponentType<ComponentPropsWithoutRef<'svg'>>;
/** 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;
Expand Down Expand Up @@ -48,6 +53,7 @@ export const Avatar = ({
className,
FallbackIcon = IconUser,
imageUrl,
initials: customInitials,
isOnline,
size,
userName,
Expand All @@ -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;

Expand All @@ -95,7 +100,7 @@ export const Avatar = ({
)}
{showImage ? (
<img
alt={avatarImageAlt}
alt={nameString}
className='str-chat__avatar-image'
data-testid='avatar-img'
onError={() => setError(true)}
Expand Down
9 changes: 9 additions & 0 deletions src/components/Avatar/utils.ts
Original file line number Diff line number Diff line change
@@ -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,
});
5 changes: 4 additions & 1 deletion src/components/ChannelListItem/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
39 changes: 21 additions & 18 deletions src/components/Dialog/components/ContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -141,23 +141,26 @@ export const UserContextMenuButton = ({
role = 'menuitem',
userName,
...props
}: UserContextMenuButtonProps) => (
<button
{...props}
className={clsx(
'str-chat__context-menu__button str-chat__user-context-menu__button',
className,
)}
role={role}
type='button'
>
{/* The avatar is decorative here: the button already carries the user's name as its
label, so exposing the avatar (its fallback initials, title, and role="button")
to assistive tech only adds noise to the option's accessible name. */}
<Avatar aria-hidden imageUrl={imageUrl} size='sm' userName={userName} />
<div className='str-chat__context-menu__button__label'>{children ?? userName}</div>
</button>
);
}: UserContextMenuButtonProps) => {
const { Avatar = DefaultAvatar } = useComponentContext();
return (
<button
{...props}
className={clsx(
'str-chat__context-menu__button str-chat__user-context-menu__button',
className,
)}
role={role}
type='button'
>
{/* The avatar is decorative here: the button already carries the user's name as its
label, so exposing the avatar (its fallback initials, title, and role="button")
to assistive tech only adds noise to the option's accessible name. */}
<Avatar aria-hidden imageUrl={imageUrl} size='sm' userName={userName} />
<div className='str-chat__context-menu__button__label'>{children ?? userName}</div>
</button>
);
};

export type EmojiContextMenuButtonProps = { emoji: string } & Pick<
BaseContextMenuButtonProps,
Expand Down
16 changes: 7 additions & 9 deletions src/components/Message/MessageRepliesCountButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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,
Expand All @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions src/components/Message/MessageUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -79,6 +80,7 @@ const MessageUIWithContext = ({
const {
Attachment = DefaultAttachment,
Avatar = DefaultAvatar,
extractDisplayInfo = defaultExtractDisplayInfo,
MessageActions = DefaultMessageActions,
MessageAlsoSentInChannelIndicator = DefaultMessageAlsoSentInChannelIndicator,
MessageBlocked = DefaultMessageBlocked,
Expand Down Expand Up @@ -210,12 +212,11 @@ const MessageUIWithContext = ({
<MessageTranslationIndicator message={message} />
{message.user && (
<Avatar
{...extractDisplayInfo({ user: message.user ?? undefined })}
className='str-chat__avatar--with-border'
imageUrl={message.user.image}
onClick={onUserClick}
onMouseOver={onUserHover}
size='md'
userName={message.user.name || message.user.id}
/>
)}
<div
Expand Down
14 changes: 7 additions & 7 deletions src/components/Poll/PollOptionSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
Expand Down
14 changes: 10 additions & 4 deletions src/components/Poll/PollVote.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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')
Expand All @@ -50,11 +57,10 @@ const PollVoteAuthor = ({ vote }: PollVoteAuthor) => {
<div className='str-chat__poll-vote__author'>
{vote.user && (
<Avatar
{...extractDisplayInfo({ user: vote.user })}
className='str-chat__avatar--poll-vote-author'
imageUrl={vote.user.image}
key={`poll-vote-${vote.id}-avatar-${vote.user.id}`}
size='md'
userName={vote.user.name}
/>
)}
<div className='str-chat__poll-vote__author__name'>{displayName}</div>
Expand Down
5 changes: 3 additions & 2 deletions src/components/Reactions/MessageReactionsDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -66,6 +67,7 @@ export const MessageReactionsDetail: MessageReactionsDetailInterface = ({
const { client } = useChatContext();
const {
Avatar = DefaultAvatar,
extractDisplayInfo = defaultExtractDisplayInfo,
LoadingIndicator = MessageReactionsDetailLoadingIndicator,
reactionOptions = defaultReactionOptions,
ReactionSelectorExtendedList = ReactionSelector.ExtendedList,
Expand Down Expand Up @@ -212,11 +214,10 @@ export const MessageReactionsDetail: MessageReactionsDetailInterface = ({
key={`${user?.id}-${type}`}
>
<Avatar
{...extractDisplayInfo({ user: user ?? undefined })}
className='str-chat__avatar--with-border'
data-testid='avatar'
imageUrl={user?.image as string | undefined}
size='md'
userName={user?.name || user?.id}
/>
<div className='str-chat__message-reactions-detail__user-list-item-info'>
<span
Expand Down
9 changes: 6 additions & 3 deletions src/components/Search/SearchResults/SearchResultItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import type { ComponentType } from 'react';
import type { Channel, MessageResponse, User } from 'stream-chat';

import { useSearchContext } from '../SearchContext';
import { Avatar } from '../../../components/Avatar';
import { Avatar as DefaultAvatar } from '../../../components/Avatar';
import { extractDisplayInfo as defaultExtractDisplayInfo } from '../../../components/Avatar/utils';
import { ChannelListItem } from '../../../components/ChannelListItem';
import {
useChannelListContext,
useChatContext,
useComponentContext,
useTranslationContext,
} from '../../../context';
import { DEFAULT_JUMP_TO_PAGE_SIZE } from '../../../constants/limits';
Expand Down Expand Up @@ -99,6 +101,8 @@ export const UserSearchResultItem = ({ item }: UserSearchResultItemProps) => {
const { setChannels } = useChannelListContext();
const { directMessagingChannelType } = useSearchContext();
const { t } = useTranslationContext();
const { Avatar = DefaultAvatar, extractDisplayInfo = defaultExtractDisplayInfo } =
useComponentContext();

const onClick = useCallback(() => {
const newChannel = client.channel(directMessagingChannelType, {
Expand All @@ -121,10 +125,9 @@ export const UserSearchResultItem = ({ item }: UserSearchResultItemProps) => {
role='option'
>
<Avatar
imageUrl={item.image}
{...extractDisplayInfo({ user: item })}
isOnline={item.online}
size='xl'
userName={item.name || item.id}
/>
<div className='str-chat__search-result-data'>
<div className='str-chat__search-result__display-name'>
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 (
<Avatar
aria-hidden
imageUrl={entity.image}
size='md'
userName={titleAttribute}
/>
);
},
[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 {...displayInfo} aria-hidden size='md' />;
};
}, [Avatar, entity, extractDisplayInfo]);

if (!hasEntity) return null;

Expand Down
Loading