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 // Document should be focused for this option to work
// enableAutomaticUrlCopy: false, // 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. // Base URL for a Gravatar-compatible service. Defaults to libravatar.
// gravatarBaseURL: 'https://seccdn.libravatar.org/avatar/', // gravatarBaseURL: 'https://seccdn.libravatar.org/avatar/',

View File

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

View File

@ -4,12 +4,17 @@ import React, { PureComponent } from 'react';
import { getParticipantById } from '../../participants'; import { getParticipantById } from '../../participants';
import { connect } from '../../redux'; import { connect } from '../../redux';
import { getAvatarColor, getInitials } from '../functions'; import { getAvatarColor, getInitials, isCORSAvatarURL } from '../functions';
import { StatelessAvatar } from '.'; import { StatelessAvatar } from '.';
export type Props = { export type Props = {
/**
* The URL patterns for URLs that needs to be handled with CORS.
*/
_corsAvatarURLs: Array<string>,
/** /**
* Custom avatar backgrounds from branding. * Custom avatar backgrounds from branding.
*/ */
@ -25,6 +30,11 @@ export type Props = {
*/ */
_loadableAvatarUrl: ?string, _loadableAvatarUrl: ?string,
/**
* Indicates whether _loadableAvatarUrl should use CORS or not.
*/
_loadableAvatarUrlUseCORS: ?boolean,
/** /**
* A prop to maintain compatibility with web. * A prop to maintain compatibility with web.
*/ */
@ -76,10 +86,16 @@ export type Props = {
* URL of the avatar, if any. * URL of the avatar, if any.
*/ */
url: ?string, url: ?string,
/**
* Indicates whether to load the avatar using CORS or not.
*/
useCORS?: ?boolean
} }
type State = { type State = {
avatarFailed: boolean avatarFailed: boolean,
isUsingCORS: boolean
} }
export const DEFAULT_SIZE = 65; export const DEFAULT_SIZE = 65;
@ -105,8 +121,15 @@ class Avatar<P: Props> extends PureComponent<P, State> {
constructor(props: P) { constructor(props: P) {
super(props); super(props);
const {
_corsAvatarURLs,
url,
useCORS
} = props;
this.state = { this.state = {
avatarFailed: false avatarFailed: false,
isUsingCORS: Boolean(useCORS) || Boolean(url && isCORSAvatarURL(url, _corsAvatarURLs))
}; };
this._onAvatarLoadError = this._onAvatarLoadError.bind(this); this._onAvatarLoadError = this._onAvatarLoadError.bind(this);
@ -118,7 +141,9 @@ class Avatar<P: Props> extends PureComponent<P, State> {
* @inheritdoc * @inheritdoc
*/ */
componentDidUpdate(prevProps: P) { 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. // 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 // 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 // eslint-disable-next-line react/no-did-update-set-state
this.setState({ 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, _customAvatarBackgrounds,
_initialsBase, _initialsBase,
_loadableAvatarUrl, _loadableAvatarUrl,
_loadableAvatarUrlUseCORS,
className, className,
colorBase, colorBase,
dynamicColor, dynamicColor,
@ -150,7 +177,7 @@ class Avatar<P: Props> extends PureComponent<P, State> {
testId, testId,
url url
} = this.props; } = this.props;
const { avatarFailed } = this.state; const { avatarFailed, isUsingCORS } = this.state;
const avatarProps = { const avatarProps = {
className, className,
@ -158,19 +185,26 @@ class Avatar<P: Props> extends PureComponent<P, State> {
id, id,
initials: undefined, initials: undefined,
onAvatarLoadError: undefined, onAvatarLoadError: undefined,
onAvatarLoadErrorParams: undefined,
size, size,
status, status,
testId, testId,
url: undefined url: undefined,
useCORS: isUsingCORS
}; };
// _loadableAvatarUrl is validated that it can be loaded, but uri (if present) is not, so // _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 // we still need to do a check for that. And an explicitly provided URI is higher priority than
// an avatar URL anyhow. // an avatar URL anyhow.
const effectiveURL = (!avatarFailed && url) || _loadableAvatarUrl; const useReduxLoadableAvatarURL = avatarFailed || !url;
const effectiveURL = useReduxLoadableAvatarURL ? _loadableAvatarUrl : url;
if (effectiveURL) { if (effectiveURL) {
avatarProps.onAvatarLoadError = this._onAvatarLoadError; avatarProps.onAvatarLoadError = this._onAvatarLoadError;
if (useReduxLoadableAvatarURL) {
avatarProps.onAvatarLoadErrorParams = { dontRetry: true };
avatarProps.useCORS = _loadableAvatarUrlUseCORS;
}
avatarProps.url = effectiveURL; 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. * 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} * @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({ this.setState({
avatarFailed: true avatarFailed: true
}); });
} }
}
} }
/** /**
@ -215,11 +261,14 @@ export function _mapStateToProps(state: Object, ownProps: Props) {
const { colorBase, displayName, participantId } = ownProps; const { colorBase, displayName, participantId } = ownProps;
const _participant: ?Object = participantId && getParticipantById(state, participantId); const _participant: ?Object = participantId && getParticipantById(state, participantId);
const _initialsBase = _participant?.name ?? displayName; const _initialsBase = _participant?.name ?? displayName;
const { corsAvatarURLs } = state['features/base/config'];
return { return {
_customAvatarBackgrounds: state['features/dynamic-branding'].avatarBackgrounds, _customAvatarBackgrounds: state['features/dynamic-branding'].avatarBackgrounds,
_corsAvatarURLs: corsAvatarURLs,
_initialsBase, _initialsBase,
_loadableAvatarUrl: _participant?.loadableAvatarUrl, _loadableAvatarUrl: _participant?.loadableAvatarUrl,
_loadableAvatarUrlUseCORS: _participant?.loadableAvatarUrlUseCORS,
colorBase colorBase
}; };
} }

View File

@ -29,6 +29,18 @@ type Props = AbstractProps & {
* props. * props.
*/ */
export default class StatelessAvatar extends AbstractStatelessAvatar<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}. * Implements {@code Component#render}.
* *
@ -164,4 +176,22 @@ export default class StatelessAvatar extends AbstractStatelessAvatar<Props> {
style = { styles.avatarContent(size) } /> 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 React from 'react';
import { Icon } from '../../../icons'; import { Icon } from '../../../icons';
import { isGravatarURL } from '../../functions';
import AbstractStatelessAvatar, { type Props as AbstractProps } from '../AbstractStatelessAvatar'; import AbstractStatelessAvatar, { type Props as AbstractProps } from '../AbstractStatelessAvatar';
type Props = AbstractProps & { type Props = AbstractProps & {
@ -31,7 +30,12 @@ type Props = AbstractProps & {
/** /**
* TestId of the element, if any. * 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. * props.
*/ */
export default class StatelessAvatar extends AbstractStatelessAvatar<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}. * Implements {@code Component#render}.
* *
* @inheritdoc * @inheritdoc
*/ */
render() { render() {
const { initials, url } = this.props; const { initials, url, useCORS } = this.props;
if (this._isIcon(url)) { if (this._isIcon(url)) {
return ( return (
@ -67,10 +83,10 @@ export default class StatelessAvatar extends AbstractStatelessAvatar<Props> {
<img <img
alt = 'avatar' alt = 'avatar'
className = { this._getAvatarClassName() } className = { this._getAvatarClassName() }
crossOrigin = { isGravatarURL(url) ? '' : undefined } crossOrigin = { useCORS ? '' : undefined }
data-testid = { this.props.testId } data-testid = { this.props.testId }
id = { this.props.id } id = { this.props.id }
onError = { this.props.onAvatarLoadError } onError = { this._onAvatarLoadError }
src = { url } src = { url }
style = { this._getAvatarStyle() } /> style = { this._getAvatarStyle() } />
</div> </div>
@ -160,4 +176,19 @@ export default class StatelessAvatar extends AbstractStatelessAvatar<Props> {
} }
_isIcon: (?string | ?Object) => boolean; _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 GraphemeSplitter from 'grapheme-splitter';
import _ from 'lodash'; import _ from 'lodash';
import { GRAVATAR_BASE_URL } from './constants';
const AVATAR_COLORS = [ const AVATAR_COLORS = [
'#6A50D3', '#6A50D3',
'#FF9B42', '#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 {string} url - The URL.
* @param {Array<string>} corsURLs - The URL pattern that matches a URL that needs to be handled with CORS.
* @returns {void} * @returns {void}
*/ */
export function isGravatarURL(url: string = '') { export function isCORSAvatarURL(url: string | any = '', corsURLs: Array<string> = []) {
return url.startsWith(GRAVATAR_BASE_URL); 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} participantId - The ID of the participant.
* @param {string} url - The new URL. * @param {string} url - The new URL.
* @param {boolean} useCORS - Indicates whether we need to use CORS for this URL.
* @returns {{ * @returns {{
* type: SET_LOADABLE_AVATAR_URL, * type: SET_LOADABLE_AVATAR_URL,
* participant: { * participant: {
* id: string, * id: string,
* loadableAvatarUrl: string * loadableAvatarUrl: string,
* loadableAvatarUrlUseCORS: boolean
* } * }
* }} * }}
*/ */
export function setLoadableAvatarUrl(participantId, url) { export function setLoadableAvatarUrl(participantId, url, useCORS) {
return { return {
type: SET_LOADABLE_AVATAR_URL, type: SET_LOADABLE_AVATAR_URL,
participant: { participant: {
id: participantId, id: participantId,
loadableAvatarUrl: url loadableAvatarUrl: url,
loadableAvatarUrlUseCORS: useCORS
} }
}; };
} }

View File

@ -3,7 +3,7 @@
import { getGravatarURL } from '@jitsi/js-utils/avatar'; import { getGravatarURL } from '@jitsi/js-utils/avatar';
import type { Store } from 'redux'; 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 { JitsiParticipantConnectionStatus } from '../lib-jitsi-meet';
import { MEDIA_TYPE, shouldRenderVideoTrack } from '../media'; import { MEDIA_TYPE, shouldRenderVideoTrack } from '../media';
import { toState } from '../redux'; import { toState } from '../redux';
@ -56,7 +56,7 @@ export function getFirstLoadableAvatarUrl(participant: Object, store: Store<any,
const deferred = createDeferred(); const deferred = createDeferred();
const fullPromise = deferred.promise const fullPromise = deferred.promise
.then(() => _getFirstLoadableAvatarUrl(participant, store)) .then(() => _getFirstLoadableAvatarUrl(participant, store))
.then(src => { .then(result => {
if (AVATAR_QUEUE.length) { if (AVATAR_QUEUE.length) {
const next = AVATAR_QUEUE.shift(); const next = AVATAR_QUEUE.shift();
@ -64,7 +64,7 @@ export function getFirstLoadableAvatarUrl(participant: Object, store: Store<any,
next.resolve(); next.resolve();
} }
return src; return result;
}); });
if (AVATAR_QUEUE.length) { if (AVATAR_QUEUE.length) {
@ -432,18 +432,33 @@ async function _getFirstLoadableAvatarUrl(participant, store) {
if (url !== null) { if (url !== null) {
if (AVATAR_CHECKED_URLS.has(url)) { if (AVATAR_CHECKED_URLS.has(url)) {
if (AVATAR_CHECKED_URLS.get(url)) { const { isLoadable, isUsingCORS } = AVATAR_CHECKED_URLS.get(url) || {};
return url;
if (isLoadable) {
return {
isUsingCORS,
src: url
};
} }
} else { } else {
try { 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) { } 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); const updatedParticipant = getParticipantById(getState(), participantId);
getFirstLoadableAvatarUrl(updatedParticipant, store) getFirstLoadableAvatarUrl(updatedParticipant, store)
.then(url => { .then(urlData => {
dispatch(setLoadableAvatarUrl(participantId, url)); 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. * @param {string | Object} src - Source of the avatar.
* @returns {Promise} * @returns {Promise}
*/ */
export function preloadImage(src: string | Object): Promise<string> { export function preloadImage(src: string | Object): Promise<Object> {
if (isIconUrl(src)) { if (isIconUrl(src)) {
return Promise.resolve(src); return Promise.resolve(src);
} }
return new Promise((resolve, reject) => { 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 // @flow
import { isGravatarURL } from '../avatar';
import { isIconUrl } from './functions'; import { isIconUrl } from './functions';
/** /**
* Tries to preload an image. * Tries to preload an image.
* *
* @param {string | Object} src - Source of the avatar. * @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} * @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)) { if (isIconUrl(src)) {
return Promise.resolve(src); return Promise.resolve({ src });
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const image = document.createElement('img'); const image = document.createElement('img');
if (isGravatarURL(src)) { if (useCORS) {
image.setAttribute('crossOrigin', ''); image.setAttribute('crossOrigin', '');
} }
image.onload = () => resolve(src); image.onload = () => resolve({
image.onerror = reject; src,
isUsingCORS: useCORS
});
image.onerror = error => {
if (tryOnce) {
reject(error);
} else {
preloadImage(src, !useCORS, true)
.then(resolve)
.catch(reject);
}
};
// $FlowExpectedError // $FlowExpectedError
image.referrerPolicy = 'no-referrer'; image.referrerPolicy = 'no-referrer';

View File

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