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 (
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