fix: AlwaysOnTop avatar

This commit is contained in:
Bettenbuk Zoltan 2019-07-03 17:39:39 +02:00 committed by Zoltan Bettenbuk
parent 658679f89e
commit a04982fd96
18 changed files with 386 additions and 325 deletions

View File

@ -2,6 +2,11 @@
import React, { Component } from 'react';
// We need to reference these files directly to avoid loading things that are not available
// in this environment (e.g. JitsiMeetJS or interfaceConfig)
import StatelessAvatar from '../base/avatar/components/web/StatelessAvatar';
import { getInitials } from '../base/avatar/functions';
import Toolbar from './Toolbar';
const { api } = window.alwaysOnTop;
@ -17,6 +22,7 @@ const TOOLBAR_TIMEOUT = 4000;
type State = {
avatarURL: string,
displayName: string,
formattedDisplayName: string,
isVideoDisplayed: boolean,
visible: boolean
};
@ -42,6 +48,7 @@ export default class AlwaysOnTop extends Component<*, State> {
this.state = {
avatarURL: '',
displayName: '',
formattedDisplayName: '',
isVideoDisplayed: true,
visible: true
};
@ -78,10 +85,15 @@ export default class AlwaysOnTop extends Component<*, State> {
*
* @returns {void}
*/
_displayNameChangedListener({ formattedDisplayName, id }) {
_displayNameChangedListener({ displayname, formattedDisplayName, id }) {
if (api._getOnStageParticipant() === id
&& formattedDisplayName !== this.state.displayName) {
this.setState({ displayName: formattedDisplayName });
&& (formattedDisplayName !== this.state.formattedDisplayName
|| displayname !== this.state.displayName)) {
// I think the API has a typo using lowercase n for the displayname
this.setState({
displayName: displayname,
formattedDisplayName
});
}
}
@ -112,12 +124,14 @@ export default class AlwaysOnTop extends Component<*, State> {
_largeVideoChangedListener() {
const userID = api._getOnStageParticipant();
const avatarURL = api.getAvatarURL(userID);
const displayName = api._getFormattedDisplayName(userID);
const displayName = api.getDisplayName(userID);
const formattedDisplayName = api._getFormattedDisplayName(userID);
const isVideoDisplayed = Boolean(api._getLargeVideo());
this.setState({
avatarURL,
displayName,
formattedDisplayName,
isVideoDisplayed
});
}
@ -161,7 +175,7 @@ export default class AlwaysOnTop extends Component<*, State> {
* @returns {ReactElement}
*/
_renderVideoNotAvailableScreen() {
const { avatarURL, displayName, isVideoDisplayed } = this.state;
const { avatarURL, displayName, formattedDisplayName, isVideoDisplayed } = this.state;
if (isVideoDisplayed) {
return null;
@ -169,19 +183,16 @@ export default class AlwaysOnTop extends Component<*, State> {
return (
<div id = 'videoNotAvailableScreen'>
{
avatarURL
? <div id = 'avatarContainer'>
<img
id = 'avatar'
src = { avatarURL } />
</div>
: null
}
<div id = 'avatarContainer'>
<StatelessAvatar
id = 'avatar'
initials = { getInitials(displayName) }
url = { avatarURL } />)
</div>
<div
className = 'displayname'
id = 'displayname'>
{ displayName }
{ formattedDisplayName }
</div>
</div>
);

View File

@ -0,0 +1,37 @@
// @flow
import { PureComponent } from 'react';
export type Props = {
/**
* Color of the (initials based) avatar, if needed.
*/
color?: string,
/**
* 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,
/**
* Expected size of the avatar.
*/
size?: number;
/**
* The URL of the avatar to render.
*/
url?: ?string
};
/**
* Implements an abstract stateless avatar component that renders an avatar purely from what gets passed through
* props.
*/
export default class AbstractStatelessAvatar<P: Props> extends PureComponent<P> {}

View File

@ -1,11 +1,14 @@
// @flow
import { PureComponent } from 'react';
import React, { PureComponent } from 'react';
import { getParticipantById } from '../../participants';
import { connect } from '../../redux';
import { getAvatarColor, getInitials } from '../functions';
import { StatelessAvatar } from '.';
export type Props = {
/**
@ -18,6 +21,11 @@ export type Props = {
*/
_loadableAvatarUrl: ?string,
/**
* 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.
@ -30,6 +38,11 @@ export type Props = {
*/
displayName?: string,
/**
* ID of the element, if any.
*/
id?: string,
/**
* The ID of the participant to render an avatar for (if it's a participant avatar).
*/
@ -41,9 +54,9 @@ export type Props = {
size: number,
/**
* URI of the avatar, if any.
* URL of the avatar, if any.
*/
uri: ?string,
url: ?string,
}
type State = {
@ -53,9 +66,9 @@ type State = {
export const DEFAULT_SIZE = 65;
/**
* Implements an abstract class to render avatars in the app.
* Implements a class to render avatars in the app.
*/
export default class AbstractAvatar<P: Props> extends PureComponent<P, State> {
class Avatar<P: Props> extends PureComponent<P, State> {
/**
* Instantiates a new {@code Component}.
*
@ -77,7 +90,7 @@ export default class AbstractAvatar<P: Props> extends PureComponent<P, State> {
* @inheritdoc
*/
componentDidUpdate(prevProps: P) {
if (prevProps.uri !== this.props.uri) {
if (prevProps.url !== this.props.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
@ -99,25 +112,45 @@ export default class AbstractAvatar<P: Props> extends PureComponent<P, State> {
const {
_initialsBase,
_loadableAvatarUrl,
className,
colorBase,
uri
id,
size,
url
} = this.props;
const { avatarFailed } = this.state;
const avatarProps = {
className,
color: undefined,
id,
initials: undefined,
onAvatarLoadError: undefined,
size,
url: undefined
};
// _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.
if ((uri && !avatarFailed) || _loadableAvatarUrl) {
return this._renderURLAvatar((!avatarFailed && uri) || _loadableAvatarUrl);
const effectiveURL = (!avatarFailed && url) || _loadableAvatarUrl;
if (effectiveURL) {
avatarProps.onAvatarLoadError = this._onAvatarLoadError;
avatarProps.url = effectiveURL;
} else {
const initials = getInitials(_initialsBase);
if (initials) {
avatarProps.color = getAvatarColor(colorBase || _initialsBase);
avatarProps.initials = initials;
}
}
const _initials = getInitials(_initialsBase);
if (_initials) {
return this._renderInitialsAvatar(_initials, getAvatarColor(colorBase || _initialsBase));
}
return this._renderDefaultAvatar();
return (
<StatelessAvatar
{ ...avatarProps } />
);
}
_onAvatarLoadError: () => void;
@ -132,30 +165,6 @@ export default class AbstractAvatar<P: Props> extends PureComponent<P, State> {
avatarFailed: true
});
}
/**
* Function to render the actual, platform specific default avatar component.
*
* @returns {React$Element<*>}
*/
_renderDefaultAvatar: () => React$Element<*>
/**
* Function to render the actual, platform specific initials-based avatar component.
*
* @param {string} initials - The initials to use.
* @param {string} color - The color to use.
* @returns {React$Element<*>}
*/
_renderInitialsAvatar: (string, string) => React$Element<*>
/**
* Function to render the actual, platform specific URL-based avatar component.
*
* @param {string} uri - The URI of the avatar.
* @returns {React$Element<*>}
*/
_renderURLAvatar: ?string => React$Element<*>
}
/**
@ -168,10 +177,12 @@ export default class AbstractAvatar<P: Props> extends PureComponent<P, State> {
export function _mapStateToProps(state: Object, ownProps: Props) {
const { displayName, participantId } = ownProps;
const _participant = participantId && getParticipantById(state, participantId);
const _initialsBase = (_participant && (_participant.name || _participant.email)) || displayName;
const _initialsBase = (_participant && _participant.name) || displayName;
return {
_initialsBase,
_loadableAvatarUrl: _participant && _participant.loadableAvatarUrl
};
}
export default connect(_mapStateToProps)(Avatar);

View File

@ -1,3 +1,4 @@
// @flow
export * from './native';
export { default as Avatar } from './Avatar';

View File

@ -1,3 +1,4 @@
// @flow
export * from './web';
export { default as Avatar } from './Avatar';

View File

@ -1,106 +0,0 @@
// @flow
import React from 'react';
import { Image, Text, View } from 'react-native';
import { connect } from '../../../redux';
import { type StyleType } from '../../../styles';
import AbstractAvatar, {
_mapStateToProps,
type Props as AbstractProps,
DEFAULT_SIZE
} from '../AbstractAvatar';
import RemoteAvatar, { DEFAULT_AVATAR } from './RemoteAvatar';
import styles from './styles';
type Props = AbstractProps & {
/**
* External style of the component.
*/
style?: StyleType
}
/**
* Implements an avatar component that has 4 ways to render an avatar:
*
* - Based on an explicit avatar URI, if provided
* - Gravatar, if there is any
* - Based on initials generated from name or email
* - Default avatar icon, if any of the above fails
*/
class Avatar extends AbstractAvatar<Props> {
_onAvatarLoadError: () => void;
/**
* Implements {@code AbstractAvatar#_renderDefaultAvatar}.
*
* @inheritdoc
*/
_renderDefaultAvatar() {
return this._wrapAvatar(
<Image
source = { DEFAULT_AVATAR }
style = { [
styles.avatarContent(this.props.size || DEFAULT_SIZE),
styles.staticAvatar
] } />
);
}
/**
* Implements {@code AbstractAvatar#_renderGravatar}.
*
* @inheritdoc
*/
_renderInitialsAvatar(initials, color) {
return this._wrapAvatar(
<View
style = { [
styles.initialsContainer,
{
backgroundColor: color
}
] }>
<Text style = { styles.initialsText(this.props.size || DEFAULT_SIZE) }> { initials } </Text>
</View>
);
}
/**
* Implements {@code AbstractAvatar#_renderGravatar}.
*
* @inheritdoc
*/
_renderURLAvatar(uri) {
return this._wrapAvatar(
<RemoteAvatar
onError = { this._onAvatarLoadError }
size = { this.props.size || DEFAULT_SIZE }
uri = { uri } />
);
}
/**
* Wraps an avatar into a common wrapper.
*
* @param {React#Component} avatar - The avatar component.
* @returns {React#Component}
*/
_wrapAvatar(avatar) {
return (
<View
style = { [
styles.avatarContainer(this.props.size || DEFAULT_SIZE),
this.props.style
] }>
{ avatar }
</View>
);
}
}
export default connect(_mapStateToProps)(Avatar);

View File

@ -1,50 +0,0 @@
// @flow
import React, { PureComponent } from 'react';
import { Image } from 'react-native';
import styles from './styles';
export const DEFAULT_AVATAR = require('../../../../../../images/avatar.png');
type Props = {
/**
* Callback for load errors.
*/
onError: Function,
/**
* Size of the avatar.
*/
size: number,
/**
* URI of the avatar to load.
*/
uri: string
};
/**
* Implements a private class that is used to fetch and render remote avatars based on an URI.
*/
export default class RemoteAvatar extends PureComponent<Props> {
/**
* Implements {@code Component#render}.
*
* @inheritdoc
*/
render() {
const { onError, size, uri } = this.props;
return (
<Image
defaultSource = { DEFAULT_AVATAR }
onError = { onError }
resizeMode = 'cover'
source = {{ uri }}
style = { styles.avatarContent(size) } />
);
}
}

View File

@ -0,0 +1,112 @@
// @flow
import React from 'react';
import { Image, Text, View } from 'react-native';
import { type StyleType } from '../../../styles';
import AbstractStatelessAvatar, { type Props as AbstractProps } from '../AbstractStatelessAvatar';
import styles from './styles';
type Props = AbstractProps & {
/**
* External style passed to the componant.
*/
style?: StyleType
};
const DEFAULT_AVATAR = require('../../../../../../images/avatar.png');
/**
* Implements a stateless avatar component that renders an avatar purely from what gets passed through
* props.
*/
export default class StatelessAvatar extends AbstractStatelessAvatar<Props> {
/**
* Implements {@code Component#render}.
*
* @inheritdoc
*/
render() {
const { initials, size, style, url } = this.props;
let avatar;
if (url) {
avatar = this._renderURLAvatar();
} else if (initials) {
avatar = this._renderInitialsAvatar();
} else {
avatar = this._renderDefaultAvatar();
}
return (
<View
style = { [
styles.avatarContainer(size),
style
] }>
{ avatar }
</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 initials-based avatar.
*
* @returns {React$Element<*>}
*/
_renderInitialsAvatar() {
const { color, initials, size } = this.props;
return (
<View
style = { [
styles.initialsContainer,
{
backgroundColor: color
}
] }>
<Text style = { styles.initialsText(size) }> { initials } </Text>
</View>
);
}
/**
* Renders the url-based avatar.
*
* @returns {React$Element<*>}
*/
_renderURLAvatar() {
const { onAvatarLoadError, size, url } = this.props;
return (
<Image
defaultSource = { DEFAULT_AVATAR }
onError = { onAvatarLoadError }
resizeMode = 'cover'
source = {{ uri: url }}
style = { styles.avatarContent(size) } />
);
}
}

View File

@ -1,3 +1,3 @@
// @flow
export { default as Avatar } from './Avatar';
export { default as StatelessAvatar } from './StatelessAvatar';

View File

@ -2,12 +2,14 @@
import { ColorPalette } from '../../../styles';
const DEFAULT_SIZE = 65;
/**
* The styles of the feature base/participants.
*/
export default {
avatarContainer: (size: number) => {
avatarContainer: (size: number = DEFAULT_SIZE) => {
return {
alignItems: 'center',
borderRadius: size / 2,
@ -18,7 +20,7 @@ export default {
};
},
avatarContent: (size: number) => {
avatarContent: (size: number = DEFAULT_SIZE) => {
return {
height: size,
width: size
@ -32,7 +34,7 @@ export default {
justifyContent: 'center'
},
initialsText: (size: number) => {
initialsText: (size: number = DEFAULT_SIZE) => {
return {
color: 'rgba(255, 255, 255, 0.6)',
fontSize: size * 0.5,

View File

@ -1,98 +0,0 @@
// @flow
import React from 'react';
import { connect } from '../../../redux';
import AbstractAvatar, {
_mapStateToProps,
type Props as AbstractProps
} from '../AbstractAvatar';
type Props = AbstractProps & {
className?: string,
id: string
};
/**
* Implements an avatar as a React/Web {@link Component}.
*/
class Avatar extends AbstractAvatar<Props> {
/**
* Constructs a style object to be used on the avatars.
*
* @param {string?} color - The desired background color.
* @returns {Object}
*/
_getAvatarStyle(color) {
const { size } = this.props;
return {
backgroundColor: color || undefined,
fontSize: size ? size * 0.5 : '180%',
height: size || '100%',
width: size || '100%'
};
}
/**
* Constructs a list of class names required for the avatar component.
*
* @param {string} additional - Any additional class to add.
* @returns {string}
*/
_getAvatarClassName(additional) {
return `avatar ${additional || ''} ${this.props.className || ''}`;
}
_onAvatarLoadError: () => void;
/**
* Implements {@code AbstractAvatar#_renderDefaultAvatar}.
*
* @inheritdoc
*/
_renderDefaultAvatar() {
return (
<img
className = { this._getAvatarClassName('defaultAvatar') }
id = { this.props.id }
src = 'images/avatar.png'
style = { this._getAvatarStyle() } />
);
}
/**
* Implements {@code AbstractAvatar#_renderGravatar}.
*
* @inheritdoc
*/
_renderInitialsAvatar(initials, color) {
return (
<div
className = { this._getAvatarClassName() }
id = { this.props.id }
style = { this._getAvatarStyle(color) }>
{ initials }
</div>
);
}
/**
* Implements {@code AbstractAvatar#_renderGravatar}.
*
* @inheritdoc
*/
_renderURLAvatar(uri) {
return (
<img
className = { this._getAvatarClassName() }
id = { this.props.id }
onError = { this._onAvatarLoadError }
src = { uri }
style = { this._getAvatarStyle() } />
);
}
}
export default connect(_mapStateToProps)(Avatar);

View File

@ -0,0 +1,96 @@
// @flow
import React from 'react';
import AbstractStatelessAvatar, { type Props as AbstractProps } from '../AbstractStatelessAvatar';
type Props = AbstractProps & {
/**
* 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
};
/**
* Implements a stateless avatar component that renders an avatar purely from what gets passed through
* props.
*/
export default class StatelessAvatar extends AbstractStatelessAvatar<Props> {
/**
* Implements {@code Component#render}.
*
* @inheritdoc
*/
render() {
const { initials, url } = this.props;
if (url) {
return (
<img
className = { this._getAvatarClassName() }
id = { this.props.id }
onError = { this.props.onAvatarLoadError }
src = { url }
style = { this._getAvatarStyle() } />
);
}
if (initials) {
return (
<div
className = { this._getAvatarClassName() }
id = { this.props.id }
style = { this._getAvatarStyle(this.props.color) }>
{ initials }
</div>
);
}
// default avatar
return (
<img
className = { this._getAvatarClassName('defaultAvatar') }
id = { this.props.id }
src = { this.props.defaultAvatar || 'images/avatar.png' }
style = { this._getAvatarStyle() } />
);
}
/**
* Constructs a style object to be used on the avatars.
*
* @param {string?} color - The desired background color.
* @returns {Object}
*/
_getAvatarStyle(color) {
const { size } = this.props;
return {
backgroundColor: color || undefined,
fontSize: size ? size * 0.5 : '180%',
height: size || '100%',
width: size || '100%'
};
}
/**
* Constructs a list of class names required for the avatar component.
*
* @param {string} additional - Any additional class to add.
* @returns {string}
*/
_getAvatarClassName(additional) {
return `avatar ${additional || ''} ${this.props.className || ''}`;
}
}

View File

@ -1,3 +1,3 @@
// @flow
export { default as Avatar } from './Avatar';
export { default as StatelessAvatar } from './StatelessAvatar';

View File

@ -1,3 +1,4 @@
// @flow
export * from './components';
export * from './functions';

View File

@ -91,7 +91,7 @@ export default class AvatarListItem extends Component<Props> {
displayName = { title }
size = { avatarSize }
style = { avatarStyle }
uri = { avatar } />
url = { avatar } />
<Container style = { styles.listItemDetails }>
<Text
numberOfLines = { 1 }

View File

@ -18,6 +18,25 @@ export function createDeferred(): Object {
return deferred;
}
/**
* Returns the base URL of the app.
*
* @param {Object} w - Window object to use instead of the built in one.
* @returns {string}
*/
export function getBaseUrl(w: Object = window) {
const doc = w.document;
const base = doc.querySelector('base');
if (base && base.href) {
return base.href;
}
const { protocol, host } = w.location;
return `${protocol}//${host}`;
}
/**
* Returns the namespace for all global variables, functions, etc that we need.
*

View File

@ -10,11 +10,11 @@ import { JitsiConferenceErrors } from '../base/lib-jitsi-meet';
import {
PARTICIPANT_KICKED,
SET_LOADABLE_AVATAR_URL,
getAvatarURLByParticipantId,
getLocalParticipant,
getParticipantById
} from '../base/participants';
import { MiddlewareRegistry } from '../base/redux';
import { getBaseUrl } from '../base/util';
import { appendSuffix } from '../display-name';
import { SUBMIT_FEEDBACK } from '../feedback';
import { SET_FILMSTRIP_VISIBLE } from '../filmstrip';
@ -39,11 +39,26 @@ MiddlewareRegistry.register(store => next => action => {
const result = next(action);
if (participant && (participant.loadableAvatarUrl !== loadableAvatarUrl)) {
APP.API.notifyAvatarChanged(
id,
loadableAvatarUrl
);
if (participant) {
if (loadableAvatarUrl) {
participant.loadableAvatarUrl !== loadableAvatarUrl && APP.API.notifyAvatarChanged(
id,
loadableAvatarUrl
);
} else {
// There is no loadable explicit URL. In this case the Avatar component would
// decide to render initials or the default avatar, but the external API needs
// a URL when it needs to be rendered, so if there is no initials, we return the default
// Avatar URL as if it was a usual avatar URL. If there are (or may be) initials
// we send undefined to signal the api user that it's not an URL that needs to be rendered.
//
// NOTE: we may implement a special URL format later to signal that the avatar is based
// on initials, that API consumers can handle as they want, e.g. initials://jm
APP.API.notifyAvatarChanged(
id,
participant.name ? undefined : _getDefaultAvatarUrl()
);
}
}
return result;
@ -65,7 +80,7 @@ MiddlewareRegistry.register(store => next => action => {
case CONFERENCE_JOINED: {
const state = store.getState();
const { room } = state['features/base/conference'];
const { name, id } = getLocalParticipant(state);
const { loadableAvatarUrl, name, id } = getLocalParticipant(state);
APP.API.notifyConferenceJoined(
room,
@ -76,7 +91,7 @@ MiddlewareRegistry.register(store => next => action => {
name,
interfaceConfig.DEFAULT_LOCAL_DISPLAY_NAME
),
avatarURL: getAvatarURLByParticipantId(state, id)
avatarURL: loadableAvatarUrl
}
);
break;
@ -125,3 +140,12 @@ MiddlewareRegistry.register(store => next => action => {
return result;
});
/**
* Returns the absolute URL of the default avatar.
*
* @returns {string}
*/
function _getDefaultAvatarUrl() {
return new URL('images/avatar.png', getBaseUrl()).href;
}

View File

@ -130,7 +130,7 @@ class IncomingCallPage extends Component<Props> {
<View style = { styles.avatar }>
<Avatar
size = { CALLER_AVATAR_SIZE }
uri = { this.props._callerAvatarURL } />
url = { this.props._callerAvatarURL } />
</View>
</View>
);