feat(Avatar): CORS mode support.
This commit is contained in:
parent
32aa40b396
commit
12bc054386
|
@ -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/',
|
||||
|
||||
|
|
|
@ -19,6 +19,11 @@ export type Props = {
|
|||
*/
|
||||
onAvatarLoadError?: Function,
|
||||
|
||||
/**
|
||||
* Additional parameters to be passed to onAvatarLoadError function.
|
||||
*/
|
||||
onAvatarLoadErrorParams?: Object,
|
||||
|
||||
/**
|
||||
* Expected size of the avatar.
|
||||
*/
|
||||
|
|
|
@ -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,14 +229,26 @@ 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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps part of the Redux state to the props of this component.
|
||||
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue