Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Image][Performance] Fast Image with Auth Token Header Caching #12648

Merged
merged 18 commits into from
Nov 23, 2022
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions ios/NewExpensify.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -766,7 +766,7 @@
COPY_PHASE_STRIP = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
Expand All @@ -791,6 +791,7 @@
);
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
};
name = Debug;
Expand Down Expand Up @@ -827,7 +828,7 @@
COPY_PHASE_STRIP = YES;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386;
thomas-coldwell marked this conversation as resolved.
Show resolved Hide resolved
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
Expand All @@ -844,6 +845,7 @@
"\"$(inherited)\"",
);
MTL_ENABLE_DEBUG_INFO = NO;
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
thomas-coldwell marked this conversation as resolved.
Show resolved Hide resolved
SDKROOT = iphoneos;
VALIDATE_PRODUCT = YES;
};
Expand Down
10 changes: 10 additions & 0 deletions ios/NewExpensify.xcworkspace/contents.xcworkspacedata

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/CONST.js
Original file line number Diff line number Diff line change
Expand Up @@ -856,6 +856,8 @@ const CONST = {
},

TFA_CODE_LENGTH: 6,

CHAT_ATTACHMENT_TOKEN_KEY: 'X-Chat-Attachment-Token',
};

export default CONST;
5 changes: 3 additions & 2 deletions src/components/Avatar.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, {PureComponent} from 'react';
import {Image, View} from 'react-native';
import {View} from 'react-native';
import PropTypes from 'prop-types';
import _ from 'underscore';
import stylePropTypes from '../styles/stylePropTypes';
Expand All @@ -9,6 +9,7 @@ import CONST from '../CONST';
import * as StyleUtils from '../styles/StyleUtils';
import * as Expensicons from './Icon/Expensicons';
import getAvatarDefaultSource from '../libs/getAvatarDefaultSource';
import FastImage from './FastImage';

