Skip to content

Commit 056b895

Browse files
Introduce extractDisplayInfo and apply it to AvatarStack
1 parent 20936d6 commit 056b895

10 files changed

Lines changed: 69 additions & 45 deletions

File tree

src/components/Avatar/Avatar.tsx

Lines changed: 9 additions & 4 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;
@@ -57,18 +62,18 @@ export const Avatar = ({
5762

5863
useEffect(() => () => setError(false), [imageUrl]);
5964

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

6368
const sizeAwareInitials = useMemo(() => {
64-
const initials = getInitials(nameString);
69+
const initials = rest.initials || 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, rest.initials]);
7277

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

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/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/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/Threads/ThreadList/ThreadListItemUI.tsx

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,18 @@ import type { ThreadState } from 'stream-chat';
55
import type { ComponentPropsWithoutRef } from 'react';
66

77
import { Timestamp } from '../../Message/Timestamp';
8-
import { Avatar, type AvatarProps, AvatarStack } from '../../Avatar';
8+
import {
9+
Avatar,
10+
type AvatarProps,
11+
AvatarStack as DefaultAvatarStack,
12+
} from '../../Avatar';
913
import { useInteractionAnnouncements } from '../../Accessibility';
1014
import { useChannelPreviewInfo } from '../../ChannelListItem';
11-
import { useChatContext, useTranslationContext } from '../../../context';
15+
import {
16+
useChatContext,
17+
useComponentContext,
18+
useTranslationContext,
19+
} from '../../../context';
1220
import { useThreadsViewContext } from '../../ChatView';
1321
import { useThreadListItemContext } from './ThreadListItem';
1422
import { useStateStore } from '../../../store';
@@ -21,6 +29,7 @@ import {
2129
composeThreadListItemAccessibleLabel,
2230
type ThreadListItemLabelConfig,
2331
} from './utils.a11y';
32+
import { extractDisplayInfo as defaultExtractDisplayInfo } from '../../Avatar/utils';
2433

2534
export type ThreadListItemUIProps = ComponentPropsWithoutRef<'button'> & {
2635
/**
@@ -39,6 +48,10 @@ export const ThreadListItemUI = ({
3948
const { client, isMessageAIGenerated } = useChatContext();
4049
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
4150
const thread = useThreadListItemContext()!;
51+
const {
52+
AvatarStack = DefaultAvatarStack,
53+
extractDisplayInfo = defaultExtractDisplayInfo,
54+
} = useComponentContext();
4255

4356
const selector = useCallback(
4457
(nextValue: ThreadState) => ({
@@ -141,13 +154,8 @@ export const ThreadListItemUI = ({
141154
const displayInfo = useMemo(() => {
142155
if (!participants) return [];
143156

144-
return participants.slice(0, 3).map((participant) => ({
145-
id: participant.user?.id ?? undefined,
146-
imageUrl: participant.user?.image,
147-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
148-
userName: participant.user?.name || participant.user!.id,
149-
}));
150-
}, [participants]);
157+
return participants.slice(0, 3).map(extractDisplayInfo);
158+
}, [extractDisplayInfo, participants]);
151159

152160
useEffect(() => {
153161
if (!resetHighlighting) return;

src/components/Threads/ThreadList/__tests__/ThreadListItemUI.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const mockUseChannelPreviewInfo = vi.fn();
2222

2323
vi.mock('../../../../context', () => ({
2424
useChatContext: () => mockUseChatContext(),
25+
useComponentContext: () => ({}),
2526
useTranslationContext: () => mockUseTranslationContext(),
2627
}));
2728

src/components/TypingIndicator/TypingIndicator.tsx

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React, { useEffect, useMemo } from 'react';
22
import clsx from 'clsx';
33

4-
import { AvatarStack } from '../Avatar';
4+
import { AvatarStack as DefaultAvatarStack } from '../Avatar';
55
import { TypingIndicatorDots } from './TypingIndicatorDots';
66
import { useChannelStateContext } from '../../context/ChannelStateContext';
77
import { useChatContext } from '../../context/ChatContext';
@@ -12,6 +12,8 @@ import { VisuallyHidden } from '../VisuallyHidden';
1212

1313
import { useDebouncedTypingActive } from './hooks/useDebouncedTypingActive';
1414
import { getTypingStatusMessage } from './utils/getTypingStatusMessage';
15+
import { useComponentContext } from '../../context';
16+
import { extractDisplayInfo as defaultExtractDisplayInfo } from '../Avatar/utils';
1517

1618
export type TypingIndicatorProps = {
1719
/** 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 = {
2931
const UnMemoizedTypingIndicator = (props: TypingIndicatorProps) => {
3032
const { isMessageListScrolledToBottom = true, scrollToBottom, threadList } = props;
3133

34+
const {
35+
AvatarStack = DefaultAvatarStack,
36+
extractDisplayInfo = defaultExtractDisplayInfo,
37+
} = useComponentContext();
3238
const { channelConfig, thread } = useChannelStateContext('TypingIndicator');
3339
const threadInstance = useThreadContext();
3440
const parentId = threadInstance?.id ?? thread?.id;
@@ -57,16 +63,8 @@ const UnMemoizedTypingIndicator = (props: TypingIndicatorProps) => {
5763
);
5864

5965
const displayInfo = useMemo(
60-
() =>
61-
displayUsers.map(
62-
({ user }) =>
63-
({
64-
id: user?.id,
65-
imageUrl: user?.image,
66-
userName: user?.name,
67-
}) as const,
68-
),
69-
[displayUsers],
66+
() => displayUsers.map(extractDisplayInfo),
67+
[displayUsers, extractDisplayInfo],
7068
);
7169

7270
useEffect(() => {

src/components/TypingIndicator/hooks/useDebouncedTypingActive.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
import { useEffect, useMemo, useRef, useState } from 'react';
2+
import type { ChannelState } from 'stream-chat';
23

34
const DEFAULT_HIDE_DELAY_MS = 2000;
45

5-
export type TypingEntry = {
6-
user?: { id?: string; name?: string; image?: string };
7-
parent_id?: string;
8-
};
6+
export type TypingEntry = ChannelState['typing'][string];
97

108
/**
119
* Derive a stable key from typing users so that the effect only runs when the

src/context/ComponentContext.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ComponentProps, PropsWithChildren } from 'react';
22
import React, { useContext } from 'react';
3+
import type { UserResponse } from 'stream-chat';
34

45
import {
56
type AttachmentPreviewListProps,
@@ -103,6 +104,9 @@ export type ComponentContextValue = {
103104
AutocompleteSuggestionItem?: React.ComponentType<SuggestionItemProps>;
104105
/** 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) */
105106
AutocompleteSuggestionList?: React.ComponentType<SuggestionListProps>;
107+
extractDisplayInfo?: (_: {
108+
user?: Partial<UserResponse>;
109+
}) => NonNullable<AvatarStackProps['displayInfo']>[number];
106110
/** 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) */
107111
Avatar?: React.ComponentType<ChannelAvatarProps>;
108112
/** 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) */

0 commit comments

Comments
 (0)