diff --git a/examples/vite/src/stream-imports-layout.scss b/examples/vite/src/stream-imports-layout.scss index 54aa83a22..f888aec92 100644 --- a/examples/vite/src/stream-imports-layout.scss +++ b/examples/vite/src/stream-imports-layout.scss @@ -2,7 +2,7 @@ //@use 'stream-chat-react/dist/scss/v2/global-layout-variables'; @use 'stream-chat-react/dist/scss/v2/icons'; -@use 'stream-chat-react/dist/scss/v2/Avatar/Avatar-layout'; +//@use 'stream-chat-react/dist/scss/v2/Avatar/Avatar-layout'; //@use 'stream-chat-react/dist/scss/v2/AttachmentList/AttachmentList-layout'; //@use 'stream-chat-react/dist/scss/v2/AttachmentPreviewList/AttachmentPreviewList-layout'; // X @use 'stream-chat-react/dist/scss/v2/Autocomplete/Autocomplete-layout'; diff --git a/examples/vite/src/stream-imports-theme.scss b/examples/vite/src/stream-imports-theme.scss index c5fe6e118..997a029ba 100644 --- a/examples/vite/src/stream-imports-theme.scss +++ b/examples/vite/src/stream-imports-theme.scss @@ -5,7 +5,7 @@ @use 'stream-chat-react/dist/scss/v2/common/CTAButton/CTAButton-theme'; @use 'stream-chat-react/dist/scss/v2/common/CircleFAButton/CircleFAButton-theme'; -@use 'stream-chat-react/dist/scss/v2/Avatar/Avatar-theme'; +//@use 'stream-chat-react/dist/scss/v2/Avatar/Avatar-theme'; //@use 'stream-chat-react/dist/scss/v2/AttachmentList/AttachmentList-theme'; //@use 'stream-chat-react/dist/scss/v2/AudioRecorder/AudioRecorder-theme'; @use 'stream-chat-react/dist/scss/v2/Autocomplete/Autocomplete-theme'; diff --git a/src/components/Avatar/Avatar.scss b/src/components/Avatar/Avatar.scss new file mode 100644 index 000000000..60accf383 --- /dev/null +++ b/src/components/Avatar/Avatar.scss @@ -0,0 +1,136 @@ +.str-chat__avatar { + position: relative; + display: flex; + justify-content: center; + align-items: center; + border-radius: var(--radius-max, 9999px); + background: var(--avatar-bg-default, #e3edff); + color: var(--avatar-text-default, #142f63); + text-align: center; + font-feature-settings: + 'liga' off, + 'clig' off; + + font-family: var(--typography-font-family-sans, 'SF Pro'); + font-style: normal; + font-weight: var(--typography-font-weight-semi-bold, 600); + line-height: 1; + text-transform: uppercase; + width: var(--avatar-size); + aspect-ratio: 1/1; + --avatar-badge-angle: -45deg; + + // FIXME: temporary thing, should be removed when we get rid of the old CSS + grid-area: avatar; + + .str-chat__avatar-image { + object-fit: cover; + border-radius: inherit; + width: 100%; + height: 100%; + } + + .str-chat__avatar-icon { + width: var(--avatar-icon-size); + height: var(--avatar-icon-size); + & path { + stroke: var(--avatar-text-default, #142f63); + stroke-width: var(--avatar-icon-stroke-width, 2px); + } + } + + &.str-chat__avatar--with-border:has(.str-chat__avatar-image)::before { + border: 1px solid var(--border-core-opacity-10); + content: ''; + width: 100%; + height: 100%; + position: absolute; + background: transparent; + border-radius: inherit; + } + + &.str-chat__avatar--online::after, + &.str-chat__avatar--offline::after { + aspect-ratio: 1/1; + content: ''; + position: absolute; + width: var(--avatar-badge-size); + + left: calc( + var(--avatar-size) / 2 + var(--avatar-size) / 2 * + cos(var(--avatar-badge-angle)) - var(--avatar-badge-size) / 2 + ); + top: calc( + var(--avatar-size) / 2 + var(--avatar-size) / 2 * + sin(var(--avatar-badge-angle)) - var(--avatar-badge-size) / 2 + ); + + border-radius: var(--radius-max, 9999px); + border-style: solid; + border-color: var(--presence-border, #fff); + border-width: 2px; + } + + &.str-chat__avatar--online::after { + background: var(--presence-bg-online, #00c384); + } + + &.str-chat__avatar--offline::after { + background: var(--presence-bg-offline, #687385); + } + + &.str-chat__avatar--size-xl { + --avatar-size: 64px; + --avatar-badge-size: 16px; + --avatar-icon-size: 32px; + --avatar-icon-stroke-width: 2px; + + font-size: var(--typography-font-size-xl, 20px); + } + + &.str-chat__avatar--size-lg { + --avatar-size: 40px; + --avatar-badge-size: 14px; + --avatar-icon-size: 20px; + --avatar-icon-stroke-width: 1.5px; + + font-size: var(--typography-font-size-md, 15px); + } + + &.str-chat__avatar--size-md { + --avatar-size: 32px; + --avatar-badge-size: 12px; + --avatar-icon-size: 16px; + --avatar-icon-stroke-width: 1.5px; + + font-size: var(--typography-font-size-sm, 13px); + } + + &.str-chat__avatar--size-sm { + --avatar-size: 24px; + --avatar-badge-size: 8px; + --avatar-icon-size: 12px; + --avatar-icon-stroke-width: 1.2px; + + font-size: var(--typography-font-size-sm, 13px); + + &.str-chat__avatar--offline::after, + &.str-chat__avatar--online::after { + border-width: 1px; + } + } + + &.str-chat__avatar--size-xs { + --avatar-size: 20px; + --avatar-badge-size: 8px; + --avatar-icon-size: 10px; + --avatar-icon-stroke-width: 1.2px; + + font-size: var(--typography-font-size-xs, 12px); + + &.str-chat__avatar--offline::after, + &.str-chat__avatar--online::after { + border-width: 1px; + } + } +} diff --git a/src/components/Avatar/Avatar.tsx b/src/components/Avatar/Avatar.tsx index ccd17d8dc..3626e06d4 100644 --- a/src/components/Avatar/Avatar.tsx +++ b/src/components/Avatar/Avatar.tsx @@ -1,79 +1,129 @@ import clsx from 'clsx'; -import React, { useEffect, useState } from 'react'; -import type { UserResponse } from 'stream-chat'; - -import { Icon } from '../Threads/icons'; -import { getWholeChar } from '../../utils'; +import React, { + type ComponentPropsWithoutRef, + useEffect, + useMemo, + useState, +} from 'react'; export type AvatarProps = { - /** Custom root element class that will be merged with the default class */ - className?: string; /** Image URL or default is an image of the first initial of the name if there is one */ - image?: string | null; - /** Name of the image, used for title tag fallback */ - name?: string; - /** click event handler attached to the component root element */ - onClick?: (event: React.BaseSyntheticEvent) => void; - /** mouseOver event handler attached to the component root element */ - onMouseOver?: (event: React.BaseSyntheticEvent) => void; - /** The entire user object for the chat user displayed in the component */ - user?: UserResponse; + imageUrl?: string | null; + // /** Name of the image, used for title tag fallback */ + userName?: string; + /** Online status indicator, not rendered if not of type boolean */ + isOnline?: boolean; + + size: 'xl' | 'lg' | 'md' | 'sm' | 'xs' | null; +} & ComponentPropsWithoutRef<'div'>; + +const getInitials = (name?: string) => { + const regex = /(\p{L}{1})\p{L}+/gu; + + if (!name || name.trim().length === 0) { + return ''; + } + + const initials = Array.from(name?.matchAll(regex) || []); + + if (!initials.length) { + return ''; + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const startInitial = initials.at(0)![1]; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const endInitial = initials.length > 1 ? initials.at(-1)![1] : ''; + + return `${startInitial}${endInitial}`; }; /** * A round avatar image with fallback to username's first letter */ -export const Avatar = (props: AvatarProps) => { - const { - className, - image, - name, - onClick = () => undefined, - onMouseOver = () => undefined, - } = props; - +export const Avatar = ({ + className, + imageUrl, + isOnline, + size, + userName, + ...rest +}: AvatarProps) => { const [error, setError] = useState(false); - useEffect(() => { - setError(false); - }, [image]); + useEffect(() => () => setError(false), [imageUrl]); + + const nameString = userName?.toString() || ''; + + const sizeAwareInitials = useMemo(() => { + const initials = getInitials(nameString); + + if (size === 'sm' || size === 'xs') { + return getInitials(nameString).charAt(0); + } - const nameStr = name?.toString() || ''; - const initials = getWholeChar(nameStr, 0); - const showImage = image && !error; + return initials; + }, [nameString, size]); + + const showImage = typeof imageUrl === 'string' && !error; return (
1, - ['str-chat__avatar--no-letters']: !initials.length, - ['str-chat__avatar--one-letter']: initials.length === 1, + className={clsx(`str-chat__avatar`, className, { + 'str-chat__avatar--multiple-letters': sizeAwareInitials.length > 1, + 'str-chat__avatar--no-letters': !sizeAwareInitials.length, + 'str-chat__avatar--one-letter': sizeAwareInitials.length === 1, + 'str-chat__avatar--online': typeof isOnline === 'boolean' && isOnline, + // eslint-disable-next-line sort-keys + 'str-chat__avatar--offline': typeof isOnline === 'boolean' && !isOnline, + [`str-chat__avatar--size-${size}`]: typeof size === 'string', })} data-testid='avatar' - onClick={onClick} - onMouseOver={onMouseOver} role='button' - title={name} + title={userName} + {...rest} > {showImage ? ( {initials} setError(true)} - src={image} + src={imageUrl} /> ) : ( <> - {!!initials.length && ( + {!!sizeAwareInitials.length && (
- {initials} + {sizeAwareInitials}
)} - {!initials.length && } + {!sizeAwareInitials.length && ( + + + + + )} )}
diff --git a/src/components/Avatar/ChannelAvatar.tsx b/src/components/Avatar/ChannelAvatar.tsx index 009326404..393df09e4 100644 --- a/src/components/Avatar/ChannelAvatar.tsx +++ b/src/components/Avatar/ChannelAvatar.tsx @@ -7,9 +7,9 @@ export type ChannelAvatarProps = Partial & AvatarProps; export const ChannelAvatar = ({ groupChannelDisplayInfo, - image, - name, - user, + imageUrl, + size, + userName, ...sharedProps }: ChannelAvatarProps) => { if (groupChannelDisplayInfo) { @@ -17,5 +17,5 @@ export const ChannelAvatar = ({ ); } - return ; + return ; }; diff --git a/src/components/Avatar/GroupAvatar.tsx b/src/components/Avatar/GroupAvatar.tsx index 28dd773ab..781f50180 100644 --- a/src/components/Avatar/GroupAvatar.tsx +++ b/src/components/Avatar/GroupAvatar.tsx @@ -1,13 +1,9 @@ import clsx from 'clsx'; -import React from 'react'; -import type { AvatarProps } from './Avatar'; +import React, { type ComponentPropsWithoutRef } from 'react'; import { Avatar } from './Avatar'; import type { GroupChannelDisplayInfo } from '../ChannelPreview'; -export type GroupAvatarProps = Pick< - AvatarProps, - 'className' | 'onClick' | 'onMouseOver' -> & { +export type GroupAvatarProps = ComponentPropsWithoutRef<'div'> & { /** Mapping of image URLs to names which initials will be used as fallbacks in case image assets fail to load. */ groupChannelDisplayInfo: GroupChannelDisplayInfo; }; @@ -15,8 +11,7 @@ export type GroupAvatarProps = Pick< export const GroupAvatar = ({ className, groupChannelDisplayInfo, - onClick, - onMouseOver, + ...rest }: GroupAvatarProps) => (
{groupChannelDisplayInfo.slice(0, 4).map(({ image, name }, i) => ( ))}
diff --git a/src/components/ChannelHeader/ChannelHeader.tsx b/src/components/ChannelHeader/ChannelHeader.tsx index 802478352..6e31f2679 100644 --- a/src/components/ChannelHeader/ChannelHeader.tsx +++ b/src/components/ChannelHeader/ChannelHeader.tsx @@ -56,8 +56,9 @@ export const ChannelHeader = (props: ChannelHeaderProps) => {

diff --git a/src/components/ChannelPreview/ChannelPreviewMessenger.tsx b/src/components/ChannelPreview/ChannelPreviewMessenger.tsx index 13730a475..fb9cc3eee 100644 --- a/src/components/ChannelPreview/ChannelPreviewMessenger.tsx +++ b/src/components/ChannelPreview/ChannelPreviewMessenger.tsx @@ -62,8 +62,9 @@ const UnMemoizedChannelPreviewMessenger = (props: ChannelPreviewUIComponentProps

diff --git a/src/components/ChannelSearch/SearchResults.tsx b/src/components/ChannelSearch/SearchResults.tsx index 9160a37bd..ff52cd5c7 100644 --- a/src/components/ChannelSearch/SearchResults.tsx +++ b/src/components/ChannelSearch/SearchResults.tsx @@ -97,9 +97,9 @@ const DefaultSearchResultItem = (props: SearchResultItemProps) => { >
{result.name || result.id} diff --git a/src/components/EventComponent/EventComponent.tsx b/src/components/EventComponent/EventComponent.tsx index bc7e9d918..41ff4140a 100644 --- a/src/components/EventComponent/EventComponent.tsx +++ b/src/components/EventComponent/EventComponent.tsx @@ -58,7 +58,7 @@ const UnMemoizedEventComponent = (props: EventComponentProps) => { return (
- +
{sentence} diff --git a/src/components/Message/FixedHeightMessage.tsx b/src/components/Message/FixedHeightMessage.tsx index 9ee2801eb..4c9ef48c9 100644 --- a/src/components/Message/FixedHeightMessage.tsx +++ b/src/components/Message/FixedHeightMessage.tsx @@ -86,9 +86,9 @@ const UnMemoizedFixedHeightMessage = (props: FixedHeightMessageProps) => { > {message.user && ( )}
diff --git a/src/components/Message/MessageSimple.tsx b/src/components/Message/MessageSimple.tsx index 36e38e663..0c7485afe 100644 --- a/src/components/Message/MessageSimple.tsx +++ b/src/components/Message/MessageSimple.tsx @@ -170,13 +170,14 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
{PinIndicator && } {!!reminder && } - {message.user && ( + {message.user && !isMyMessage() && ( )}
{ {readersWithoutOwnUser.length > 1 && ( diff --git a/src/components/Poll/PollOptionSelector.tsx b/src/components/Poll/PollOptionSelector.tsx index 6ca6d24b3..cdce442ed 100644 --- a/src/components/Poll/PollOptionSelector.tsx +++ b/src/components/Poll/PollOptionSelector.tsx @@ -115,9 +115,10 @@ export const PollOptionSelector = ({ .slice(0, displayAvatarCount) .map(({ user }) => ( ))}
diff --git a/src/components/Poll/PollVote.tsx b/src/components/Poll/PollVote.tsx index 4c85e3f83..04ac67f58 100644 --- a/src/components/Poll/PollVote.tsx +++ b/src/components/Poll/PollVote.tsx @@ -57,9 +57,10 @@ const PollVoteAuthor = ({ vote }: PollVoteProps) => { {vote.user && ( )}
{displayName}
diff --git a/src/components/Reactions/ReactionSelector.tsx b/src/components/Reactions/ReactionSelector.tsx index 4434d8016..1e18ec866 100644 --- a/src/components/Reactions/ReactionSelector.tsx +++ b/src/components/Reactions/ReactionSelector.tsx @@ -47,8 +47,6 @@ const UnMemoizedReactionSelector = (props: ReactionSelectorProps) => { return map; }, [ownReactions]); - console.log({ ownReactionByType }); - return (
    diff --git a/src/components/Reactions/ReactionsListModal.tsx b/src/components/Reactions/ReactionsListModal.tsx index 4d2da8a54..523ff9d79 100644 --- a/src/components/Reactions/ReactionsListModal.tsx +++ b/src/components/Reactions/ReactionsListModal.tsx @@ -116,8 +116,9 @@ export function ReactionsListModal({ {
    {renderName()} diff --git a/src/components/Threads/ThreadList/ThreadListItemUI.tsx b/src/components/Threads/ThreadList/ThreadListItemUI.tsx index 76692e282..e598c86eb 100644 --- a/src/components/Threads/ThreadList/ThreadListItemUI.tsx +++ b/src/components/Threads/ThreadList/ThreadListItemUI.tsx @@ -5,7 +5,7 @@ import type { LocalMessage, ThreadState } from 'stream-chat'; import type { ComponentPropsWithoutRef } from 'react'; import { Timestamp } from '../../Message/Timestamp'; -import { Avatar } from '../../Avatar'; +import { Avatar, type AvatarProps } from '../../Avatar'; import { Icon } from '../icons'; import { UnreadCountBadge } from '../UnreadCountBadge'; @@ -91,7 +91,13 @@ export const ThreadListItemUI = (props: ThreadListItemUIProps) => { const { activeThread, setActiveThread } = useThreadsViewContext(); - const avatarProps = deletedAt ? null : latestReply?.user; + const avatarProps: AvatarProps | undefined = deletedAt + ? undefined + : ({ + imageUrl: latestReply?.user?.image, + size: 'md', + userName: latestReply?.user?.name || latestReply?.user?.id, + } as const); return (
    - +
    {!deletedAt && (
    diff --git a/src/experimental/Search/SearchResults/SearchResultItem.tsx b/src/experimental/Search/SearchResults/SearchResultItem.tsx index 826c728b4..86ff7dddb 100644 --- a/src/experimental/Search/SearchResults/SearchResultItem.tsx +++ b/src/experimental/Search/SearchResults/SearchResultItem.tsx @@ -113,9 +113,9 @@ export const UserSearchResultItem = ({ item }: UserSearchResultItemProps) => { >
    {item.name || item.id}
    diff --git a/src/styling/fonts.scss b/src/styling/fonts.scss new file mode 100644 index 000000000..1cc38c241 --- /dev/null +++ b/src/styling/fonts.scss @@ -0,0 +1 @@ +@import url('https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap'); diff --git a/src/styling/index.scss b/src/styling/index.scss index e83148382..21d6332b1 100644 --- a/src/styling/index.scss +++ b/src/styling/index.scss @@ -23,6 +23,10 @@ @use "../components/Message/styling" as Message; @use "../components/MessageInput/styling" as MessageComposer; @use '../components/Reactions/ReactionSelector' as ReactionSelector; +@use "../components/Avatar/Avatar" as Avatar; + +// Fonts +@use "./fonts"; // Layers have to be kept the last @import 'modern-normalize' layer(css-reset);