diff --git a/src/CONST.js b/src/CONST.js index fb0bd7ba6a2..135314377a1 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -856,6 +856,8 @@ const CONST = { }, TFA_CODE_LENGTH: 6, + + CHAT_ATTACHMENT_TOKEN_KEY: 'X-Chat-Attachment-Token', }; export default CONST; diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js index 7895b119b72..05f818f337a 100755 --- a/src/components/AttachmentModal.js +++ b/src/components/AttachmentModal.js @@ -115,7 +115,7 @@ class AttachmentModal extends PureComponent { * @param {String} sourceURL */ downloadAttachment(sourceURL) { - fileDownload(sourceURL, this.props.originalFileName); + fileDownload(this.props.isAuthTokenRequired ? addEncryptedAuthTokenToURL(sourceURL) : sourceURL, this.props.originalFileName); // At ios, if the keyboard is open while opening the attachment, then after downloading // the attachment keyboard will show up. So, to fix it we need to dismiss the keyboard. @@ -229,9 +229,7 @@ class AttachmentModal extends PureComponent { } render() { - const sourceURL = this.props.isAuthTokenRequired - ? addEncryptedAuthTokenToURL(this.state.sourceURL) - : this.state.sourceURL; + const sourceURL = this.state.sourceURL; const {fileName, fileExtension} = FileUtils.splitExtensionFromFileName(this.props.originalFileName || lodashGet(this.state, 'file.name', '')); @@ -266,6 +264,7 @@ class AttachmentModal extends PureComponent { {this.state.sourceURL && ( { // will appear with a sourceURL that is a blob if (Str.isPDF(props.sourceURL) || (props.file && Str.isPDF(props.file.name || props.translate('attachmentView.unknownFilename')))) { + const sourceURL = props.isAuthTokenRequired + ? addEncryptedAuthTokenToURL(props.sourceURL) + : props.sourceURL; return ( @@ -61,7 +70,7 @@ const AttachmentView = (props) => { // both PDFs and images will appear as images when pasted into the the text field if (Str.isImage(props.sourceURL) || (props.file && Str.isImage(props.file.name))) { return ( - + ); } diff --git a/src/components/Avatar.js b/src/components/Avatar.js index aa4f6dbf92f..d0a6c648acf 100644 --- a/src/components/Avatar.js +++ b/src/components/Avatar.js @@ -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'; @@ -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. */ @@ -72,7 +73,7 @@ class Avatar extends PureComponent { /> ) : ( - { + this.props.onLoad({nativeEvent: {width, height}}); + }); + } + + render() { + // eslint-disable-next-line + const { source, onLoad, ...rest } = this.props; + + // eslint-disable-next-line + return ; + } +} + +FastImage.propTypes = Image.propTypes; +FastImage.resizeMode = RESIZE_MODES; +export default FastImage; diff --git a/src/components/FastImage/index.native.js b/src/components/FastImage/index.native.js new file mode 100644 index 00000000000..bb9501df53b --- /dev/null +++ b/src/components/FastImage/index.native.js @@ -0,0 +1,9 @@ +import RNFastImage from '@pieter-pot/react-native-fast-image'; + +// eslint-disable-next-line +const FastImage = (props) => ; + +FastImage.displayName = 'FastImage'; +FastImage.propTypes = RNFastImage.propTypes; +FastImage.resizeMode = RNFastImage.resizeMode; +export default FastImage; diff --git a/src/components/ImageView/index.js b/src/components/ImageView/index.js index 2356ffee8b5..ebf8d9faee9 100644 --- a/src/components/ImageView/index.js +++ b/src/components/ImageView/index.js @@ -1,20 +1,33 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; import { - View, Image, Pressable, + View, Pressable, } from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import FastImage from '../FastImage'; 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 = { + + /** Do the urls require an authToken? */ + isAuthTokenRequired: PropTypes.bool, + /** URL to full-sized image */ url: PropTypes.string.isRequired, ...windowDimensionsPropTypes, }; +const defaultProps = { + isAuthTokenRequired: false, +}; + class ImageView extends PureComponent { constructor(props) { super(props); @@ -23,6 +36,7 @@ class ImageView extends PureComponent { this.onContainerLayoutChanged = this.onContainerLayoutChanged.bind(this); this.onContainerPressIn = this.onContainerPressIn.bind(this); this.onContainerPress = this.onContainerPress.bind(this); + this.imageLoad = this.imageLoad.bind(this); this.imageLoadingStart = this.imageLoadingStart.bind(this); this.imageLoadingEnd = this.imageLoadingEnd.bind(this); this.trackMovement = this.trackMovement.bind(this); @@ -46,9 +60,6 @@ class ImageView extends PureComponent { } componentDidMount() { - Image.getSize(this.props.url, (width, height) => { - this.setImageRegion(width, height); - }); if (this.canUseTouchScreen) { return; } @@ -207,6 +218,10 @@ class ImageView extends PureComponent { this.setState(prevState => ({isDragging: prevState.isMouseDown})); } + imageLoad({nativeEvent}) { + this.setImageRegion(nativeEvent.width, nativeEvent.height); + } + imageLoadingStart() { this.setState({isLoading: true}); } @@ -216,14 +231,18 @@ class ImageView extends PureComponent { } render() { + const headers = this.props.isAuthTokenRequired ? chatAttachmentTokenHeaders() : undefined; if (this.canUseTouchScreen) { return ( - 1 ? 'center' : 'contain'} + resizeMode={this.state.zoomScale > 1 ? FastImage.resizeMode.center : FastImage.resizeMode.contain} onLoadStart={this.imageLoadingStart} onLoadEnd={this.imageLoadingEnd} + onLoad={this.imageLoad} /> {this.state.isLoading && ( - @@ -288,4 +312,7 @@ class ImageView extends PureComponent { } ImageView.propTypes = propTypes; -export default withWindowDimensions(ImageView); +ImageView.defaultProps = defaultProps; +export default compose(withWindowDimensions, withOnyx({ + session: {key: ONYXKEYS.SESSION}, +}))(ImageView); diff --git a/src/components/ImageView/index.native.js b/src/components/ImageView/index.native.js index e3b0a6ef0e0..1736af4c53c 100644 --- a/src/components/ImageView/index.native.js +++ b/src/components/ImageView/index.native.js @@ -1,35 +1,46 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; import { - View, InteractionManager, PanResponder, + View, PanResponder, InteractionManager, } from 'react-native'; -import Image from '@pieter-pot/react-native-fast-image'; import ImageZoom from 'react-native-image-pan-zoom'; -import ImageSize from 'react-native-image-size'; import _ from 'underscore'; import styles from '../../styles/styles'; import variables from '../../styles/variables'; import withWindowDimensions, {windowDimensionsPropTypes} from '../withWindowDimensions'; import FullscreenLoadingIndicator from '../FullscreenLoadingIndicator'; +import FastImage from '../FastImage'; +import chatAttachmentTokenHeaders from '../../libs/chatAttachmentTokenHeaders'; /** * On the native layer, we use a image library to handle zoom functionality */ const propTypes = { + + /** Do the urls require an authToken? */ + isAuthTokenRequired: PropTypes.bool, + /** URL to full-sized image */ url: PropTypes.string.isRequired, ...windowDimensionsPropTypes, }; +const defaultProps = { + isAuthTokenRequired: false, +}; + class ImageView extends PureComponent { constructor(props) { super(props); this.state = { isLoading: false, - imageWidth: undefined, - imageHeight: undefined, + + // Default to large image width and height to prevent + // small, blurry image being present by react-native-image-pan-zoom + imageWidth: props.windowWidth, + imageHeight: props.windowHeight, interactionPromise: undefined, containerHeight: undefined, }; @@ -47,12 +58,7 @@ class ImageView extends PureComponent { }); this.imageLoadingStart = this.imageLoadingStart.bind(this); - this.imageLoadingEnd = this.imageLoadingEnd.bind(this); - } - - componentDidMount() { - // Wait till animations are over to prevent stutter in navigation animation - this.state.interactionPromise = InteractionManager.runAfterInteractions(() => this.calculateImageSize()); + this.imageLoad = this.imageLoad.bind(this); } componentWillUnmount() { @@ -62,13 +68,27 @@ class ImageView extends PureComponent { this.state.interactionPromise.cancel(); } - calculateImageSize() { - if (!this.props.url) { - return; + /** + * Updates the amount of active touches on the PanResponder on our ImageZoom overlay View + * + * @param {Event} e + * @param {GestureState} gestureState + * @returns {Boolean} + */ + updatePanResponderTouches(e, gestureState) { + if (_.isNumber(gestureState.numberActiveTouches)) { + this.amountOfTouches = gestureState.numberActiveTouches; } - ImageSize.getSize(this.props.url).then(({width, height}) => { - let imageWidth = width; - let imageHeight = height; + + // We don't need to set the panResponder since all we care about is checking the gestureState, so return false + return false; + } + + imageLoad({nativeEvent}) { + // Wait till animations are over to prevent stutter in navigation animation + this.state.interactionPromise = InteractionManager.runAfterInteractions(() => { + let imageWidth = nativeEvent.width; + let imageHeight = nativeEvent.height; const containerWidth = Math.round(this.props.windowWidth); const containerHeight = Math.round(this.state.containerHeight); @@ -84,63 +104,18 @@ class ImageView extends PureComponent { const maxDimensionsScale = 11; imageHeight = Math.min(imageHeight, (this.props.windowHeight * maxDimensionsScale)); imageWidth = Math.min(imageWidth, (this.props.windowWidth * maxDimensionsScale)); - this.setState({imageHeight, imageWidth}); + this.setState({imageHeight, imageWidth, isLoading: false}); }); } - /** - * Updates the amount of active touches on the PanResponder on our ImageZoom overlay View - * - * @param {Event} e - * @param {GestureState} gestureState - * @returns {Boolean} - */ - updatePanResponderTouches(e, gestureState) { - if (_.isNumber(gestureState.numberActiveTouches)) { - this.amountOfTouches = gestureState.numberActiveTouches; - } - - // We don't need to set the panResponder since all we care about is checking the gestureState, so return false - return false; - } - imageLoadingStart() { this.setState({isLoading: true}); } - imageLoadingEnd() { - this.setState({isLoading: false}); - } - render() { // Default windowHeight accounts for the modal header height const windowHeight = this.props.windowHeight - variables.contentHeaderHeight; - - // Display thumbnail until Image size calculation is complete - if (!this.state.imageWidth || !this.state.imageHeight) { - return ( - { - const layout = event.nativeEvent.layout; - this.setState({ - containerHeight: layout.height, - }); - }} - > - - - ); - } + const headers = this.props.isAuthTokenRequired ? chatAttachmentTokenHeaders() : undefined; // Zoom view should be loaded only after measuring actual image dimensions, otherwise it causes blurred images on Android return ( @@ -152,6 +127,12 @@ class ImageView extends PureComponent { styles.justifyContentCenter, styles.overflowHidden, ]} + onLayout={(event) => { + const layout = event.nativeEvent.layout; + this.setState({ + containerHeight: layout.height, + }); + }} > this.zoom = el} @@ -186,16 +167,24 @@ class ImageView extends PureComponent { this.imageZoomScale = scale; }} > - {/** Create an invisible view on top of the image so we can capture and set the amount of touches before @@ -223,5 +212,6 @@ class ImageView extends PureComponent { } ImageView.propTypes = propTypes; +ImageView.defaultProps = defaultProps; export default withWindowDimensions(ImageView); diff --git a/src/components/ImageWithSizeCalculation.js b/src/components/ImageWithSizeCalculation.js index 3b6eee240d1..f7a32be9536 100644 --- a/src/components/ImageWithSizeCalculation.js +++ b/src/components/ImageWithSizeCalculation.js @@ -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 */ @@ -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, + }), }; const defaultProps = { style: {}, onMeasure: () => {}, + isAuthTokenRequired: false, + session: { + encryptedAuthToken: false, + }, }; /** @@ -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; return ( - {this.state.isLoading && ( @@ -103,6 +100,7 @@ class ThumbnailImage extends PureComponent { diff --git a/src/libs/chatAttachmentTokenHeaders.js b/src/libs/chatAttachmentTokenHeaders.js new file mode 100644 index 00000000000..60c4d5dc850 --- /dev/null +++ b/src/libs/chatAttachmentTokenHeaders.js @@ -0,0 +1,21 @@ +import lodashGet from 'lodash/get'; +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '../ONYXKEYS'; +import CONST from '../CONST'; + +let encryptedAuthToken = ''; +Onyx.connect({ + key: ONYXKEYS.SESSION, + callback: session => encryptedAuthToken = lodashGet(session, 'encryptedAuthToken', ''), +}); + +/** + * Create a header object with the encryptedAuthToken for image caching via headers + * + * @returns {String} + */ +export default function () { + return { + [CONST.CHAT_ATTACHMENT_TOKEN_KEY]: encryptedAuthToken, + }; +}