const propTypes = {
/** Source for the avatar. Can be a URL or an icon. */
Expand Down Expand Up @@ -72,7 +73,7 @@ class Avatar extends PureComponent {
/>
)
: (
<Image
<FastImage
source={{uri: this.props.source}}
defaultSource={getAvatarDefaultSource(this.props.source)}
style={imageStyle}
Expand Down
29 changes: 29 additions & 0 deletions src/components/FastImage/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import React from 'react';
import {Image} from 'react-native';
import addEncryptedAuthTokenToURL from '../../libs/addEncryptedAuthTokenToURL';

const RESIZE_MODES = {
contain: 'contain',
cover: 'cover',
stretch: 'stretch',
center: 'center',
};

const FastImage = (props) => {
// eslint-disable-next-line
const {source, ...rest} = props;

// Check for headers - if it has them then we need to instead just add the
// encryptedAuthToken to the url and RNW's Image component headers do not work (or do they just not cache?)
if (typeof source === 'number' || props.source.headers == null) {
// eslint-disable-next-line
return <Image source={props.source} {...rest} />;
}
// eslint-disable-next-line
return <Image source={{uri: addEncryptedAuthTokenToURL(props.source.uri)}} {...rest} />;
};

FastImage.displayName = 'FastImage';
FastImage.propTypes = Image.propTypes;
FastImage.resizeMode = RESIZE_MODES;
export default FastImage;
9 changes: 9 additions & 0 deletions src/components/FastImage/index.native.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import RNFastImage from '@pieter-pot/react-native-fast-image';

// eslint-disable-next-line
const FastImage = (props) => <RNFastImage {...props} />;

FastImage.displayName = 'FastImage';
FastImage.propTypes = RNFastImage.propTypes;
FastImage.resizeMode = RNFastImage.resizeMode;
export default FastImage;
22 changes: 16 additions & 6 deletions src/components/ImageView/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@ import PropTypes from 'prop-types';
import {
View, Image, Pressable,
} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import FastImage from '../FastImage';
thomas-coldwell marked this conversation as resolved.
Show resolved Hide resolved
import styles from '../../styles/styles';
import * as StyleUtils from '../../styles/StyleUtils';
import canUseTouchScreen from '../../libs/canUseTouchscreen';
import withWindowDimensions, {windowDimensionsPropTypes} from '../withWindowDimensions';
import FullscreenLoadingIndicator from '../FullscreenLoadingIndicator';
import compose from '../../libs/compose';
import ONYXKEYS from '../../ONYXKEYS';
import chatAttachmentTokenHeaders from '../../libs/chatAttachmentTokenHeaders';

const propTypes = {
/** URL to full-sized image */
Expand Down Expand Up @@ -222,16 +227,19 @@ class ImageView extends PureComponent {
style={[styles.imageViewContainer, styles.overflowHidden]}
onLayout={this.onContainerLayoutChanged}
>
<Image
source={{uri: this.props.url}}
<FastImage
source={{
uri: this.props.url,
headers: chatAttachmentTokenHeaders(),
}}
style={this.state.zoomScale === 0 ? undefined : [
styles.w100,
styles.h100,
]} // Hide image until zoomScale calculated to prevent showing preview with wrong dimensions.

// When Image dimensions are lower than the container boundary(zoomscale <= 1), use `contain` to render the image with natural dimensions.
// Both `center` and `contain` keeps the image centered on both x and y axis.
resizeMode={this.state.zoomScale > 1 ? 'center' : 'contain'}
resizeMode={this.state.zoomScale > 1 ? FastImage.resizeMode.center : FastImage.resizeMode.contain}
onLoadStart={this.imageLoadingStart}
onLoadEnd={this.imageLoadingEnd}
/>
Expand Down Expand Up @@ -265,13 +273,13 @@ class ImageView extends PureComponent {
onPressIn={this.onContainerPressIn}
onPress={this.onContainerPress}
>
<Image
<FastImage
source={{uri: this.props.url}}
style={this.state.zoomScale === 0 ? undefined : [
styles.h100,
styles.w100,
]} // Hide image until zoomScale calculated to prevent showing preview with wrong dimensions.
resizeMode="contain"
resizeMode={FastImage.resizeMode.contain}
onLoadStart={this.imageLoadingStart}
onLoadEnd={this.imageLoadingEnd}
/>
Expand All @@ -288,4 +296,6 @@ class ImageView extends PureComponent {
}

ImageView.propTypes = propTypes;
export default withWindowDimensions(ImageView);
export default compose(withWindowDimensions, withOnyx({
session: {key: ONYXKEYS.SESSION},
}))(ImageView);
102 changes: 45 additions & 57 deletions src/components/ImageWithSizeCalculation.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import React, {PureComponent} from 'react';
import {View, Image} from 'react-native';
import {View} from 'react-native';
import PropTypes from 'prop-types';
import {withOnyx} from 'react-native-onyx';
import lodashGet from 'lodash/get';
import Log from '../libs/Log';
import styles from '../styles/styles';
import makeCancellablePromise from '../libs/MakeCancellablePromise';
import FullscreenLoadingIndicator from './FullscreenLoadingIndicator';
import ONYXKEYS from '../ONYXKEYS';
import chatAttachmentTokenHeaders from '../libs/chatAttachmentTokenHeaders';
import FastImage from './FastImage';

const propTypes = {
/** Url for image to display */
Expand All @@ -16,11 +20,25 @@ const propTypes = {

/** Callback fired when the image has been measured. */
onMeasure: PropTypes.func,

/** Does the image require an authToken? */
isAuthTokenRequired: PropTypes.bool,

/* Onyx props */
/** Session object */
session: PropTypes.shape({
/** An error message to display to the user */
encryptedAuthToken: PropTypes.string,
}),
Beamanator marked this conversation as resolved.
Show resolved Hide resolved
};

const defaultProps = {
style: {},
onMeasure: () => {},
isAuthTokenRequired: false,
session: {
encryptedAuthToken: false,
},
};

/**
Expand All @@ -39,71 +57,33 @@ class ImageWithSizeCalculation extends PureComponent {

this.imageLoadingStart = this.imageLoadingStart.bind(this);
this.imageLoadingEnd = this.imageLoadingEnd.bind(this);
this.onError = this.onError.bind(this);
this.imageLoadedSuccessfuly = this.imageLoadedSuccessfuly.bind(this);
}

componentDidMount() {
this.calculateImageSize();
onError() {
Log.hmmm('Unable to fetch image to calculate size', {url: this.props.url});
}

componentDidUpdate(prevProps) {
if (prevProps.url === this.props.url) {
return;
}

this.calculateImageSize();
}

componentWillUnmount() {
if (!this.getImageSizePromise) {
return;
}

this.getImageSizePromise.cancel();
imageLoadingStart() {
this.setState({isLoading: true});
}

/**
* @param {String} url
* @returns {Promise}
*/
getImageSize(url) {
return new Promise((resolve, reject) => {
Image.getSize(url, (width, height) => {
resolve({width, height});
}, (error) => {
reject(error);
});
});
imageLoadingEnd() {
this.setState({isLoading: false});
}

calculateImageSize() {
if (!this.props.url) {
imageLoadedSuccessfuly(event) {
if (!lodashGet(event, 'nativeEvent.width', false) || !lodashGet(event, 'nativeEvent.height', false)) {
// Image didn't load properly
return;
}

this.getImageSizePromise = makeCancellablePromise(this.getImageSize(this.props.url));
this.getImageSizePromise.promise
.then(({width, height}) => {
if (!width || !height) {
// Image didn't load properly
return;
}

this.props.onMeasure({width, height});
})
.catch((error) => {
Log.hmmm('Unable to fetch image to calculate size', {error, url: this.props.url});
});
}

imageLoadingStart() {
this.setState({isLoading: true});
}

imageLoadingEnd() {
this.setState({isLoading: false});
this.props.onMeasure({width: event.nativeEvent.width, height: event.nativeEvent.height});
}

render() {
const headers = this.props.isAuthTokenRequired ? chatAttachmentTokenHeaders() : undefined;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO it might be better to forward isAuthTokenRequired to the Image and let the Image run this logic instead of having it repeat for any subtype of Image we have

return (
<View
style={[
Expand All @@ -112,15 +92,21 @@ class ImageWithSizeCalculation extends PureComponent {
this.props.style,
]}
>
<Image
<FastImage
style={[
styles.w100,
styles.h100,
]}
source={{uri: this.props.url}}
resizeMode="contain"
fallback
thomas-coldwell marked this conversation as resolved.
Show resolved Hide resolved
source={{
uri: this.props.url,
headers,
}}
resizeMode={FastImage.resizeMode.contain}
onLoadStart={this.imageLoadingStart}
onLoadEnd={this.imageLoadingEnd}
onError={this.onError}
onLoad={this.imageLoadedSuccessfuly}
/>
{this.state.isLoading && (
<FullscreenLoadingIndicator
Expand All @@ -134,4 +120,6 @@ class ImageWithSizeCalculation extends PureComponent {

ImageWithSizeCalculation.propTypes = propTypes;
ImageWithSizeCalculation.defaultProps = defaultProps;
export default ImageWithSizeCalculation;
export default withOnyx({
session: {key: ONYXKEYS.SESSION},
})(ImageWithSizeCalculation);
Comment on lines +122 to +124
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As pointed out in another comment - this is unused

8 changes: 3 additions & 5 deletions src/components/ThumbnailImage.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import React, {PureComponent} from 'react';
import {View} from 'react-native';
import PropTypes from 'prop-types';
import ImageWithSizeCalculation from './ImageWithSizeCalculation';
import addEncryptedAuthTokenToURL from '../libs/addEncryptedAuthTokenToURL';
import styles from '../styles/styles';
import * as StyleUtils from '../styles/StyleUtils';
import withWindowDimensions, {windowDimensionsPropTypes} from './withWindowDimensions';
Expand All @@ -16,7 +15,7 @@ const propTypes = {
// eslint-disable-next-line react/forbid-prop-types
style: PropTypes.any,

/** Do the urls require an authToken? */
/** Does the image require an authToken? */
isAuthTokenRequired: PropTypes.bool.isRequired,

/** Width of the thumbnail image */
Expand Down Expand Up @@ -87,9 +86,7 @@ class ThumbnailImage extends PureComponent {
}

render() {
const url = this.props.isAuthTokenRequired
? addEncryptedAuthTokenToURL(this.props.previewSourceURL)
: this.props.previewSourceURL;
const url = this.props.previewSourceURL;

return (
<View style={[this.props.style, styles.overflowHidden]}>
Expand All @@ -103,6 +100,7 @@ class ThumbnailImage extends PureComponent {
<ImageWithSizeCalculation
url={url}
onMeasure={this.updateImageSize}
isAuthTokenRequired={this.props.isAuthTokenRequired}
/>
</View>
</View>
Expand Down
Loading