feat(Avatar): CORS mode support.

This commit is contained in:
Hristo Terezov 2021-12-16 18:16:24 -06:00
parent 32aa40b396
commit 12bc054386
12 changed files with 208 additions and 49 deletions

View File

@ -543,6 +543,9 @@ var config = {
// Document should be focused for this option to work
// enableAutomaticUrlCopy: false,
// Array with avatar URL prefixes that need to use CORS.
// corsAvatarURLs: [ 'https://www.gravatar.com/avatar/' ],
// Base URL for a Gravatar-compatible service. Defaults to libravatar.
// gravatarBaseURL: 'https://seccdn.libravatar.org/avatar/',

View File

@ -19,6 +19,11 @@ export type Props = {
*/
onAvatarLoadError?: Function,
/**
* Additional parameters to be passed to onAvatarLoadError function.
*/
onAvatarLoadErrorParams?: Object,
/**
* Expected size of the avatar.
*/

View File

@ -4,12 +4,17 @@ import React, { PureComponent } from 'react';
import { getParticipantById } from '../../participants';
import { connect } from '../../redux';
import { getAvatarColor, getInitials } from '../functions';
import { getAvatarColor, getInitials, isCORSAvatarURL } from '../functions';
import { StatelessAvatar } from '.';
export type Props = {
/**
* The URL patterns for URLs that needs to be handled with CORS.
*/
_corsAvatarURLs: Array<string>,
/**
* Custom avatar backgrounds from branding.
*/
@ -25,6 +30,11 @@ export type Props = {
*/
_loadableAvatarUrl: ?string,
/**
* Indicates whether _loadableAvatarUrl should use CORS or not.
*/
_loadableAvatarUrlUseCORS: ?boolean,
/**
* A prop to maintain compatibility with web.
*/
@ -76,10 +86,16 @@ export type Props = {
* URL of the avatar, if any.
*/
url: ?string,
/**
* Indicates whether to load the avatar using CORS or not.
*/
useCORS?: ?boolean
}
type State = {
avatarFailed: boolean
avatarFailed: boolean,
isUsingCORS: boolean
}
export const DEFAULT_SIZE = 65;
@ -105,8 +121,15 @@ class Avatar<P: Props> extends PureComponent<P, State> {
constructor(props: P) {
super(props);
const {
_corsAvatarURLs,
url,
useCORS
} = props;
this.state = {
avatarFailed: false
avatarFailed: false,
isUsingCORS: Boolean(useCORS) || Boolean(url && isCORSAvatarURL(url, _corsAvatarURLs))
};
this._onAvatarLoadError = this._onAvatarLoadError.bind(this);
@ -118,7 +141,9 @@ class Avatar<P: Props> extends PureComponent<P, State> {
* @inheritdoc
*/
componentDidUpdate(prevProps: P) {
if (prevProps.url !== this.props.url) {
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
@ -126,7 +151,8 @@ class Avatar<P: Props> extends PureComponent<P, State> {
// eslint-disable-next-line react/no-did-update-set-state
this.setState({
avatarFailed: false
avatarFailed: false,
isUsingCORS: Boolean(this.props.useCORS) || Boolean(url && isCORSAvatarURL(url, _corsAvatarURLs))
});
}
}
@ -141,6 +167,7 @@ class Avatar<P: Props> extends PureComponent<P, State> {
_customAvatarBackgrounds,
_initialsBase,
_loadableAvatarUrl,
_loadableAvatarUrlUseCORS,
className,
colorBase,
dynamicColor,
@ -150,7 +177,7 @@ class Avatar<P: Props> extends PureComponent<P, State> {
testId,
url
} = this.props;
const { avatarFailed } = this.state;
const { avatarFailed, isUsingCORS } = this.state;
const avatarProps = {
className,
@ -158,19 +185,26 @@ class Avatar<P: Props> extends PureComponent<P, State> {
id,
initials: undefined,
onAvatarLoadError: undefined,
onAvatarLoadErrorParams: undefined,
size,
status,
testId,
url: undefined
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 effectiveURL = (!avatarFailed && url) || _loadableAvatarUrl;
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;
}
@ -195,13 +229,25 @@ class Avatar<P: Props> extends PureComponent<P, State> {
/**
* 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() {
_onAvatarLoadError(params = {}) {
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
});
}
}
}
/**
@ -215,11 +261,14 @@ export function _mapStateToProps(state: Object, ownProps: Props) {
const { colorBase, displayName, participantId } = ownProps;
const _participant: ?Object = participantId && getParticipantById(state, participantId);
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
};
}

View File

@ -29,6 +29,18 @@ type Props = AbstractProps & {
* props.
*/
export default class StatelessAvatar extends AbstractStatelessAvatar<Props> {
/**
* Instantiates a new {@code Component}.
*
* @inheritdoc
*/
constructor(props: Props) {
super(props);
this._onAvatarLoadError = this._onAvatarLoadError.bind(this);
}
/**
* Implements {@code Component#render}.
*
@ -164,4 +176,22 @@ export default class StatelessAvatar extends AbstractStatelessAvatar<Props> {
style = { styles.avatarContent(size) } />
);
}
_onAvatarLoadError: () => void;
/**
* Handles avatar load errors.
*
* @returns {void}
*/
_onAvatarLoadError() {
const { onAvatarLoadError, onAvatarLoadErrorParams = {} } = this.props;
if (onAvatarLoadError) {
onAvatarLoadError({
...onAvatarLoadErrorParams,
dontRetry: true
});
}
}
}

View File

@ -3,7 +3,6 @@
import React from 'react';
import { Icon } from '../../../icons';
import { isGravatarURL } from '../../functions';
import AbstractStatelessAvatar, { type Props as AbstractProps } from '../AbstractStatelessAvatar';
type Props = AbstractProps & {
@ -31,7 +30,12 @@ type Props = AbstractProps & {
/**
* TestId of the element, if any.
*/
testId?: string
testId?: string,
/**
* Indicates whether to load the avatar using CORS or not.
*/
useCORS?: ?boolean
};
/**
@ -39,13 +43,25 @@ type Props = AbstractProps & {
* props.
*/
export default class StatelessAvatar extends AbstractStatelessAvatar<Props> {
/**
* Instantiates a new {@code Component}.
*
* @inheritdoc
*/
constructor(props: Props) {
super(props);
this._onAvatarLoadError = this._onAvatarLoadError.bind(this);
}
/**
* Implements {@code Component#render}.
*
* @inheritdoc
*/
render() {
const { initials, url } = this.props;
const { initials, url, useCORS } = this.props;
if (this._isIcon(url)) {
return (
@ -67,10 +83,10 @@ export default class StatelessAvatar extends AbstractStatelessAvatar<Props> {
<img
alt = 'avatar'
className = { this._getAvatarClassName() }
crossOrigin = { isGravatarURL(url) ? '' : undefined }
crossOrigin = { useCORS ? '' : undefined }
data-testid = { this.props.testId }
id = { this.props.id }
onError = { this.props.onAvatarLoadError }
onError = { this._onAvatarLoadError }
src = { url }
style = { this._getAvatarStyle() } />
</div>
@ -160,4 +176,19 @@ export default class StatelessAvatar extends AbstractStatelessAvatar<Props> {
}
_isIcon: (?string | ?Object) => boolean;
_onAvatarLoadError: () => void;
/**
* Handles avatar load errors.
*
* @returns {void}
*/
_onAvatarLoadError() {
const { onAvatarLoadError, onAvatarLoadErrorParams } = this.props;
if (typeof onAvatarLoadError === 'function') {
onAvatarLoadError(onAvatarLoadErrorParams);
}
}
}

View File

@ -3,8 +3,6 @@
import GraphemeSplitter from 'grapheme-splitter';
import _ from 'lodash';
import { GRAVATAR_BASE_URL } from './constants';
const AVATAR_COLORS = [
'#6A50D3',
'#FF9B42',
@ -74,11 +72,12 @@ export function getInitials(s: ?string) {
}
/**
* Checks if the passed URL is pointing to the gravatar service.
* Checks if the passed URL should be loaded with CORS.
*
* @param {string} url - The URL.
* @param {Array<string>} corsURLs - The URL pattern that matches a URL that needs to be handled with CORS.
* @returns {void}
*/
export function isGravatarURL(url: string = '') {
return url.startsWith(GRAVATAR_BASE_URL);
export function isCORSAvatarURL(url: string | any = '', corsURLs: Array<string> = []) {
return corsURLs.some(pattern => url.startsWith(pattern));
}

View File

@ -540,20 +540,23 @@ export function pinParticipant(id) {
*
* @param {string} participantId - The ID of the participant.
* @param {string} url - The new URL.
* @param {boolean} useCORS - Indicates whether we need to use CORS for this URL.
* @returns {{
* type: SET_LOADABLE_AVATAR_URL,
* participant: {
* id: string,
* loadableAvatarUrl: string
* loadableAvatarUrl: string,
* loadableAvatarUrlUseCORS: boolean
* }
* }}
*/
export function setLoadableAvatarUrl(participantId, url) {
export function setLoadableAvatarUrl(participantId, url, useCORS) {
return {
type: SET_LOADABLE_AVATAR_URL,
participant: {
id: participantId,
loadableAvatarUrl: url
loadableAvatarUrl: url,
loadableAvatarUrlUseCORS: useCORS
}
};
}

View File

@ -3,7 +3,7 @@
import { getGravatarURL } from '@jitsi/js-utils/avatar';
import type { Store } from 'redux';
import { GRAVATAR_BASE_URL } from '../avatar';
import { GRAVATAR_BASE_URL, isCORSAvatarURL } from '../avatar';
import { JitsiParticipantConnectionStatus } from '../lib-jitsi-meet';
import { MEDIA_TYPE, shouldRenderVideoTrack } from '../media';
import { toState } from '../redux';
@ -56,7 +56,7 @@ export function getFirstLoadableAvatarUrl(participant: Object, store: Store<any,
const deferred = createDeferred();
const fullPromise = deferred.promise
.then(() => _getFirstLoadableAvatarUrl(participant, store))
.then(src => {
.then(result => {
if (AVATAR_QUEUE.length) {
const next = AVATAR_QUEUE.shift();
@ -64,7 +64,7 @@ export function getFirstLoadableAvatarUrl(participant: Object, store: Store<any,
next.resolve();
}
return src;
return result;
});
if (AVATAR_QUEUE.length) {
@ -432,18 +432,33 @@ async function _getFirstLoadableAvatarUrl(participant, store) {
if (url !== null) {
if (AVATAR_CHECKED_URLS.has(url)) {
if (AVATAR_CHECKED_URLS.get(url)) {
return url;
const { isLoadable, isUsingCORS } = AVATAR_CHECKED_URLS.get(url) || {};
if (isLoadable) {
return {
isUsingCORS,
src: url
};
}
} else {
try {
const finalUrl = await preloadImage(url);
const { corsAvatarURLs } = store.getState()['features/base/config'];
const { isUsingCORS, src } = await preloadImage(url, isCORSAvatarURL(url, corsAvatarURLs));
AVATAR_CHECKED_URLS.set(finalUrl, true);
AVATAR_CHECKED_URLS.set(src, {
isLoadable: true,
isUsingCORS
});
return finalUrl;
return {
isUsingCORS,
src
};
} catch (e) {
AVATAR_CHECKED_URLS.set(url, false);
AVATAR_CHECKED_URLS.set(url, {
isLoadable: false,
isUsingCORS: false
});
}
}
}

View File

@ -488,8 +488,8 @@ function _participantJoinedOrUpdated(store, next, action) {
const updatedParticipant = getParticipantById(getState(), participantId);
getFirstLoadableAvatarUrl(updatedParticipant, store)
.then(url => {
dispatch(setLoadableAvatarUrl(participantId, url));
.then(urlData => {
dispatch(setLoadableAvatarUrl(participantId, urlData?.src, urlData?.isUsingCORS));
});
}
}

View File

@ -11,12 +11,17 @@ import { isIconUrl } from './functions';
* @param {string | Object} src - Source of the avatar.
* @returns {Promise}
*/
export function preloadImage(src: string | Object): Promise<string> {
export function preloadImage(src: string | Object): Promise<Object> {
if (isIconUrl(src)) {
return Promise.resolve(src);
}
return new Promise((resolve, reject) => {
Image.prefetch(src).then(() => resolve(src), reject);
Image.prefetch(src).then(
() => resolve({
src,
isUsingCORS: false
}),
reject);
});
}

View File

@ -1,29 +1,45 @@
// @flow
import { isGravatarURL } from '../avatar';
import { isIconUrl } from './functions';
/**
* Tries to preload an image.
*
* @param {string | Object} src - Source of the avatar.
* @param {boolean} useCORS - Whether to use CORS or not.
* @param {boolean} tryOnce - If true we try to load the image only using the specified CORS mode. Otherwise both modes
* (CORS and no CORS) will be used to load the image if the first atempt fails.
* @returns {Promise}
*/
export function preloadImage(src: string | Object): Promise<string> {
export function preloadImage(
src: string | Object,
useCORS: ?boolean = false,
tryOnce: ?boolean = false
): Promise<Object> {
if (isIconUrl(src)) {
return Promise.resolve(src);
return Promise.resolve({ src });
}
return new Promise((resolve, reject) => {
const image = document.createElement('img');
if (isGravatarURL(src)) {
if (useCORS) {
image.setAttribute('crossOrigin', '');
}
image.onload = () => resolve(src);
image.onerror = reject;
image.onload = () => resolve({
src,
isUsingCORS: useCORS
});
image.onerror = error => {
if (tryOnce) {
reject(error);
} else {
preloadImage(src, !useCORS, true)
.then(resolve)
.catch(reject);
}
};
// $FlowExpectedError
image.referrerPolicy = 'no-referrer';

View File

@ -193,12 +193,15 @@ function _findLoadableAvatarForKnockingParticipant(store, { id }) {
const { disableThirdPartyRequests } = getState()['features/base/config'];
if (!disableThirdPartyRequests && updatedParticipant && !updatedParticipant.loadableAvatarUrl) {
getFirstLoadableAvatarUrl(updatedParticipant, store).then(loadableAvatarUrl => {
if (loadableAvatarUrl) {
getFirstLoadableAvatarUrl(updatedParticipant, store).then(result => {
if (result) {
const { isUsingCORS, src } = result;
dispatch(
participantIsKnockingOrUpdated({
loadableAvatarUrl,
id
loadableAvatarUrl: src,
id,
isUsingCORS
})
);
}