Browse Source

rn: refactor Avatar to deal with FastImage changes

Updating react-native-fast-image brings a couple of interesting changes:

- onLoad is not called for cached images (reported and ignored upstream)
- load progress not working if component not displayed (on Android)

In order to fix this, a combination of 2 approaches was used:

- onLoadEnd / onError are used to detect if the image is loaded
- off-screen rendering is used on Android to get progress events

While implementing the above, yours truly noticed the complexity was increasing
way too much, so some extra refactoring was also performed:

- componentWillReceiveProps is dropped
- an auxiliary component (AvatarContent) is used for the actual content of the
  Avatar, with the former passing the key prop to the latter

Using the key prop ensures AvatarContent will be recreated if the URI changes,
which is not a bad idea anyway, since the new image needs to be downloaded.
master
Saúl Ibarra Corretgé 6 years ago
parent
commit
0a9333af02
1 changed files with 122 additions and 111 deletions
  1. 122
    111
      react/features/base/participants/components/Avatar.native.js

+ 122
- 111
react/features/base/participants/components/Avatar.native.js View File

@@ -1,7 +1,7 @@
1 1
 // @flow
2 2
 
3
-import React, { Component, Fragment } from 'react';
4
-import { Image, View } from 'react-native';
3
+import React, { Component, Fragment, PureComponent } from 'react';
4
+import { Dimensions, Image, Platform, View } from 'react-native';
5 5
 import FastImage from 'react-native-fast-image';
6 6
 
7 7
 import { ColorPalette } from '../../styles';
