From c796cb4c675d5d865c51ba72ef0f3c446aa06f06 Mon Sep 17 00:00:00 2001 From: Arthurk12 Date: Thu, 3 Sep 2026 14:27:14 -0300 Subject: [PATCH] feat(BBBAvatar): add avatar component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The library has no reusable component for rendering a user avatar. Every BBB surface that needs one — e.g. bbb-plugin-pick-random-user's user-avatar — reimplements the same image/initials/color logic locally, causing duplicated code and visual inconsistency across plugins and apps. Add BBBAvatar (src/components/Avatar), using bbb-plugin-pick-random-user's user-avatar as the reference implementation, extended to mirror BBB's own conventions rather than inventing new ones: - Renders the avatar image, falling back to initials on a colored background when no image is given or it fails to load. - The fallback color is hashed deterministically from the user's name into the same 16-color palette akka-bbb-apps' ColorPicker assigns server-side, so the fallback lands on colors real BBB users get. - small/medium/large size variants (medium default). - isModerator renders the rounded-square shape BBB uses for moderators, independent of color. - isYou applies BBB's own "you" color (colorUserYou, added to the shared palette) in place of the fallback, overridden by an explicit color prop. - isTalking shows a pulsing ring in the avatar's own color, mirroring BBB's talking indicator (ported to use CSS's native color-to-transparent interpolation instead of manual alpha-string concatenation, so it works with both hex colors and CSS custom properties). - A Tippy tooltip shows the full name on hover; disableTooltip opts out. --- README.md | 4 +- package.json | 10 ++ src/components/Avatar/README.md | 83 +++++++++++++++ src/components/Avatar/assets/example.png | Bin 0 -> 29282 bytes src/components/Avatar/component.stories.tsx | 112 ++++++++++++++++++++ src/components/Avatar/component.tsx | 72 +++++++++++++ src/components/Avatar/constants.ts | 15 +++ src/components/Avatar/index.ts | 1 + src/components/Avatar/styles.ts | 48 +++++++++ src/components/Avatar/types.ts | 36 +++++++ src/components/index.ts | 1 + src/stylesheets/colors.ts | 4 + src/stylesheets/palette.ts | 3 + webpack.config.babel.js | 1 + 14 files changed, 389 insertions(+), 1 deletion(-) create mode 100644 src/components/Avatar/README.md create mode 100644 src/components/Avatar/assets/example.png create mode 100644 src/components/Avatar/component.stories.tsx create mode 100644 src/components/Avatar/component.tsx create mode 100644 src/components/Avatar/constants.ts create mode 100644 src/components/Avatar/index.ts create mode 100644 src/components/Avatar/styles.ts create mode 100644 src/components/Avatar/types.ts diff --git a/README.md b/README.md index 223b759..dbf0055 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ This repository contains a library of reusable React components extracted from t Below is a list of the components available in this library. Each component has its own detailed documentation with usage examples and a complete list of props. - [BBBAccordion](./src/components/Accordion/README.md) +- [BBBAvatar](./src/components/Avatar/README.md) - [BBButton](./src/components/Button/README.md) - [BBBCheckbox](./src/components/Checkbox/README.md) - [BBBDivider](./src/components/Divider/README.md) @@ -101,6 +102,7 @@ The following table lists the supported CSS variables for color overriding, extr | `--color-icon-default-dark`| No | rgba(255, 255, 255, 0.35) | | `--color-overlay` | No | rgba(0, 0, 0, 0.75) | | `--color-shadow-default` | No | rgb(0 35 11 / 20%) | +| `--color-user-you` | No | #19237C | **Example Usage**: ```css @@ -132,7 +134,7 @@ const StyledDiv = styled.div` ``` `colors` is grouped the same way as the table above: `neutral`, `brand`, `semantic`, `background`, -`border`, `text`, `icon`, `hover`. +`border`, `text`, `icon`, `hover`, `overlay`, `shadow`, `user`. ## Installation diff --git a/package.json b/package.json index bea56d7..b56e4f4 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,13 @@ "require": "./dist/components/Accordion.js", "default": "./dist/components/Accordion.js" }, + "./Avatar": { + "types": "./dist/types/components/Avatar/index.d.ts", + "node": "./dist/components/Avatar.js", + "import": "./dist/esm/components/Avatar/index.js", + "require": "./dist/components/Avatar.js", + "default": "./dist/components/Avatar.js" + }, "./Button": { "types": "./dist/types/components/Button/index.d.ts", "node": "./dist/components/Button.js", @@ -181,6 +188,9 @@ "Accordion": [ "dist/types/components/Accordion/index.d.ts" ], + "Avatar": [ + "dist/types/components/Avatar/index.d.ts" + ], "Button": [ "dist/types/components/Button/index.d.ts" ], diff --git a/src/components/Avatar/README.md b/src/components/Avatar/README.md new file mode 100644 index 0000000..1d98e3f --- /dev/null +++ b/src/components/Avatar/README.md @@ -0,0 +1,83 @@ +# BBBAvatar + +The `BBBAvatar` component renders a user's avatar image, falling back to their initials on a deterministically-colored background when no image is available or the image fails to load. It shows a tooltip with the full name on hover, and can highlight the current user or a speaking user, matching BBB's own avatar treatment. + +![Demo](assets/example.png) + +## Usage Example + +### Avatar with image +```jsx +import { BBBAvatar } from 'bbb-ui-components-react'; + + +``` + +### Avatar with initials fallback +```jsx +import { BBBAvatar } from 'bbb-ui-components-react'; + + +``` + +### Avatar with a custom color +```jsx +import { BBBAvatar } from 'bbb-ui-components-react'; + + +``` + +### Medium avatar +```jsx +import { BBBAvatar } from 'bbb-ui-components-react'; + + +``` + +### Large avatar +```jsx +import { BBBAvatar } from 'bbb-ui-components-react'; + + +``` + +### Moderator avatar +```jsx +import { BBBAvatar } from 'bbb-ui-components-react'; + + +``` + +### Current user's avatar +```jsx +import { BBBAvatar } from 'bbb-ui-components-react'; + + +``` + +### Talking indicator +```jsx +import { BBBAvatar } from 'bbb-ui-components-react'; + + +``` + +### Avatar without the hover tooltip +```jsx +import { BBBAvatar } from 'bbb-ui-components-react'; + + +``` + +## Props + +| Property | Type | Default | Description | +| ---------------- | -------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `name` | `string` | | Full name of the user; used to render initials, to derive a deterministic fallback color, and as the tooltip content. | +| `avatarUrl` | `string` | | URL of the user's avatar image. Falls back to initials when omitted or if the image fails to load. | +| `color` | `string` | color deterministically derived from `name` | Background color behind the initials (and border color on the image). Overrides `isYou`. | +| `size` | `'small' \| 'medium' \| 'large'` | `'medium'` | Size variant of the avatar. | +| `isModerator` | `boolean` | `false` | Renders a rounded-square shape instead of a circle, matching BBB's moderator avatar treatment. | +| `isYou` | `boolean` | `false` | Marks this avatar as belonging to the current user, applying BBB's "you" color in place of the fallback/computed color. Ignored when `color` is set. | +| `isTalking` | `boolean` | `false` | Shows a pulsing ring around the avatar, in its own color, matching BBB's talking indicator. | +| `disableTooltip` | `boolean` | `false` | Disables the tooltip that shows the full `name` on hover. | diff --git a/src/components/Avatar/assets/example.png b/src/components/Avatar/assets/example.png new file mode 100644 index 0000000000000000000000000000000000000000..7a9463aaa2e68c1a5cbebf842f749eeaa3967ea3 GIT binary patch literal 29282 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 GcmD;m&PmV! literal 0 HcmV?d00001 diff --git a/src/components/Avatar/component.stories.tsx b/src/components/Avatar/component.stories.tsx new file mode 100644 index 0000000..94bd44a --- /dev/null +++ b/src/components/Avatar/component.stories.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import BBBAvatar from './component'; +import { AVATAR_SIZE_VALUES, DEFAULT_AVATAR_SIZE } from './constants'; + +const meta = { + title: 'BBBAvatar', + component: BBBAvatar, + tags: ['autodocs'], + argTypes: { + name: { + control: 'text', + description: 'Full name of the user; used to render initials and to derive a deterministic fallback color.', + }, + avatarUrl: { + control: 'text', + description: "URL of the user's avatar image. Falls back to initials when omitted or if the image fails to load.", + }, + color: { + control: 'color', + description: 'Background color behind the initials (and border color on the image).', + }, + size: { + control: 'select', + options: AVATAR_SIZE_VALUES, + description: 'Size variant of the avatar.', + table: { defaultValue: { summary: `${DEFAULT_AVATAR_SIZE}` } }, + }, + isModerator: { + control: 'boolean', + description: "Renders a rounded-square shape instead of a circle, matching BBB's moderator avatar treatment.", + table: { defaultValue: { summary: 'false' } }, + }, + isYou: { + control: 'boolean', + description: 'Marks this avatar as belonging to the current user, applying BBB\'s "you" color in place of the fallback/computed color. Ignored when `color` is set.', + table: { defaultValue: { summary: 'false' } }, + }, + isTalking: { + control: 'boolean', + description: "Shows a pulsing ring around the avatar, in its own color, matching BBB's talking indicator.", + table: { defaultValue: { summary: 'false' } }, + }, + disableTooltip: { + control: 'boolean', + description: 'Disables the tooltip that shows the full `name` on hover.', + table: { defaultValue: { summary: 'false' } }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** Falls back to initials when no image is given, next to an avatar rendering a provided image. */ +export const Default: Story = { + render: (args) => ( +
+ + +
+ ), +}; + +/** All size variants rendered side by side. */ +export const Sizes: Story = { + render: (args) => ( +
+ {AVATAR_SIZE_VALUES.map((size) => ( + + ))} +
+ ), +}; + +/** The default circular shape next to the rounded-square shape used for moderators. */ +export const Types: Story = { + render: (args) => ( +
+ + +
+ ), +}; + +/** Different names deriving different colors from the deterministic fallback, next to an explicit custom color override. */ +export const Colors: Story = { + render: (args) => ( +
+ + + + + + + + + + +
+ ), +}; + +/** Not talking next to the pulsing ring shown while the user is speaking. */ +export const Talking: Story = { + render: (args) => ( +
+ + +
+ ), +}; diff --git a/src/components/Avatar/component.tsx b/src/components/Avatar/component.tsx new file mode 100644 index 0000000..fe2e161 --- /dev/null +++ b/src/components/Avatar/component.tsx @@ -0,0 +1,72 @@ +import React, { JSX, useEffect, useState } from 'react'; +import Tippy from '@tippyjs/react'; +import 'tippy.js/dist/tippy.css'; +import * as Styled from './styles'; +import { AvatarProps } from './types'; +import { DEFAULT_AVATAR_SIZE, AVATAR_FALLBACK_COLORS } from './constants'; +import { colorUserYou } from '../../stylesheets/palette'; + +function getInitials(name: string): string { + const words = name.trim().split(/\s+/).filter(Boolean); + if (words.length === 0) return ''; + if (words.length === 1) return words[0].slice(0, 2).toUpperCase(); + return `${words[0][0]}${words[words.length - 1][0]}`.toUpperCase(); +} + +function getFallbackColor(name: string): string { + const hash = name.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); + return AVATAR_FALLBACK_COLORS[hash % AVATAR_FALLBACK_COLORS.length]; +} + +/** + * A user avatar component. + * + * Renders the user's avatar image when available, falling back to their initials on a + * deterministically-colored background when no image is provided or it fails to load. + * + */ +function Avatar({ + name, + avatarUrl, + color, + size = DEFAULT_AVATAR_SIZE, + isModerator = false, + isYou = false, + isTalking = false, + disableTooltip = false, +}: AvatarProps): JSX.Element { + const [hasImageError, setHasImageError] = useState(false); + const resolvedColor = color || (isYou ? colorUserYou : getFallbackColor(name)); + + useEffect(() => { + setHasImageError(false); + }, [avatarUrl]); + + const avatarElement = avatarUrl && !hasImageError ? ( + setHasImageError(true)} + /> + ) : ( + + {getInitials(name)} + + ); + + if (disableTooltip) { + return avatarElement; + } + + return ( + + {avatarElement} + + ); +} + +export default Avatar; diff --git a/src/components/Avatar/constants.ts b/src/components/Avatar/constants.ts new file mode 100644 index 0000000..7e9e260 --- /dev/null +++ b/src/components/Avatar/constants.ts @@ -0,0 +1,15 @@ +export const AVATAR_SIZES = { + SMALL: 'small', + MEDIUM: 'medium', + LARGE: 'large', +} as const; + +export const AVATAR_SIZE_VALUES = Object.values(AVATAR_SIZES); +export const DEFAULT_AVATAR_SIZE = AVATAR_SIZES.MEDIUM; + +// Mirrors the palette akka-bbb-apps' ColorPicker assigns to users server-side (round-robin per +// meeting), so a name-hash fallback here lands on the same colors real BBB users get. +export const AVATAR_FALLBACK_COLORS = [ + '#7b1fa2', '#6a1b9a', '#4a148c', '#5e35b1', '#512da8', '#4527a0', '#311b92', + '#3949ab', '#303f9f', '#283593', '#1a237e', '#1976d2', '#1565c0', '#0d47a1', '#0277bd', '#01579b', +]; diff --git a/src/components/Avatar/index.ts b/src/components/Avatar/index.ts new file mode 100644 index 0000000..3d4170d --- /dev/null +++ b/src/components/Avatar/index.ts @@ -0,0 +1 @@ +export { default as BBBAvatar } from './component'; diff --git a/src/components/Avatar/styles.ts b/src/components/Avatar/styles.ts new file mode 100644 index 0000000..71ab5f7 --- /dev/null +++ b/src/components/Avatar/styles.ts @@ -0,0 +1,48 @@ +import styled, { css, keyframes } from 'styled-components'; +import { colorWhite } from '../../stylesheets/palette'; +import { AVATAR_SIZES } from './constants'; +import { StyledAvatarProps } from './types'; + +const DIMENSIONS = { + [AVATAR_SIZES.SMALL]: { dimension: '1.625rem', fontSize: '0.625rem', fontWeight: '600' }, + [AVATAR_SIZES.MEDIUM]: { dimension: '3rem', fontSize: '1.125rem', fontWeight: '500' }, + [AVATAR_SIZES.LARGE]: { dimension: '6rem', fontSize: '2.75rem', fontWeight: '400' }, +}; + +// Mirrors BBB's talking indicator (a ring, in the user's own color, that spreads and fades out). +const talkingPulse = (color: string) => keyframes` + 0% { box-shadow: 0 0 0 0 ${color}; } + 100% { box-shadow: 0 0 0 4px transparent; } +`; + +const talkingStyles = css` + ${({ $isTalking, $color }) => $isTalking && css` + animation: ${talkingPulse($color)} 1s infinite ease-in; + `} +`; + +export const AvatarInitials = styled.div` + width: ${({ $size }) => DIMENSIONS[$size].dimension}; + height: ${({ $size }) => DIMENSIONS[$size].dimension}; + border-radius: ${({ $isModerator }) => ($isModerator ? '20%' : '50%')}; + flex-shrink: 0; + background: ${({ $color }) => $color}; + display: flex; + align-items: center; + justify-content: center; + font-size: ${({ $size }) => DIMENSIONS[$size].fontSize}; + font-weight: ${({ $size }) => DIMENSIONS[$size].fontWeight}; + color: ${colorWhite}; + text-transform: uppercase; + ${talkingStyles} +`; + +export const AvatarImage = styled.img` + width: ${({ $size }) => DIMENSIONS[$size].dimension}; + height: ${({ $size }) => DIMENSIONS[$size].dimension}; + border-radius: ${({ $isModerator }) => ($isModerator ? '20%' : '50%')}; + flex-shrink: 0; + object-fit: cover; + border: 2px solid ${({ $color }) => $color}; + ${talkingStyles} +`; diff --git a/src/components/Avatar/types.ts b/src/components/Avatar/types.ts new file mode 100644 index 0000000..978b74f --- /dev/null +++ b/src/components/Avatar/types.ts @@ -0,0 +1,36 @@ +import { AVATAR_SIZE_VALUES } from './constants'; + +export type AvatarSize = typeof AVATAR_SIZE_VALUES[number]; + +export interface StyledAvatarProps { + $size: AvatarSize; + $color: string; + $isModerator: boolean; + $isTalking: boolean; +} + +export interface AvatarProps { + /** Full name of the user; used to render initials, to derive a deterministic fallback color, and as the tooltip content. */ + name: string; + + /** URL of the user's avatar image. Falls back to initials when omitted or if the image fails to load. */ + avatarUrl?: string; + + /** Background color behind the initials (and border color on the image). @default a color deterministically derived from `name`, or BBB's "you" color when `isYou` is set */ + color?: string; + + /** Size variant of the avatar. @default 'medium' */ + size?: AvatarSize; + + /** Renders a rounded-square shape instead of a circle, matching BBB's moderator avatar treatment. @default false */ + isModerator?: boolean; + + /** Marks this avatar as belonging to the current user, applying BBB's "you" color in place of the fallback/computed color. Ignored when `color` is set. @default false */ + isYou?: boolean; + + /** Shows a pulsing ring around the avatar, in its own color, matching BBB's talking indicator. @default false */ + isTalking?: boolean; + + /** Disables the tooltip that shows the full `name` on hover. @default false */ + disableTooltip?: boolean; +} diff --git a/src/components/index.ts b/src/components/index.ts index 230d706..ab4ba37 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,4 +1,5 @@ export { BBBAccordion } from './Accordion'; +export { BBBAvatar } from './Avatar'; export { BBButton } from './Button'; export { BBBCheckbox } from './Checkbox'; export { BBBDivider } from './Divider'; diff --git a/src/stylesheets/colors.ts b/src/stylesheets/colors.ts index 84251c7..fc5341a 100644 --- a/src/stylesheets/colors.ts +++ b/src/stylesheets/colors.ts @@ -9,6 +9,7 @@ import { colorHoverDark, colorHoverLight, colorHoverNeutral, colorOverlay, colorShadowDefault, + colorUserYou, } from './palette'; export const colors = { @@ -66,6 +67,9 @@ export const colors = { shadow: { default: colorShadowDefault, }, + user: { + you: colorUserYou, + }, } as const; export type Colors = typeof colors; diff --git a/src/stylesheets/palette.ts b/src/stylesheets/palette.ts index 1867278..6b43f8e 100644 --- a/src/stylesheets/palette.ts +++ b/src/stylesheets/palette.ts @@ -66,3 +66,6 @@ export const colorOverlay = 'var(--color-overlay, rgba(0, 0, 0, 0.75))'; //Shadow colors export const colorShadowDefault = 'var(--color-shadow-default, rgb(0 35 11 / 20%))'; + +// User colors +export const colorUserYou = 'var(--color-user-you, #19237C)'; diff --git a/webpack.config.babel.js b/webpack.config.babel.js index 461ae7f..90d5502 100644 --- a/webpack.config.babel.js +++ b/webpack.config.babel.js @@ -3,6 +3,7 @@ import path from 'path'; export default { entry: { Accordion: './src/components/Accordion/index.ts', + Avatar: './src/components/Avatar/index.ts', Button: './src/components/Button/index.ts', Checkbox: './src/components/Checkbox/index.ts', Divider: './src/components/Divider/index.ts',