From 122ebe48c73090e322cca0704480aaefe4b0a961 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sa=C3=BAl=20Ibarra=20Corretg=C3=A9?= Date: Fri, 21 Jul 2017 16:41:01 +0200 Subject: [PATCH] [RN] Cache avatars and provide a default in case load fails Avatars are cached to the filesystem and loaded from there when requested again. The cache is cleaned after a conference ends and on application startup (defensive move). In addition, implement a fully local avatar system, which is used as a fallback when loading a remote avatar fails. It can also be forced using a prop. The fully local avatars use a user icon as a mask and apply a background color qhich is picked by hashing the URI passed to the avatar. If no URI is passed a random color is chosen. A grace period of 1 second is also implemented so a default local avatar will be rendered if an Avatar component is mounted but has no URI. If a URI is specified later on, it will be loaded and displayed. In case loading the remote avatar fails, the locally generated one will be used. --- android/sdk/build.gradle | 1 + .../org/jitsi/meet/sdk/JitsiMeetView.java | 4 +- android/settings.gradle | 2 + ios/Podfile | 8 +- package.json | 2 + react/features/app/components/App.native.js | 1 + .../participants/components/Avatar.native.js | 25 +-- .../components/AvatarImage.native.js | 200 ++++++++++++++++++ .../participants/components/defaultAvatar.png | Bin 0 -> 5443 bytes react/features/mobile/image-cache/index.js | 1 + .../features/mobile/image-cache/middleware.js | 28 +++ 11 files changed, 253 insertions(+), 19 deletions(-) create mode 100644 react/features/base/participants/components/AvatarImage.native.js create mode 100644 react/features/base/participants/components/defaultAvatar.png create mode 100644 react/features/mobile/image-cache/index.js create mode 100644 react/features/mobile/image-cache/middleware.js diff --git a/android/sdk/build.gradle b/android/sdk/build.gradle index 050a044d1..02b3efd82 100644 --- a/android/sdk/build.gradle +++ b/android/sdk/build.gradle @@ -26,6 +26,7 @@ dependencies { compile 'com.facebook.react:react-native:+' compile project(':react-native-background-timer') + compile project(':react-native-fetch-blob') compile project(':react-native-immersive') compile project(':react-native-keep-awake') compile project(':react-native-vector-icons') diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetView.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetView.java index 7fbfc0892..0939f4e35 100644 --- a/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetView.java +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetView.java @@ -84,10 +84,10 @@ public class JitsiMeetView extends FrameLayout { .addPackage(new com.oblador.vectoricons.VectorIconsPackage()) .addPackage(new com.ocetnik.timer.BackgroundTimerPackage()) .addPackage(new com.oney.WebRTCModule.WebRTCModulePackage()) + .addPackage(new com.RNFetchBlob.RNFetchBlobPackage()) .addPackage(new com.rnimmersive.RNImmersivePackage()) .addPackage(new org.jitsi.meet.sdk.audiomode.AudioModePackage()) - .addPackage( - new org.jitsi.meet.sdk.externalapi.ExternalAPIPackage()) + .addPackage(new org.jitsi.meet.sdk.externalapi.ExternalAPIPackage()) .addPackage(new org.jitsi.meet.sdk.proximity.ProximityPackage()) .setUseDeveloperSupport(BuildConfig.DEBUG) .setInitialLifecycleState(LifecycleState.RESUMED) diff --git a/android/settings.gradle b/android/settings.gradle index b062a0e0e..2684040b5 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -3,6 +3,8 @@ rootProject.name = 'jitsi-meet' include ':app', ':sdk' include ':react-native-background-timer' project(':react-native-background-timer').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-background-timer/android') +include ':react-native-fetch-blob' +project(':react-native-fetch-blob').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-fetch-blob/android') include ':react-native-immersive' project(':react-native-immersive').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-immersive/android') include ':react-native-keep-awake' diff --git a/ios/Podfile b/ios/Podfile index eea5b633c..ed8f1f3dc 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -17,8 +17,12 @@ target 'JitsiMeet' do ] pod 'Yoga', :path => '../node_modules/react-native/ReactCommon/yoga' - pod 'react-native-background-timer', :path => '../node_modules/react-native-background-timer' - pod 'react-native-keep-awake', :path => '../node_modules/react-native-keep-awake' + pod 'react-native-background-timer', + :path => '../node_modules/react-native-background-timer' + pod 'react-native-fetch-blob', + :path => '../node_modules/react-native-fetch-blob' + pod 'react-native-keep-awake', + :path => '../node_modules/react-native-keep-awake' pod 'react-native-webrtc', :path => '../node_modules/react-native-webrtc' pod 'RNVectorIcons', :path => '../node_modules/react-native-vector-icons' end diff --git a/package.json b/package.json index eb3645aa0..7afe38bc1 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,8 @@ "react-i18next": "4.7.0", "react-native": "0.42.3", "react-native-background-timer": "1.1.0", + "react-native-fetch-blob": "0.10.6", + "react-native-img-cache": "1.4.0", "react-native-immersive": "0.0.5", "react-native-keep-awake": "2.0.4", "react-native-locale-detector": "1.0.1", diff --git a/react/features/app/components/App.native.js b/react/features/app/components/App.native.js index 3b2e9bc0b..dc5ec5ef5 100644 --- a/react/features/app/components/App.native.js +++ b/react/features/app/components/App.native.js @@ -8,6 +8,7 @@ import '../../mobile/audio-mode'; import '../../mobile/background'; import '../../mobile/external-api'; import '../../mobile/full-screen'; +import '../../mobile/image-cache'; import '../../mobile/proximity'; import '../../mobile/wake-lock'; diff --git a/react/features/base/participants/components/Avatar.native.js b/react/features/base/participants/components/Avatar.native.js index 3c10b9c54..b1861c7f6 100644 --- a/react/features/base/participants/components/Avatar.native.js +++ b/react/features/base/participants/components/Avatar.native.js @@ -1,5 +1,8 @@ import React, { Component } from 'react'; -import { Image } from 'react-native'; +import { CustomCachedImage } from 'react-native-img-cache'; + +import AvatarImage from './AvatarImage'; + /** * Implements an avatar as a React Native/mobile {@link Component}. @@ -52,21 +55,16 @@ export default class Avatar extends Component { * @returns {void} */ componentWillReceiveProps(nextProps) { - // uri const prevURI = this.props && this.props.uri; const nextURI = nextProps && nextProps.uri; - let nextState; if (prevURI !== nextURI || !this.state) { - nextState = { - ...nextState, - + const nextState = { /** * The source of the {@link Image} which is the actual - * representation of this {@link Avatar}. As {@code Avatar} - * accepts a single URI and {@code Image} deals with a set of - * possibly multiple URIs, the state {@code source} was - * explicitly introduced in order to reduce unnecessary renders. + * representation of this {@link Avatar}. The state + * {@code source} was explicitly introduced in order to reduce + * unnecessary renders. * * @type {{ * uri: string @@ -76,9 +74,7 @@ export default class Avatar extends Component { uri: nextURI } }; - } - if (nextState) { if (this.state) { this.setState(nextState); } else { @@ -100,10 +96,9 @@ export default class Avatar extends Component { const { uri, ...props } = this.props; return ( - ); diff --git a/react/features/base/participants/components/AvatarImage.native.js b/react/features/base/participants/components/AvatarImage.native.js new file mode 100644 index 000000000..f9193b5f2 --- /dev/null +++ b/react/features/base/participants/components/AvatarImage.native.js @@ -0,0 +1,200 @@ +import React, { Component } from 'react'; +import { Image, View } from 'react-native'; + +import { Platform } from '../../react'; + + +/** + * The default avatar to be used, in case the requested URI is not available + * or fails to be loaded. + * + * This is an inline version of images/avatar2.png. + * + * @type {string} + */ +const DEFAULT_AVATAR = require('./defaultAvatar.png'); + +/** + * The amount of time to wait when the avatar URI is undefined before we start + * showing a default locally generated one. Note that since we have no URI, we + * have nothing we can cache, so the color will be random. + * + * @type {number} + */ +const UNDEFINED_AVATAR_TIMEOUT = 1000; + + +/** + * Implements an Image component wrapper, which returns a default image if the + * requested one fails to load. The default image background is chosen by + * hashing the URL of the image. + */ +export default class AvatarImage extends Component { + /** + * AvatarImage component's property types. + * + * @static + */ + static propTypes = { + /** + * If set to true it will not load the URL, but will use the + * default instead. + */ + forceDefault: React.PropTypes.bool, + + /** + * The source the {@link Image}. + */ + source: React.PropTypes.object, + + /** + * The optional style to add to the {@link Image} in order to customize + * its base look (and feel). + */ + style: React.PropTypes.object + }; + + /** + * Initializes new AvatarImage component. + * + * @param {Object} props - Component props. + */ + constructor(props) { + super(props); + + this.state = { + failed: false, + showDefault: false + }; + + this.componentWillReceiveProps(props); + + this._onError = this._onError.bind(this); + } + + /** + * Notifies this mounted React Component that it will receive new props. + * If the URI is undefined, wait {@code UNDEFINED_AVATAR_TIMEOUT} ms and + * start showing a default locally generated avatar afterwards. + * + * Once a URI is passed, it will be rendered instead, except if loading it + * fails, in which case we fallback to a locally generated avatar again. + * + * @inheritdoc + * @param {Object} nextProps - The read-only React Component props that this + * instance will receive. + * @returns {void} + */ + componentWillReceiveProps(nextProps) { + const prevURI = this.props.source && this.props.source.uri; + const nextURI = nextProps.source && nextProps.source.uri; + + if (typeof prevURI === 'undefined') { + clearTimeout(this._timeout); + if (typeof nextURI === 'undefined') { + this._timeout = setTimeout(() => { + this.setState({ showDefault: true }); + }, UNDEFINED_AVATAR_TIMEOUT); + } else { + this.setState({ showDefault: nextProps.forceDefault }); + } + } + } + + /** + * Clear the timer just in case. See {@code componentWillReceiveProps} for + * details. + * + * @inheritdoc + */ + componentWillUnmount() { + clearTimeout(this._timeout); + } + + /** + * Computes a hash over the URI and returns a HSL background color. We use + * 75% as lightness, for nice pastel style colors. + * + * @returns {string} - The HSL CSS property. + * @private + */ + _getBackgroundColor() { + const uri = this.props.source.uri; + let hash = 0; + + // If we have no URI yet we have no data to hash from, so use a random + // value. + if (typeof uri === 'undefined') { + hash = Math.floor(Math.random() * 360); + } else { + /* eslint-disable no-bitwise */ + + for (let i = 0; i < uri.length; i++) { + hash = uri.charCodeAt(i) + ((hash << 5) - hash); + hash |= 0; // Convert to 32bit integer + } + + /* eslint-enable no-bitwise */ + } + + return `hsl(${hash % 360}, 100%, 75%)`; + } + + /** + * Error handler for image loading. When an image fails to load we'll mark + * it as failed and load the default URI instead. + * + * @private + * @returns {void} + */ + _onError() { + this.setState({ failed: true }); + } + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + */ + render() { + // eslint-disable-next-line no-unused-vars + const { forceDefault, source, style, ...props } = this.props; + const { failed, showDefault } = this.state; + + if (failed || showDefault) { + const coloredBackground = { + ...style, + backgroundColor: this._getBackgroundColor(), + overflow: 'hidden' + }; + + let element = React.createElement(Image, { + ...props, + source: DEFAULT_AVATAR, + style: Platform.OS === 'android' ? style : coloredBackground + }); + + if (Platform.OS === 'android') { + // Here we need to wrap the Image in a View because of a bug in + // React Native for Android: + // https://github.com/facebook/react-native/issues/3198 + + element = React.createElement(View, + { style: coloredBackground }, element); + } + + return element; + } else if (typeof source.uri === 'undefined') { + return null; + } + + // We have a URI and it's time to render it. + return ( + + ); + } +} diff --git a/react/features/base/participants/components/defaultAvatar.png b/react/features/base/participants/components/defaultAvatar.png new file mode 100644 index 0000000000000000000000000000000000000000..b5b04608a9c70cd936c123024ac9543d3b4b9bcb GIT binary patch literal 5443 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 next => action => { + switch (action.type) { + case APP_WILL_MOUNT: + case CONFERENCE_FAILED: + case CONFERENCE_LEFT: + ImageCache.get().clear(); + break; + + } + + return next(action); +});