@@ -44,21 +44,33 @@ type Props = {
44 44
  * The type of the React {@link Component} state of {@link Avatar}.
45 45
  */
46 46
 type State = {
47
+
48
+    /**
49
+     * Background color for the locally generated avatar.
50
+     */
47 51
     backgroundColor: string,
48
-    source: ?{ uri: string },
49
-    useDefaultAvatar: boolean
50
-};
51 52
 
52
-/**
53
- * Implements an avatar as a React Native/mobile {@link Component}.
54
- */
55
-export default class Avatar extends Component<Props, State> {
56 53
     /**
57
-     * The indicator which determines whether this {@code Avatar} has been
58
-     * unmounted.
54
+     * Error indicator for non-local avatars.
55
+     */
56
+    error: boolean,
57
+
58
+    /**
59
+     * Indicates if the non-local avatar was loaded or not.
60
+     */
61
+    loaded: boolean,
62
+
63
+    /**
64
+     * Source for the non-local avatar.
59 65
      */
60
-    _unmounted: ?boolean;
66
+    source: { uri: ?string }
67
+};
61 68
 
69
+/**
70
+ * Implements a React Native/mobile {@link Component} wich renders the content
71
+ * of an Avatar.
72
+ */
73
+class AvatarContent extends Component<Props, State> {
62 74
     /**
63 75
      * Initializes a new Avatar instance.
64 76
      *
@@ -68,95 +80,46 @@ export default class Avatar extends Component<Props, State> {
68 80
     constructor(props: Props) {
69 81
         super(props);
70 82
 
83
+        // Set the image source. The logic for the character # below is as
84
+        // follows:
85
+        // - Technically, URI is supposed to start with a scheme and scheme
86
+        //   cannot contain the character #.
87
+        // - Technically, the character # in a URI signals the start of the
88
+        //   fragment/hash.
89
+        // - Technically, the fragment/hash does not imply a retrieval
90
+        //   action.
91
+        // - Practically, the fragment/hash does not always mandate a
92
+        //   retrieval action. For example, an HTML anchor with an href that
93
+        //   starts with the character # does not cause a Web browser to
94
+        //   initiate a retrieval action.
95
+        // So I'll use the character # at the start of URI to not initiate
96
+        // an image retrieval action.
97
+        const source = {};
98
+
99
+        if (props.uri && !props.uri.startsWith('#')) {
100
+            source.uri = props.uri;
101
+        }
102
+
103
+        this.state = {
104
+            backgroundColor: this._getBackgroundColor(props),
105
+            error: false,
106
+            loaded: false,
107
+            source
108
+        };
109
+
71 110
         // Bind event handlers so they are only bound once per instance.
72 111
         this._onAvatarLoaded = this._onAvatarLoaded.bind(this);
73
-
74
-        // Fork (in Facebook/React speak) the prop uri because Image will
75
-        // receive it through a source object. Additionally, other props may be
76
-        // forked as well.
77
-        this.componentWillReceiveProps(props);
112
+        this._onAvatarLoadError = this._onAvatarLoadError.bind(this);
78 113
     }
79 114
 
80 115
     /**
81
-     * Notifies this mounted React Component that it will receive new props.
82
-     * Forks (in Facebook/React speak) the prop {@code uri} because
83
-     * {@link Image} will receive it through a {@code source} object.
84
-     * Additionally, other props may be forked as well.
85
-     *
86
-     * @inheritdoc
87
-     * @param {Props} nextProps - The read-only React Component props that this
88
-     * instance will receive.
89
-     * @returns {void}
116
+     * Computes if the default avatar (ie, locally generated) should be used
117
+     * or not.
90 118
      */
91
-    componentWillReceiveProps(nextProps: Props) {
92
-        // uri
93
-        const prevURI = this.props && this.props.uri;
94
-        const nextURI = nextProps && nextProps.uri;
95
-        const assignState = !this.state;
96
-
97
-        if (prevURI !== nextURI || assignState) {
98
-            const nextState = {
99
-                backgroundColor: this._getBackgroundColor(nextProps),
100
-                source: undefined,
101
-                useDefaultAvatar: true
102
-            };
103
-
104
-            if (assignState) {
105
-                // eslint-disable-next-line react/no-direct-mutation-state
106
-                this.state = nextState;
107
-            } else {
108
-                this.setState(nextState);
109
-            }
110
-
111
-            // XXX @lyubomir: My logic for the character # bellow is as follows:
112
-            // - Technically, URI is supposed to start with a scheme and scheme
113
-            //   cannot contain the character #.
114
-            // - Technically, the character # in URI signals the start of the
115
-            //   fragment/hash.
116
-            // - Technically, the fragment/hash does not imply a retrieval
117
-            //   action.
118
-            // - Practically, the fragment/hash does not always mandate a
119
-            //   retrieval action. For example, an HTML anchor with an href that
120
-            //   starts with the character # does not cause a Web browser to
121
-            //   initiate a retrieval action.
122
-            // So I'll use the character # at the start of URI to not initiate
123
-            // an image retrieval action.
124
-            if (nextURI && !nextURI.startsWith('#')) {
125
-                const nextSource = { uri: nextURI };
126
-
127
-                if (assignState) {
128
-                    // eslint-disable-next-line react/no-direct-mutation-state
129
-                    this.state = {
130
-                        ...this.state,
131
-                        source: nextSource
132
-                    };
133
-                } else {
134
-                    this._unmounted || this.setState((prevState, props) => {
135
-                        if (props.uri === nextURI
136
-                                && (!prevState.source
137
-                                    || prevState.source.uri !== nextURI)) {
138
-                            return { source: nextSource };
139
-                        }
140
-
141
-                        return {};
142
-                    });
143
-                }
144
-            }
145
-        }
146
-    }
119
+    get useDefaultAvatar() {
120
+        const { error, loaded, source } = this.state;
147 121
 
148
-    /**
149
-     * Notifies this {@code Component} that it will be unmounted and destroyed,
150
-     * and most importantly, that it should no longer call
151
-     * {@link #setState(Object)}. The {@code Avatar} needs it because it
152
-     * downloads images via {@link ImageCache} which will asynchronously notify
153
-     * about success.
154
-     *
155
-     * @inheritdoc
156
-     * @returns {void}
157
-     */
158
-    componentWillUnmount() {
159
-        this._unmounted = true;
122
+        return !source.uri || error || !loaded;
160 123
     }
161 124
 
162 125
     /**
@@ -208,14 +171,26 @@ export default class Avatar extends Component<Props, State> {
208 171
     _onAvatarLoaded: () => void;
209 172
 
210 173
     /**
211
-     * Handler called when the remote image was loaded. When this happens we
212
-     * show that instead of the default locally generated one.
174
+     * Handler called when the remote image loading finishes. This doesn't
175
+     * necessarily mean the load was successful.
213 176
      *
214 177
      * @private
215 178
      * @returns {void}
216 179
      */
217 180
     _onAvatarLoaded() {
218
-        this._unmounted || this.setState({ useDefaultAvatar: false });
181
+        this.setState({ loaded: true });
182
+    }
183
+
184
+    _onAvatarLoadError: () => void;
185
+
186
+    /**
187
+     * Handler called when the remote image loading failed.
188
+     *
189
+     * @private
190
+     * @returns {void}
191
+     */
192
+    _onAvatarLoadError() {
193
+        this.setState({ error: true });
219 194
     }
220 195
 
221 196
     /**
@@ -229,15 +204,14 @@ export default class Avatar extends Component<Props, State> {
229 204
         // regular Image, so we need to wrap it in a view to make it round.
230 205
         // https://github.com/facebook/react-native/issues/3198
231 206
 
232
-        const { backgroundColor, useDefaultAvatar } = this.state;
207
+        const { backgroundColor } = this.state;
233 208
         const imageStyle = this._getImageStyle();
234 209
         const viewStyle = {
235 210
             ...imageStyle,
236 211
 
237 212
             backgroundColor,
238
-            display: useDefaultAvatar ? 'flex' : 'none',
239 213
 
240
-            // FIXME @lyubomir: Without the opacity bellow I feel like the
214
+            // FIXME @lyubomir: Without the opacity below I feel like the
241 215
             // avatar colors are too strong. Besides, we use opacity for the
242 216
             // ToolbarButtons. That's where I copied the value from and we
243 217
             // may want to think about "standardizing" the opacity in the
@@ -268,18 +242,32 @@ export default class Avatar extends Component<Props, State> {
268 242
      * @returns {ReactElement}
269 243
      */
270 244
     _renderAvatar() {
271
-        const { source, useDefaultAvatar } = this.state;
272
-        const style = {
273
-            ...this._getImageStyle(),
274
-            display: useDefaultAvatar ? 'none' : 'flex'
275
-        };
245
+        const { source } = this.state;
246
+        let extraStyle;
247
+
248
+        if (this.useDefaultAvatar) {
249
+            // On Android, the image loading indicators don't work unless the
250
+            // Glide image is actually created, so we cannot use display: none.
251
+            // Instead, render it off-screen, which does the trick.
252
+            if (Platform.OS === 'android') {
253
+                const windowDimensions = Dimensions.get('window');
254
+
255
+                extraStyle = {
256
+                    bottom: -windowDimensions.height,
257
+                    right: -windowDimensions.width
258
+                };
259
+            } else {
260
+                extraStyle = { display: 'none' };
261
+            }
262
+        }
276 263
 
277
-        return (
264
+        return (// $FlowFixMe
278 265
             <FastImage
279
-                onLoad = { this._onAvatarLoaded }
266
+                onError = { this._onAvatarLoadError }
267
+                onLoadEnd = { this._onAvatarLoaded }
280 268
                 resizeMode = 'contain'
281 269
                 source = { source }
282
-                style = { style } />
270
+                style = { [ this._getImageStyle(), extraStyle ] } />
283 271
         );
284 272
     }
285 273
 
@@ -289,13 +277,36 @@ export default class Avatar extends Component<Props, State> {
289 277
      * @inheritdoc
290 278
      */
291 279
     render() {
292
-        const { source, useDefaultAvatar } = this.state;
280
+        const { source } = this.state;
293 281
 
294 282
         return (
295 283
             <Fragment>
296
-                { source && this._renderAvatar() }
297
-                { useDefaultAvatar && this._renderDefaultAvatar() }
284
+                { source.uri && this._renderAvatar() }
285
+                { this.useDefaultAvatar && this._renderDefaultAvatar() }
298 286
             </Fragment>
299 287
         );
300 288
     }
301 289
 }
290
+
291
+/* eslint-disable react/no-multi-comp */
292
+
293
+/**
294
+ * Implements an avatar as a React Native/mobile {@link Component}.
295
+ *
296
+ * Note: we use `key` in order to trigger a new component creation in case
297
+ * the URI changes.
298
+ */
299
+export default class Avatar extends PureComponent<Props> {
300
+    /**
301
+     * Implements React's {@link Component#render()}.
302
+     *
303
+     * @inheritdoc
304
+     */
305
+    render() {
306
+        return (
307
+            <AvatarContent
308
+                key = { this.props.uri }
309
+                { ...this.props } />
310
+        );
311
+    }
312
+}

Loading…
Cancel
Save