init
Some checks failed
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2025-09-02 14:49:16 +08:00
commit 38ba663466
2885 changed files with 391107 additions and 0 deletions

View File

@@ -0,0 +1,295 @@
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { IconUser } from '../../icons/svg';
import { getParticipantById } from '../../participants/functions';
import { IParticipant } from '../../participants/types';
import { getAvatarColor, getInitials, isCORSAvatarURL } from '../functions';
import { IAvatarProps as AbstractProps } from '../types';
import { StatelessAvatar } from './';
export interface IProps {
/**
* The URL patterns for URLs that needs to be handled with CORS.
*/
_corsAvatarURLs?: Array<string>;
/**
* Custom avatar backgrounds from branding.
*/
_customAvatarBackgrounds?: Array<string>;
/**
* The string we base the initials on (this is generated from a list of precedences).
*/
_initialsBase?: string;
/**
* An URL that we validated that it can be loaded.
*/
_loadableAvatarUrl?: string;
/**
* Indicates whether _loadableAvatarUrl should use CORS or not.
*/
_loadableAvatarUrlUseCORS?: boolean;
/**
* A prop to maintain compatibility with web.
*/
className?: string;
/**
* A string to override the initials to generate a color of. This is handy if you don't want to make
* the background color match the string that the initials are generated from.
*/
colorBase?: string;
/**
* Indicates the default icon for the avatar.
*/
defaultIcon?: string;
/**
* Display name of the entity to render an avatar for (if any). This is handy when we need
* an avatar for a non-participant entity (e.g. A recent list item).
*/
displayName?: string;
/**
* Whether or not to update the background color of the avatar.
*/
dynamicColor?: boolean;
/**
* ID of the element, if any.
*/
id?: string;
/**
* The ID of the participant to render an avatar for (if it's a participant avatar).
*/
participantId?: string;
/**
* The size of the avatar.
*/
size?: number;
/**
* One of the expected status strings (e.g. 'available') to render a badge on the avatar, if necessary.
*/
status?: string;
/**
* TestId of the element, if any.
*/
testId?: string;
/**
* URL of the avatar, if any.
*/
url?: string;
/**
* Indicates whether to load the avatar using CORS or not.
*/
useCORS?: boolean;
}
interface IState {
avatarFailed: boolean;
isUsingCORS: boolean;
}
export const DEFAULT_SIZE = 65;
/**
* Implements a class to render avatars in the app.
*/
class Avatar<P extends IProps> extends PureComponent<P, IState> {
/**
* Default values for {@code Avatar} component's properties.
*
* @static
*/
static defaultProps = {
defaultIcon: IconUser,
dynamicColor: true
};
/**
* Instantiates a new {@code Component}.
*
* @inheritdoc
*/
constructor(props: P) {
super(props);
const {
_corsAvatarURLs,
url,
useCORS
} = props;
this.state = {
avatarFailed: false,
isUsingCORS: Boolean(useCORS) || Boolean(url && isCORSAvatarURL(url, _corsAvatarURLs))
};
this._onAvatarLoadError = this._onAvatarLoadError.bind(this);
}
/**
* Implements {@code Component#componentDidUpdate}.
*
* @inheritdoc
*/
override componentDidUpdate(prevProps: P) {
const { _corsAvatarURLs, url } = this.props;
if (prevProps.url !== url) {
// URI changed, so we need to try to fetch it again.
// Eslint doesn't like this statement, but based on the React doc, it's safe if it's
// wrapped in a condition: https://reactjs.org/docs/react-component.html#componentdidupdate
// eslint-disable-next-line react/no-did-update-set-state
this.setState({
avatarFailed: false,
isUsingCORS: Boolean(this.props.useCORS) || Boolean(url && isCORSAvatarURL(url, _corsAvatarURLs))
});
}
}
/**
* Implements {@code Componenr#render}.
*
* @inheritdoc
*/
override render() {
const {
_customAvatarBackgrounds,
_initialsBase,
_loadableAvatarUrl,
_loadableAvatarUrlUseCORS,
className,
colorBase,
defaultIcon,
dynamicColor,
id,
size,
status,
testId,
url
} = this.props;
const { avatarFailed, isUsingCORS } = this.state;
const avatarProps: AbstractProps & {
className?: string;
iconUser?: any;
id?: string;
status?: string;
testId?: string;
url?: string;
useCORS?: boolean;
} = {
className,
color: undefined,
id,
initials: undefined,
onAvatarLoadError: undefined,
onAvatarLoadErrorParams: undefined,
size,
status,
testId,
url: undefined,
useCORS: isUsingCORS
};
// _loadableAvatarUrl is validated that it can be loaded, but uri (if present) is not, so
// we still need to do a check for that. And an explicitly provided URI is higher priority than
// an avatar URL anyhow.
const useReduxLoadableAvatarURL = avatarFailed || !url;
const effectiveURL = useReduxLoadableAvatarURL ? _loadableAvatarUrl : url;
if (effectiveURL) {
avatarProps.onAvatarLoadError = this._onAvatarLoadError;
if (useReduxLoadableAvatarURL) {
avatarProps.onAvatarLoadErrorParams = { dontRetry: true };
avatarProps.useCORS = _loadableAvatarUrlUseCORS;
}
avatarProps.url = effectiveURL;
}
const initials = getInitials(_initialsBase);
if (initials) {
if (dynamicColor) {
avatarProps.color = getAvatarColor(colorBase || _initialsBase, _customAvatarBackgrounds ?? []);
}
avatarProps.initials = initials;
}
if (navigator.product !== 'ReactNative') {
avatarProps.iconUser = defaultIcon;
}
return (
<StatelessAvatar
{ ...avatarProps } />
);
}
/**
* Callback to handle the error while loading of the avatar URI.
*
* @param {Object} params - An object with parameters.
* @param {boolean} params.dontRetry - If false we will retry to load the Avatar with different CORS mode.
* @returns {void}
*/
_onAvatarLoadError(params: { dontRetry?: boolean; } = {}) {
const { dontRetry = false } = params;
if (Boolean(this.props.useCORS) === this.state.isUsingCORS && !dontRetry) {
// try different mode of loading the avatar.
this.setState({
isUsingCORS: !this.state.isUsingCORS
});
} else {
// we already have tried loading the avatar with and without CORS and it failed.
this.setState({
avatarFailed: true
});
}
}
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @param {IProps} ownProps - The own props of the component.
* @returns {IProps}
*/
export function _mapStateToProps(state: IReduxState, ownProps: IProps) {
const { colorBase, displayName, participantId } = ownProps;
const _participant: IParticipant | undefined = participantId ? getParticipantById(state, participantId) : undefined;
const _initialsBase = _participant?.name ?? displayName;
const { corsAvatarURLs } = state['features/base/config'];
return {
_customAvatarBackgrounds: state['features/dynamic-branding'].avatarBackgrounds,
_corsAvatarURLs: corsAvatarURLs,
_initialsBase,
_loadableAvatarUrl: _participant?.loadableAvatarUrl,
_loadableAvatarUrlUseCORS: _participant?.loadableAvatarUrlUseCORS,
colorBase
};
}
export default connect(_mapStateToProps)(Avatar);

View File

@@ -0,0 +1 @@
export { default as StatelessAvatar } from './native/StatelessAvatar';

View File

@@ -0,0 +1 @@
export { default as StatelessAvatar } from './web/StatelessAvatar';

View File

@@ -0,0 +1,200 @@
import React, { Component } from 'react';
import { Image, Text, TextStyle, View, ViewStyle } from 'react-native';
import Icon from '../../../icons/components/Icon';
import { StyleType } from '../../../styles/functions.native';
import { isIcon } from '../../functions';
import { IAvatarProps } from '../../types';
import styles from './styles';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const DEFAULT_AVATAR = require('../../../../../../images/avatar.png');
interface IProps extends IAvatarProps {
/**
* One of the expected status strings (e.g. 'available') to render a badge on the avatar, if necessary.
*/
status?: string;
/**
* External style passed to the component.
*/
style?: StyleType;
/**
* The URL of the avatar to render.
*/
url?: string;
}
/**
* Implements a stateless avatar component that renders an avatar purely from what gets passed through
* props.
*/
export default class StatelessAvatar extends Component<IProps> {
/**
* Instantiates a new {@code Component}.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this._onAvatarLoadError = this._onAvatarLoadError.bind(this);
}
/**
* Implements {@code Component#render}.
*
* @inheritdoc
*/
override render() {
const { initials, size, style, url } = this.props;
let avatar;
if (isIcon(url)) {
avatar = this._renderIconAvatar(url);
} else if (url) {
avatar = this._renderURLAvatar();
} else if (initials) {
avatar = this._renderInitialsAvatar();
} else {
avatar = this._renderDefaultAvatar();
}
return (
<View>
<View
style = { [
styles.avatarContainer(size) as ViewStyle,
style
] }>
{ avatar }
</View>
{ this._renderAvatarStatus() }
</View>
);
}
/**
* Renders a badge representing the avatar status.
*
* @returns {React$Elementaa}
*/
_renderAvatarStatus() {
const { size, status } = this.props;
if (!status) {
return null;
}
return (
<View style = { styles.badgeContainer }>
<View style = { styles.badge(size, status) as ViewStyle } />
</View>
);
}
/**
* Renders the default avatar.
*
* @returns {React$Element<*>}
*/
_renderDefaultAvatar() {
const { size } = this.props;
return (
<Image
source = { DEFAULT_AVATAR }
style = { [
styles.avatarContent(size),
styles.staticAvatar
] } />
);
}
/**
* Renders the icon avatar.
*
* @param {Object} icon - The icon component to render.
* @returns {React$Element<*>}
*/
_renderIconAvatar(icon: Function) {
const { color, size } = this.props;
return (
<View
style = { [
styles.initialsContainer as ViewStyle,
{
backgroundColor: color
}
] }>
<Icon
src = { icon }
style = { styles.initialsText(size) } />
</View>
);
}
/**
* Renders the initials-based avatar.
*
* @returns {React$Element<*>}
*/
_renderInitialsAvatar() {
const { color, initials, size } = this.props;
return (
<View
style = { [
styles.initialsContainer as ViewStyle,
{
backgroundColor: color
}
] }>
<Text style = { styles.initialsText(size) as TextStyle }> { initials } </Text>
</View>
);
}
/**
* Renders the url-based avatar.
*
* @returns {React$Element<*>}
*/
_renderURLAvatar() {
const { onAvatarLoadError, size, url } = this.props;
return (
<Image
defaultSource = { DEFAULT_AVATAR }
// @ts-ignore
onError = { onAvatarLoadError }
resizeMode = 'cover'
source = {{ uri: url }}
style = { styles.avatarContent(size) } />
);
}
/**
* Handles avatar load errors.
*
* @returns {void}
*/
_onAvatarLoadError() {
const { onAvatarLoadError, onAvatarLoadErrorParams = {} } = this.props;
if (onAvatarLoadError) {
onAvatarLoadError({
...onAvatarLoadErrorParams,
dontRetry: true
});
}
}
}

View File

@@ -0,0 +1,82 @@
import { StyleSheet } from 'react-native';
import { ColorPalette } from '../../../styles/components/styles/ColorPalette';
import { PRESENCE_AVAILABLE_COLOR, PRESENCE_AWAY_COLOR, PRESENCE_BUSY_COLOR, PRESENCE_IDLE_COLOR } from '../styles';
const DEFAULT_SIZE = 65;
/**
* The styles of the feature base/participants.
*/
export default {
avatarContainer: (size: number = DEFAULT_SIZE) => {
return {
alignItems: 'center',
borderRadius: size / 2,
height: size,
justifyContent: 'center',
overflow: 'hidden',
width: size
};
},
avatarContent: (size: number = DEFAULT_SIZE) => {
return {
height: size,
width: size
};
},
badge: (size: number = DEFAULT_SIZE, status: string) => {
let color;
switch (status) {
case 'available':
color = PRESENCE_AVAILABLE_COLOR;
break;
case 'away':
color = PRESENCE_AWAY_COLOR;
break;
case 'busy':
color = PRESENCE_BUSY_COLOR;
break;
case 'idle':
color = PRESENCE_IDLE_COLOR;
break;
}
return {
backgroundColor: color,
borderRadius: size / 2,
bottom: 0,
height: size * 0.3,
position: 'absolute',
width: size * 0.3
};
},
badgeContainer: {
...StyleSheet.absoluteFillObject
},
initialsContainer: {
alignItems: 'center',
alignSelf: 'stretch',
flex: 1,
justifyContent: 'center'
},
initialsText: (size: number = DEFAULT_SIZE) => {
return {
color: 'white',
fontSize: size * 0.45,
fontWeight: '100'
};
},
staticAvatar: {
backgroundColor: ColorPalette.lightGrey,
opacity: 0.4
}
};

View File

@@ -0,0 +1,5 @@
// Colors for avatar status badge
export const PRESENCE_AVAILABLE_COLOR = 'rgb(110, 176, 5)';
export const PRESENCE_AWAY_COLOR = 'rgb(250, 201, 20)';
export const PRESENCE_BUSY_COLOR = 'rgb(233, 0, 27)';
export const PRESENCE_IDLE_COLOR = 'rgb(172, 172, 172)';

View File

@@ -0,0 +1,220 @@
import React, { useCallback } from 'react';
import { makeStyles } from 'tss-react/mui';
import Icon from '../../../icons/components/Icon';
import { pixelsToRem } from '../../../ui/functions.any';
import { isIcon } from '../../functions';
import { IAvatarProps } from '../../types';
import { PRESENCE_AVAILABLE_COLOR, PRESENCE_AWAY_COLOR, PRESENCE_BUSY_COLOR, PRESENCE_IDLE_COLOR } from '../styles';
interface IProps extends IAvatarProps {
/**
* External class name passed through props.
*/
className?: string;
/**
* The default avatar URL if we want to override the app bundled one (e.g. AlwaysOnTop).
*/
defaultAvatar?: string;
/**
* ID of the component to be rendered.
*/
id?: string;
/**
* One of the expected status strings (e.g. 'available') to render a badge on the avatar, if necessary.
*/
status?: string;
/**
* TestId of the element, if any.
*/
testId?: string;
/**
* The URL of the avatar to render.
*/
url?: string | Function;
/**
* Indicates whether to load the avatar using CORS or not.
*/
useCORS?: boolean;
}
const useStyles = makeStyles()(theme => {
return {
avatar: {
backgroundColor: '#AAA',
borderRadius: '50%',
color: theme.palette?.text01 || '#fff',
...(theme.typography?.heading1 ?? {}),
fontSize: 'inherit',
objectFit: 'cover',
textAlign: 'center',
overflow: 'hidden',
'&.avatar-small': {
height: '28px !important',
width: '28px !important'
},
'&.avatar-xsmall': {
height: '16px !important',
width: '16px !important'
},
'& .jitsi-icon': {
transform: 'translateY(50%)'
},
'& .avatar-svg': {
height: '100%',
width: '100%'
}
},
initialsContainer: {
width: '100%',
height: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
},
badge: {
position: 'relative',
'&.avatar-badge:after': {
borderRadius: '50%',
content: '""',
display: 'block',
height: '35%',
position: 'absolute',
bottom: 0,
width: '35%'
},
'&.avatar-badge-available:after': {
backgroundColor: PRESENCE_AVAILABLE_COLOR
},
'&.avatar-badge-away:after': {
backgroundColor: PRESENCE_AWAY_COLOR
},
'&.avatar-badge-busy:after': {
backgroundColor: PRESENCE_BUSY_COLOR
},
'&.avatar-badge-idle:after': {
backgroundColor: PRESENCE_IDLE_COLOR
}
}
};
});
const StatelessAvatar = ({
className,
color,
iconUser,
id,
initials,
onAvatarLoadError,
onAvatarLoadErrorParams,
size,
status,
testId,
url,
useCORS
}: IProps) => {
const { classes, cx } = useStyles();
const _getAvatarStyle = (backgroundColor?: string) => {
return {
background: backgroundColor || undefined,
fontSize: size ? pixelsToRem(size * 0.4) : '180%',
height: size || '100%',
width: size || '100%'
};
};
const _getAvatarClassName = (additional?: string) => cx('avatar', additional, className, classes.avatar);
const _getBadgeClassName = () => {
if (status) {
return cx('avatar-badge', `avatar-badge-${status}`, classes.badge);
}
return '';
};
const _onAvatarLoadError = useCallback(() => {
if (typeof onAvatarLoadError === 'function') {
onAvatarLoadError(onAvatarLoadErrorParams);
}
}, [ onAvatarLoadError, onAvatarLoadErrorParams ]);
if (isIcon(url)) {
return (
<div
className = { cx(_getAvatarClassName(), _getBadgeClassName()) }
data-testid = { testId }
id = { id }
style = { _getAvatarStyle(color) }>
<Icon
size = '50%'
src = { url } />
</div>
);
}
if (url) {
return (
<div className = { _getBadgeClassName() }>
<img
alt = 'avatar'
className = { _getAvatarClassName() }
crossOrigin = { useCORS ? '' : undefined }
data-testid = { testId }
id = { id }
onError = { _onAvatarLoadError }
src = { url }
style = { _getAvatarStyle() } />
</div>
);
}
if (initials) {
return (
<div
className = { cx(_getAvatarClassName(), _getBadgeClassName()) }
data-testid = { testId }
id = { id }
style = { _getAvatarStyle(color) }>
<div className = { classes.initialsContainer }>
{initials}
</div>
</div>
);
}
// default avatar
return (
<div
className = { cx(_getAvatarClassName('defaultAvatar'), _getBadgeClassName()) }
data-testid = { testId }
id = { id }
style = { _getAvatarStyle() }>
<Icon
size = { '50%' }
src = { iconUser } />
</div>
);
};
export default StatelessAvatar;

View File

@@ -0,0 +1,4 @@
/**
* The base URL for gravatar images.
*/
export const GRAVATAR_BASE_URL = 'https://www.gravatar.com/avatar/';

View File

@@ -0,0 +1,95 @@
import GraphemeSplitter from 'grapheme-splitter';
import { split } from 'lodash-es';
const AVATAR_COLORS = [
'#6A50D3',
'#FF9B42',
'#DF486F',
'#73348C',
'#B23683',
'#F96E57',
'#4380E2',
'#238561',
'#00A8B3'
];
const wordSplitRegex = (/\s+|\.+|_+|;+|-+|,+|\|+|\/+|\\+|"+|'+|\(+|\)+|#+|&+/);
const splitter = new GraphemeSplitter();
/**
* Generates the background color of an initials based avatar.
*
* @param {string?} initials - The initials of the avatar.
* @param {Array<string>} customAvatarBackgrounds - Custom avatar background values.
* @returns {string}
*/
export function getAvatarColor(initials: string | undefined, customAvatarBackgrounds: Array<string>) {
const hasCustomAvatarBackgronds = customAvatarBackgrounds?.length;
const colorsBase = hasCustomAvatarBackgronds ? customAvatarBackgrounds : AVATAR_COLORS;
let colorIndex = 0;
if (initials) {
let nameHash = 0;
for (const s of initials) {
nameHash += Number(s.codePointAt(0));
}
colorIndex = nameHash % colorsBase.length;
}
return colorsBase[colorIndex];
}
/**
* Returns the first grapheme from a word, uppercased.
*
* @param {string} word - The string to get grapheme from.
* @returns {string}
*/
function getFirstGraphemeUpper(word: string) {
if (!word?.length) {
return '';
}
return splitter.splitGraphemes(word)[0].toUpperCase();
}
/**
* Generates initials for a simple string.
*
* @param {string?} s - The string to generate initials for.
* @returns {string?}
*/
export function getInitials(s?: string) {
// We don't want to use the domain part of an email address, if it is one
const initialsBasis = split(s, '@')[0];
const [ firstWord, ...remainingWords ] = initialsBasis.split(wordSplitRegex).filter(Boolean);
return getFirstGraphemeUpper(firstWord) + getFirstGraphemeUpper(remainingWords.pop() || '');
}
/**
* Checks if the passed URL should be loaded with CORS.
*
* @param {string | Function} url - The URL (on mobile we use a specific Icon component for avatars).
* @param {Array<string>} corsURLs - The URL pattern that matches a URL that needs to be handled with CORS.
* @returns {boolean}
*/
export function isCORSAvatarURL(url: string | Function, corsURLs: Array<string> = []): boolean {
if (typeof url === 'function') {
return false;
}
return corsURLs.some(pattern => url.startsWith(pattern));
}
/**
* Checks if the passed prop is a loaded icon or not.
*
* @param {string? | Object?} iconProp - The prop to check.
* @returns {boolean}
*/
export function isIcon(iconProp?: string | Function): iconProp is Function {
return Boolean(iconProp) && (typeof iconProp === 'object' || typeof iconProp === 'function');
}

View File

@@ -0,0 +1,32 @@
export interface IAvatarProps {
/**
* Color of the (initials based) avatar, if needed.
*/
color?: string;
/**
* The user icon(browser only).
*/
iconUser?: any;
/**
* Initials to be used to render the initials based avatars.
*/
initials?: string;
/**
* Callback to signal the failure of the loading of the URL.
*/
onAvatarLoadError?: Function;
/**
* Additional parameters to be passed to onAvatarLoadError function.
*/
onAvatarLoadErrorParams?: Object;
/**
* Expected size of the avatar.
*/
size?: number;
}