diff --git a/ios/Podfile.lock b/ios/Podfile.lock index b4226a18fb71..1bda8baba485 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -569,7 +569,7 @@ PODS: - React-Core - RNDateTimePicker (3.5.2): - React-Core - - RNFastImage (8.5.11): + - RNFastImage (8.6.3): - React-Core - SDWebImage (~> 5.11.1) - SDWebImageWebPCoder (~> 0.8.4) @@ -722,7 +722,7 @@ DEPENDENCIES: - "RNCClipboard (from `../node_modules/@react-native-community/clipboard`)" - "RNCPicker (from `../node_modules/@react-native-picker/picker`)" - "RNDateTimePicker (from `../node_modules/@react-native-community/datetimepicker`)" - - "RNFastImage (from `../node_modules/@pieter-pot/react-native-fast-image`)" + - RNFastImage (from `../node_modules/react-native-fast-image`) - "RNFBAnalytics (from `../node_modules/@react-native-firebase/analytics`)" - "RNFBApp (from `../node_modules/@react-native-firebase/app`)" - "RNFBCrashlytics (from `../node_modules/@react-native-firebase/crashlytics`)" @@ -890,7 +890,7 @@ EXTERNAL SOURCES: RNDateTimePicker: :path: "../node_modules/@react-native-community/datetimepicker" RNFastImage: - :path: "../node_modules/@pieter-pot/react-native-fast-image" + :path: "../node_modules/react-native-fast-image" RNFBAnalytics: :path: "../node_modules/@react-native-firebase/analytics" RNFBApp: @@ -1005,7 +1005,7 @@ SPEC CHECKSUMS: RNCClipboard: 41d8d918092ae8e676f18adada19104fa3e68495 RNCPicker: 0b65be85fe7954fbb2062ef079e3d1cde252d888 RNDateTimePicker: 7658208086d86d09e1627b5c34ba0cf237c60140 - RNFastImage: 1f2cab428712a4baaf78d6169eaec7f622556dd7 + RNFastImage: 5c9c9fed9c076e521b3f509fe79e790418a544e8 RNFBAnalytics: f76bfa164ac235b00505deb9fc1776634056898c RNFBApp: 729c0666395b1953198dc4a1ec6deb8fbe1c302e RNFBCrashlytics: 2061ca863e8e2fa1aae9b12477d7dfa8e88ca0f9 diff --git a/package-lock.json b/package-lock.json index e983274b85ab..06f242ad5dc9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,6 @@ "@gorhom/portal": "^1.0.14", "@oguzhnatly/react-native-image-manipulator": "github:Expensify/react-native-image-manipulator#c5f654fc9d0ad7cc5b89d50b34ecf8b0e3f4d050", "@onfido/react-native-sdk": "7.0.1", - "@pieter-pot/react-native-fast-image": "8.5.11", "@react-native-async-storage/async-storage": "^1.17.10", "@react-native-community/cameraroll": "git+https://github.com/react-native-cameraroll/react-native-cameraroll.git#3f0aed96db68e134f199171c7b06c1b4d6cb382b", "@react-native-community/clipboard": "^1.5.1", @@ -62,6 +61,7 @@ "react-native-collapsible": "^1.6.0", "react-native-config": "^1.4.5", "react-native-document-picker": "^8.0.0", + "react-native-fast-image": "^8.6.3", "react-native-gesture-handler": "2.6.0", "react-native-google-places-autocomplete": "git+https://github.com/Expensify/react-native-google-places-autocomplete.git#3bbd17d63e6c38d38d857b50f6037c1c0376ff06", "react-native-haptic-feedback": "^1.13.0", @@ -4179,15 +4179,6 @@ "react-native": ">=0.68.2 <1.0.x" } }, - "node_modules/@pieter-pot/react-native-fast-image": { - "version": "8.5.11", - "resolved": "https://registry.npmjs.org/@pieter-pot/react-native-fast-image/-/react-native-fast-image-8.5.11.tgz", - "integrity": "sha512-7kcUi3UuKpVVyk32dHZsaxSbU+xKkQUscyG4umNvHXoTxDFPx0RsmkKdaDd8EPAKaBxT1Y7ljb7veYAkcXlRQg==", - "peerDependencies": { - "react": "*", - "react-native": "*" - } - }, "node_modules/@pmmmwh/react-refresh-webpack-plugin": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.7.tgz", @@ -35353,6 +35344,15 @@ } } }, + "node_modules/react-native-fast-image": { + "version": "8.6.3", + "resolved": "https://registry.npmjs.org/react-native-fast-image/-/react-native-fast-image-8.6.3.tgz", + "integrity": "sha512-Sdw4ESidXCXOmQ9EcYguNY2swyoWmx53kym2zRsvi+VeFCHEdkO+WG1DK+6W81juot40bbfLNhkc63QnWtesNg==", + "peerDependencies": { + "react": "^17 || ^18", + "react-native": ">=0.60.0" + } + }, "node_modules/react-native-flipper": { "version": "0.159.0", "resolved": "https://gitpkg.now.sh/facebook/flipper/react-native/react-native-flipper?9cacc9b59402550eae866e0e81e5f0c2f8203e6b", @@ -45734,12 +45734,6 @@ "integrity": "sha512-nhjByw/YyTACvkDWX2QtCzYmqkrDtSBJxYYgJjPuKvPRVIJhrny3bIm0DzAi1hWyIM2ZsKW/MSQxerGhR9FQaw==", "requires": {} }, - "@pieter-pot/react-native-fast-image": { - "version": "8.5.11", - "resolved": "https://registry.npmjs.org/@pieter-pot/react-native-fast-image/-/react-native-fast-image-8.5.11.tgz", - "integrity": "sha512-7kcUi3UuKpVVyk32dHZsaxSbU+xKkQUscyG4umNvHXoTxDFPx0RsmkKdaDd8EPAKaBxT1Y7ljb7veYAkcXlRQg==", - "requires": {} - }, "@pmmmwh/react-refresh-webpack-plugin": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.7.tgz", @@ -69755,6 +69749,12 @@ "invariant": "^2.2.4" } }, + "react-native-fast-image": { + "version": "8.6.3", + "resolved": "https://registry.npmjs.org/react-native-fast-image/-/react-native-fast-image-8.6.3.tgz", + "integrity": "sha512-Sdw4ESidXCXOmQ9EcYguNY2swyoWmx53kym2zRsvi+VeFCHEdkO+WG1DK+6W81juot40bbfLNhkc63QnWtesNg==", + "requires": {} + }, "react-native-flipper": { "version": "https://gitpkg.now.sh/facebook/flipper/react-native/react-native-flipper?9cacc9b59402550eae866e0e81e5f0c2f8203e6b", "integrity": "sha512-M784S/qPuN/HqjdvXg98HIDmfm0sF8mACc56YNg87nzEF90zKSKp0XyOE83SEW+UJX2Gq/rf9BvM2GZeXlrhnQ==", diff --git a/package.json b/package.json index 99c530b174cc..491a23f902c4 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,6 @@ "@gorhom/portal": "^1.0.14", "@oguzhnatly/react-native-image-manipulator": "github:Expensify/react-native-image-manipulator#c5f654fc9d0ad7cc5b89d50b34ecf8b0e3f4d050", "@onfido/react-native-sdk": "7.0.1", - "@pieter-pot/react-native-fast-image": "8.5.11", "@react-native-async-storage/async-storage": "^1.17.10", "@react-native-community/cameraroll": "git+https://github.com/react-native-cameraroll/react-native-cameraroll.git#3f0aed96db68e134f199171c7b06c1b4d6cb382b", "@react-native-community/clipboard": "^1.5.1", @@ -93,6 +92,7 @@ "react-native-collapsible": "^1.6.0", "react-native-config": "^1.4.5", "react-native-document-picker": "^8.0.0", + "react-native-fast-image": "^8.6.3", "react-native-gesture-handler": "2.6.0", "react-native-google-places-autocomplete": "git+https://github.com/Expensify/react-native-google-places-autocomplete.git#3bbd17d63e6c38d38d857b50f6037c1c0376ff06", "react-native-haptic-feedback": "^1.13.0", diff --git a/patches/react-native-fast-image+8.6.3.patch b/patches/react-native-fast-image+8.6.3.patch new file mode 100644 index 000000000000..fc7e59c17c2e --- /dev/null +++ b/patches/react-native-fast-image+8.6.3.patch @@ -0,0 +1,287 @@ +diff --git a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/BitmapSizeDecoder.java b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/BitmapSizeDecoder.java +new file mode 100644 +index 0000000..03ad017 +--- /dev/null ++++ b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/BitmapSizeDecoder.java +@@ -0,0 +1,31 @@ ++package com.dylanvann.fastimage; ++ ++import android.graphics.BitmapFactory; ++ ++import androidx.annotation.NonNull; ++import androidx.annotation.Nullable; ++ ++import com.bumptech.glide.load.Options; ++import com.bumptech.glide.load.ResourceDecoder; ++import com.bumptech.glide.load.engine.Resource; ++import com.bumptech.glide.load.resource.SimpleResource; ++ ++import java.io.IOException; ++import java.io.InputStream; ++ ++public class BitmapSizeDecoder implements ResourceDecoder { ++ ++ @Override ++ public boolean handles(@NonNull InputStream source, @NonNull Options options) throws IOException { ++ return true; ++ } ++ ++ @Nullable ++ @Override ++ public Resource decode(@NonNull InputStream source, int width, int height, @NonNull Options options) throws IOException { ++ BitmapFactory.Options bitmapOptions = new BitmapFactory.Options(); ++ bitmapOptions.inJustDecodeBounds = true; ++ BitmapFactory.decodeStream(source, null, bitmapOptions); ++ return new SimpleResource(bitmapOptions); ++ } ++} +\ No newline at end of file +diff --git a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/BitmapSizeTranscoder.java b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/BitmapSizeTranscoder.java +new file mode 100644 +index 0000000..7d208d1 +--- /dev/null ++++ b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/BitmapSizeTranscoder.java +@@ -0,0 +1,23 @@ ++package com.dylanvann.fastimage; ++ ++import android.graphics.BitmapFactory; ++ ++import androidx.annotation.NonNull; ++import androidx.annotation.Nullable; ++ ++import com.bumptech.glide.load.Options; ++import com.bumptech.glide.load.engine.Resource; ++import com.bumptech.glide.load.resource.SimpleResource; ++import com.bumptech.glide.load.resource.transcode.ResourceTranscoder; ++ ++public class BitmapSizeTranscoder implements ResourceTranscoder { ++ @Nullable ++ @Override ++ public Resource transcode(@NonNull Resource toTranscode, @NonNull Options options) { ++ BitmapFactory.Options bitmap = toTranscode.get(); ++ Size size = new Size(); ++ size.width = bitmap.outWidth; ++ size.height = bitmap.outHeight; ++ return new SimpleResource(size); ++ } ++} +\ No newline at end of file +diff --git a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageOkHttpProgressGlideModule.java b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageOkHttpProgressGlideModule.java +index 811292a..f60b87c 100644 +--- a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageOkHttpProgressGlideModule.java ++++ b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageOkHttpProgressGlideModule.java +@@ -2,6 +2,7 @@ package com.dylanvann.fastimage; + + import android.content.Context; + import androidx.annotation.NonNull; ++import android.graphics.BitmapFactory; + + import com.bumptech.glide.Glide; + import com.bumptech.glide.Registry; +@@ -47,6 +48,9 @@ public class FastImageOkHttpProgressGlideModule extends LibraryGlideModule { + .build(); + OkHttpUrlLoader.Factory factory = new OkHttpUrlLoader.Factory(client); + registry.replace(GlideUrl.class, InputStream.class, factory); ++ // Decoder + Transcoder pair for InputStream -> Size ++ registry.prepend(InputStream.class, BitmapFactory.Options.class, new BitmapSizeDecoder()); ++ registry.register(BitmapFactory.Options.class, Size.class, new BitmapSizeTranscoder()); + } + + private static Interceptor createInterceptor(final ResponseProgressListener listener) { +diff --git a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageRequestListener.java b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageRequestListener.java +index dbeb813..bf8f21c 100644 +--- a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageRequestListener.java ++++ b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageRequestListener.java +@@ -22,13 +22,6 @@ public class FastImageRequestListener implements RequestListener { + this.key = key; + } + +- private static WritableMap mapFromResource(Drawable resource) { +- WritableMap resourceData = new WritableNativeMap(); +- resourceData.putInt("width", resource.getIntrinsicWidth()); +- resourceData.putInt("height", resource.getIntrinsicHeight()); +- return resourceData; +- } +- + @Override + public boolean onLoadFailed(@androidx.annotation.Nullable GlideException e, Object model, Target target, boolean isFirstResource) { + FastImageOkHttpProgressGlideModule.forget(key); +@@ -53,7 +46,6 @@ public class FastImageRequestListener implements RequestListener { + ThemedReactContext context = (ThemedReactContext) view.getContext(); + RCTEventEmitter eventEmitter = context.getJSModule(RCTEventEmitter.class); + int viewId = view.getId(); +- eventEmitter.receiveEvent(viewId, REACT_ON_LOAD_EVENT, mapFromResource(resource)); + eventEmitter.receiveEvent(viewId, REACT_ON_LOAD_END_EVENT, new WritableNativeMap()); + return false; + } +diff --git a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageViewWithUrl.java b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageViewWithUrl.java +index 34fcf89..1339f5c 100644 +--- a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageViewWithUrl.java ++++ b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageViewWithUrl.java +@@ -2,6 +2,7 @@ package com.dylanvann.fastimage; + + import static com.dylanvann.fastimage.FastImageRequestListener.REACT_ON_ERROR_EVENT; + ++import androidx.annotation.NonNull; + import android.annotation.SuppressLint; + import android.content.Context; + import android.graphics.drawable.Drawable; +@@ -9,16 +10,24 @@ import android.graphics.drawable.Drawable; + import androidx.annotation.Nullable; + import androidx.appcompat.widget.AppCompatImageView; + ++import com.bumptech.glide.Glide; + import com.bumptech.glide.RequestBuilder; + import com.bumptech.glide.RequestManager; ++import com.bumptech.glide.load.DataSource; ++import com.bumptech.glide.load.engine.GlideException; + import com.bumptech.glide.load.model.GlideUrl; + import com.bumptech.glide.request.Request; ++import com.bumptech.glide.request.RequestListener; ++import com.bumptech.glide.request.target.SimpleTarget; ++import com.bumptech.glide.request.target.Target; ++import com.bumptech.glide.request.transition.Transition; + import com.facebook.react.bridge.ReadableMap; + import com.facebook.react.bridge.WritableMap; + import com.facebook.react.bridge.WritableNativeMap; + import com.facebook.react.uimanager.ThemedReactContext; + import com.facebook.react.uimanager.events.RCTEventEmitter; + ++import java.io.File; + import java.util.ArrayList; + import java.util.Collections; + import java.util.List; +@@ -124,9 +133,34 @@ class FastImageViewWithUrl extends AppCompatImageView { + RCTEventEmitter eventEmitter = context.getJSModule(RCTEventEmitter.class); + int viewId = this.getId(); + +- eventEmitter.receiveEvent(viewId, +- FastImageViewManager.REACT_ON_LOAD_START_EVENT, +- new WritableNativeMap()); ++ // Request the URL from cache to see if it exists there and if so pass the cache ++ // path as an argument in the onLoadStart event ++ requestManager ++ .asFile() ++ .load(glideUrl) ++ .onlyRetrieveFromCache(true) ++ .listener(new RequestListener() { ++ @Override ++ public boolean onLoadFailed(@Nullable GlideException e, Object model, Target target, boolean isFirstResource) { ++ WritableNativeMap result = new WritableNativeMap(); ++ result.putNull("cachePath"); ++ eventEmitter.receiveEvent(viewId, ++ FastImageViewManager.REACT_ON_LOAD_START_EVENT, ++ result); ++ return false; ++ } ++ ++ @Override ++ public boolean onResourceReady(File resource, Object model, Target target, DataSource dataSource, boolean isFirstResource) { ++ WritableNativeMap result = new WritableNativeMap(); ++ result.putString("cachePath", resource.getAbsolutePath()); ++ eventEmitter.receiveEvent(viewId, ++ FastImageViewManager.REACT_ON_LOAD_START_EVENT, ++ result); ++ return false; ++ } ++ }) ++ .submit(); + } + + if (requestManager != null) { +@@ -148,6 +182,25 @@ class FastImageViewWithUrl extends AppCompatImageView { + builder.listener(new FastImageRequestListener(key)); + + builder.into(this); ++ ++ // Used specifically to handle the `onLoad` event for the image ++ RCTEventEmitter eventEmitter = context.getJSModule(RCTEventEmitter.class); ++ int viewId = this.getId(); ++ requestManager ++ .as(Size.class) ++ .load(imageSource == null ? null : imageSource.getSourceForLoad()) ++ .into(new SimpleTarget() { ++ @Override ++ public void onResourceReady(@NonNull Size resource, @Nullable Transition transition) { ++ WritableMap resourceData = new WritableNativeMap(); ++ resourceData.putInt("width", resource.width); ++ resourceData.putInt("height", resource.height); ++ eventEmitter.receiveEvent(viewId, ++ "onFastImageLoad", ++ resourceData ++ ); ++ } ++ }); + } + } + +diff --git a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/Size.java b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/Size.java +new file mode 100644 +index 0000000..2fe8a47 +--- /dev/null ++++ b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/Size.java +@@ -0,0 +1,6 @@ ++package com.dylanvann.fastimage; ++ ++public class Size { ++ int width; ++ int height; ++} +\ No newline at end of file +diff --git a/node_modules/react-native-fast-image/dist/index.d.ts b/node_modules/react-native-fast-image/dist/index.d.ts +index 5abb7c9..a2672c6 100644 +--- a/node_modules/react-native-fast-image/dist/index.d.ts ++++ b/node_modules/react-native-fast-image/dist/index.d.ts +@@ -27,6 +27,11 @@ export declare type Source = { + priority?: Priority; + cache?: Cache; + }; ++export interface OnLoadStartEvent { ++ nativeEvent: { ++ cachePath: string | null; ++ }; ++} + export interface OnLoadEvent { + nativeEvent: { + width: number; +@@ -57,7 +62,7 @@ export interface FastImageProps extends AccessibilityProps, ViewProps { + defaultSource?: ImageRequireSource; + resizeMode?: ResizeMode; + fallback?: boolean; +- onLoadStart?(): void; ++ onLoadStart?(event: OnLoadStartEvent): void; + onProgress?(event: OnProgressEvent): void; + onLoad?(event: OnLoadEvent): void; + onError?(): void; +diff --git a/node_modules/react-native-fast-image/ios/FastImage/FFFastImageView.m b/node_modules/react-native-fast-image/ios/FastImage/FFFastImageView.m +index f710081..391ef92 100644 +--- a/node_modules/react-native-fast-image/ios/FastImage/FFFastImageView.m ++++ b/node_modules/react-native-fast-image/ios/FastImage/FFFastImageView.m +@@ -54,7 +54,6 @@ - (void) setOnFastImageError: (RCTDirectEventBlock)onFastImageError { + - (void) setOnFastImageLoadStart: (RCTDirectEventBlock)onFastImageLoadStart { + if (_source && !self.hasSentOnLoadStart) { + _onFastImageLoadStart = onFastImageLoadStart; +- onFastImageLoadStart(@{}); + self.hasSentOnLoadStart = YES; + } else { + _onFastImageLoadStart = onFastImageLoadStart; +@@ -188,7 +187,18 @@ - (void) reloadImage { + } + + if (self.onFastImageLoadStart) { +- self.onFastImageLoadStart(@{}); ++ NSString* cachePath = [[SDImageCache sharedImageCache] cachePathForKey:url]; ++ BOOL isCached = [[SDImageCache sharedImageCache] diskImageDataExistsWithKey:url]; ++ if (isCached) { ++ self.onFastImageLoadStart(@{ ++ @"cachePath": cachePath ++ }); ++ } ++ else { ++ self.onFastImageLoadStart(@{ ++ @"cachePath": [NSNull null] ++ }); ++ } + self.hasSentOnLoadStart = YES; + } else { + self.hasSentOnLoadStart = NO; diff --git a/src/CONST.js b/src/CONST.js index 9e704eab68e2..e1df6557772f 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -874,6 +874,7 @@ 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 14b80f5f1a49..bb63a1d01a28 100755 --- a/src/components/AttachmentModal.js +++ b/src/components/AttachmentModal.js @@ -44,7 +44,7 @@ const propTypes = { /** A function as a child to pass modal launching methods to */ children: PropTypes.func.isRequired, - /** Do the urls require an authToken? */ + /** Whether source url requires authentication */ isAuthTokenRequired: PropTypes.bool, /** Determines if download Button should be shown or not */ @@ -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 +69,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 a236c63b2cb5..65e4ece20254 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 Image from './Image'; import {withNetwork} from './OnyxProvider'; import networkPropTypes from './networkPropTypes'; import styles from '../styles/styles'; diff --git a/src/components/Image/imagePropTypes.js b/src/components/Image/imagePropTypes.js new file mode 100644 index 000000000000..645cbaada894 --- /dev/null +++ b/src/components/Image/imagePropTypes.js @@ -0,0 +1,58 @@ +import PropTypes from 'prop-types'; +import stylePropTypes from '../../styles/stylePropTypes'; +import RESIZE_MODES from './resizeModes'; + +const imagePropTypes = { + /** Styles for the Image */ + style: stylePropTypes, + + /** The static asset or URI source of the image */ + source: PropTypes.oneOfType([ + PropTypes.number, + PropTypes.shape({ + uri: PropTypes.string.isRequired, + // eslint-disable-next-line react/forbid-prop-types + headers: PropTypes.object, + }), + ]).isRequired, + + /** Should an auth token be included in the image request */ + isAuthTokenRequired: PropTypes.bool, + + /** How should the image fit within its container */ + resizeMode: PropTypes.string, + + /** Event for when the image begins loading */ + onLoadStart: PropTypes.func, + + /** Event for when the image finishes loading */ + onLoadEnd: PropTypes.func, + + /** Event for when the image is fully loaded and returns the natural dimensions of the image */ + onLoad: PropTypes.func, + + /** Progress events while the image is downloading */ + onProgress: PropTypes.func, + + /* Onyx Props */ + /** Session info for the currently logged in user. */ + session: PropTypes.shape({ + + /** Currently logged in user authToken */ + authToken: PropTypes.string, + }), +}; + +const defaultProps = { + style: [], + session: { + authToken: null, + }, + isAuthTokenRequired: false, + resizeMode: RESIZE_MODES.cover, + onLoadStart: () => {}, + onLoadEnd: () => {}, + onLoad: () => {}, +}; + +export {imagePropTypes, defaultProps}; diff --git a/src/components/Image/index.js b/src/components/Image/index.js new file mode 100644 index 000000000000..1c67dcf35129 --- /dev/null +++ b/src/components/Image/index.js @@ -0,0 +1,74 @@ +import React from 'react'; +import {Image as RNImage} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import lodashGet from 'lodash/get'; +import ONYXKEYS from '../../ONYXKEYS'; +import {defaultProps, imagePropTypes} from './imagePropTypes'; +import RESIZE_MODES from './resizeModes'; + +class Image extends React.Component { + constructor(props) { + super(props); + + this.state = { + imageSource: undefined, + }; + } + + componentDidMount() { + this.configureImageSource(); + } + + componentDidUpdate(prevProps) { + if (prevProps.source === this.props.source) { + return; + } + this.configureImageSource(); + } + + /** + * Check if the image source is a URL - if so the `encryptedAuthToken` is appended + * to the source. The natural image dimensions can then be retrieved using this source + * and as a result the `onLoad` event needs to be maunually invoked to return these dimensions + */ + configureImageSource() { + const source = this.props.source; + let imageSource = source; + if (this.props.isAuthTokenRequired) { + // There is currently a `react-native-web` bug preventing the authToken being passed + // in the headers of the image request so the authToken is added as a query param. + // On native the authToken IS passed in the image request headers + const authToken = lodashGet(this.props, 'session.encryptedAuthToken', null); + imageSource = {uri: `${source.uri}?encryptedAuthToken=${encodeURIComponent(authToken)}`}; + } + this.setState({imageSource}); + + // If an onLoad callback was specified then manually call it and pass + // the natural image dimensions to match the native API + if (this.props.onLoad == null) { + return; + } + RNImage.getSize(imageSource.uri, (width, height) => { + this.props.onLoad({nativeEvent: {width, height}}); + }); + } + + render() { + // eslint-disable-next-line + const { source, onLoad, ...rest } = this.props; + + // eslint-disable-next-line + return ; + } +} + +Image.propTypes = imagePropTypes; +Image.defaultProps = defaultProps; + +const ImageWithOnyx = withOnyx({ + session: { + key: ONYXKEYS.SESSION, + }, +})(Image); +ImageWithOnyx.resizeMode = RESIZE_MODES; +export default ImageWithOnyx; diff --git a/src/components/Image/index.native.js b/src/components/Image/index.native.js new file mode 100644 index 000000000000..4dfbe0c86d92 --- /dev/null +++ b/src/components/Image/index.native.js @@ -0,0 +1,40 @@ +import React from 'react'; +import RNFastImage from 'react-native-fast-image'; +import {withOnyx} from 'react-native-onyx'; +import lodashGet from 'lodash/get'; +import CONST from '../../CONST'; +import ONYXKEYS from '../../ONYXKEYS'; +import {defaultProps, imagePropTypes} from './imagePropTypes'; +import RESIZE_MODES from './resizeModes'; + +const Image = (props) => { + // eslint-disable-next-line react/destructuring-assignment + const { + source, isAuthTokenRequired, session, ...rest + } = props; + + let imageSource = source; + if (typeof source !== 'number' && isAuthTokenRequired) { + const authToken = lodashGet(props, 'session.encryptedAuthToken', null); + imageSource = { + ...source, + headers: authToken ? { + [CONST.CHAT_ATTACHMENT_TOKEN_KEY]: authToken, + } : null, + }; + } + + // eslint-disable-next-line react/jsx-props-no-spreading + return ; +}; + +Image.propTypes = imagePropTypes; +Image.defaultProps = defaultProps; +Image.displayName = 'Image'; +const ImageWithOnyx = withOnyx({ + session: { + key: ONYXKEYS.SESSION, + }, +})(Image); +ImageWithOnyx.resizeMode = RESIZE_MODES; +export default ImageWithOnyx; diff --git a/src/components/Image/resizeModes.js b/src/components/Image/resizeModes.js new file mode 100644 index 000000000000..e6cc699a2fe3 --- /dev/null +++ b/src/components/Image/resizeModes.js @@ -0,0 +1,8 @@ +const RESIZE_MODES = { + contain: 'contain', + cover: 'cover', + stretch: 'stretch', + center: 'center', +}; + +export default RESIZE_MODES; diff --git a/src/components/ImageView/index.js b/src/components/ImageView/index.js index 2356ffee8b5f..9bf74cff571e 100644 --- a/src/components/ImageView/index.js +++ b/src/components/ImageView/index.js @@ -1,8 +1,7 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; -import { - View, Image, Pressable, -} from 'react-native'; +import {View, Pressable} from 'react-native'; +import Image from '../Image'; import styles from '../../styles/styles'; import * as StyleUtils from '../../styles/StyleUtils'; import canUseTouchScreen from '../../libs/canUseTouchscreen'; @@ -10,11 +9,18 @@ import withWindowDimensions, {windowDimensionsPropTypes} from '../withWindowDime import FullscreenLoadingIndicator from '../FullscreenLoadingIndicator'; const propTypes = { + /** Whether source url requires authentication */ + 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 +29,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 +53,6 @@ class ImageView extends PureComponent { } componentDidMount() { - Image.getSize(this.props.url, (width, height) => { - this.setImageRegion(width, height); - }); if (this.canUseTouchScreen) { return; } @@ -207,6 +211,10 @@ class ImageView extends PureComponent { this.setState(prevState => ({isDragging: prevState.isMouseDown})); } + imageLoad({nativeEvent}) { + this.setImageRegion(nativeEvent.width, nativeEvent.height); + } + imageLoadingStart() { this.setState({isLoading: true}); } @@ -224,6 +232,7 @@ class ImageView extends PureComponent { > 1 ? 'center' : 'contain'} + resizeMode={this.state.zoomScale > 1 ? Image.resizeMode.center : Image.resizeMode.contain} onLoadStart={this.imageLoadingStart} onLoadEnd={this.imageLoadingEnd} + onLoad={this.imageLoad} /> {this.state.isLoading && ( @@ -288,4 +300,5 @@ class ImageView extends PureComponent { } ImageView.propTypes = propTypes; +ImageView.defaultProps = defaultProps; export default withWindowDimensions(ImageView); diff --git a/src/components/ImageView/index.native.js b/src/components/ImageView/index.native.js index 23382d40764e..b5fcaa4a41e1 100644 --- a/src/components/ImageView/index.native.js +++ b/src/components/ImageView/index.native.js @@ -3,33 +3,39 @@ import PropTypes from 'prop-types'; import { View, InteractionManager, PanResponder, } 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 Image from '../Image'; /** * On the native layer, we use a image library to handle zoom functionality */ const propTypes = { + /** Whether source url requires authentication */ + 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, + imageWidth: 0, + imageHeight: 0, interactionPromise: undefined, containerHeight: undefined, }; @@ -46,13 +52,8 @@ class ImageView extends PureComponent { onStartShouldSetPanResponder: this.updatePanResponderTouches.bind(this), }); - 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.imageLoadStart = this.imageLoadStart.bind(this); + this.configureImageZoom = this.configureImageZoom.bind(this); } componentWillUnmount() { @@ -62,35 +63,6 @@ class ImageView extends PureComponent { this.state.interactionPromise.cancel(); } - calculateImageSize() { - if (!this.props.url) { - return; - } - ImageSize.getSize(this.props.url).then(({width, height, rotation}) => { - let imageWidth = width; - let imageHeight = height; - const containerWidth = Math.round(this.props.windowWidth); - const containerHeight = Math.round(this.state.containerHeight); - - // On specific Android devices, the dimensions are sometimes returned to us flipped here, with `rotation` set to 90 degrees. - // Swap them back to make sure the image fits nicely in the container. On iOS, the rotation is always undefined, so this does not apply. - if (rotation === 90 || rotation === 270) { - [imageWidth, imageHeight] = [imageHeight, imageWidth]; - } - - const aspectRatio = Math.min(containerHeight / imageHeight, containerWidth / imageWidth); - - imageHeight *= aspectRatio; - imageWidth *= aspectRatio; - - // Resize the image to max dimensions possible on the Native platforms to prevent crashes on Android. To keep the same behavior, apply to IOS as well. - const maxDimensionsScale = 11; - imageHeight = Math.min(imageHeight, (this.props.windowHeight * maxDimensionsScale)); - imageWidth = Math.min(imageWidth, (this.props.windowWidth * maxDimensionsScale)); - this.setState({imageHeight, imageWidth}); - }); - } - /** * Updates the amount of active touches on the PanResponder on our ImageZoom overlay View * @@ -107,43 +79,46 @@ class ImageView extends PureComponent { return false; } - imageLoadingStart() { + imageLoadStart() { this.setState({isLoading: true}); } - imageLoadingEnd() { - this.setState({isLoading: false}); + /** + * The `ImageZoom` component requires image dimensions which + * are calculated here from the natural image dimensions produced by + * the `onLoad` event + * + * @param {Object} nativeEvent + */ + configureImageZoom({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); + + const aspectRatio = Math.min(containerHeight / imageHeight, containerWidth / imageWidth); + + if (imageHeight > imageWidth) { + imageHeight *= aspectRatio; + } else { + imageWidth *= aspectRatio; + } + + // Resize the image to max dimensions possible on the Native platforms to prevent crashes on Android. To keep the same behavior, apply to IOS as well. + const maxDimensionsScale = 11; + imageHeight = Math.min(imageHeight, (this.props.windowHeight * maxDimensionsScale)); + imageWidth = Math.min(imageWidth, (this.props.windowWidth * maxDimensionsScale)); + this.setState({imageHeight, imageWidth, 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 hasImageDimensions = this.state.imageWidth !== 0 && this.state.imageHeight !== 0; + const shouldShowLoadingIndicator = this.state.isLoading || !hasImageDimensions; // Zoom view should be loaded only after measuring actual image dimensions, otherwise it causes blurred images on Android return ( @@ -155,6 +130,12 @@ class ImageView extends PureComponent { styles.justifyContentCenter, styles.overflowHidden, ]} + onLayout={(event) => { + const layout = event.nativeEvent.layout; + this.setState({ + containerHeight: layout.height, + }); + }} > this.zoom = el} @@ -194,11 +175,17 @@ class ImageView extends PureComponent { styles.w100, styles.h100, this.props.style, + + // Hide image while loading so ImageZoom can get the image + // size before presenting - preventing visual glitches or shift + // due to ImageZoom + shouldShowLoadingIndicator ? styles.opacity0 : styles.opacity1, ]} source={{uri: this.props.url}} - resizeMode="contain" - onLoadStart={this.imageLoadingStart} - onLoadEnd={this.imageLoadingEnd} + isAuthTokenRequired={this.props.isAuthTokenRequired} + resizeMode={Image.resizeMode.contain} + onLoadStart={this.imageLoadStart} + onLoad={this.configureImageZoom} /> {/** Create an invisible view on top of the image so we can capture and set the amount of touches before @@ -215,7 +202,7 @@ class ImageView extends PureComponent { ]} /> - {this.state.isLoading && ( + {shouldShowLoadingIndicator && ( @@ -226,5 +213,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 3b6eee240d11..64e1cf7f7d7e 100644 --- a/src/components/ImageWithSizeCalculation.js +++ b/src/components/ImageWithSizeCalculation.js @@ -1,10 +1,10 @@ import React, {PureComponent} from 'react'; -import {View, Image} from 'react-native'; +import {View} from 'react-native'; import PropTypes from 'prop-types'; import Log from '../libs/Log'; import styles from '../styles/styles'; -import makeCancellablePromise from '../libs/MakeCancellablePromise'; import FullscreenLoadingIndicator from './FullscreenLoadingIndicator'; +import Image from './Image'; const propTypes = { /** Url for image to display */ @@ -16,11 +16,15 @@ const propTypes = { /** Callback fired when the image has been measured. */ onMeasure: PropTypes.func, + + /** Whether the image requires an authToken */ + isAuthTokenRequired: PropTypes.bool, }; const defaultProps = { style: {}, onMeasure: () => {}, + isAuthTokenRequired: false, }; /** @@ -39,60 +43,12 @@ class ImageWithSizeCalculation extends PureComponent { this.imageLoadingStart = this.imageLoadingStart.bind(this); this.imageLoadingEnd = this.imageLoadingEnd.bind(this); + this.onError = this.onError.bind(this); + this.imageLoadedSuccessfully = this.imageLoadedSuccessfully.bind(this); } - componentDidMount() { - this.calculateImageSize(); - } - - componentDidUpdate(prevProps) { - if (prevProps.url === this.props.url) { - return; - } - - this.calculateImageSize(); - } - - componentWillUnmount() { - if (!this.getImageSizePromise) { - return; - } - - this.getImageSizePromise.cancel(); - } - - /** - * @param {String} url - * @returns {Promise} - */ - getImageSize(url) { - return new Promise((resolve, reject) => { - Image.getSize(url, (width, height) => { - resolve({width, height}); - }, (error) => { - reject(error); - }); - }); - } - - calculateImageSize() { - if (!this.props.url) { - 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}); - }); + onError() { + Log.hmmm('Unable to fetch image to calculate size', {url: this.props.url}); } imageLoadingStart() { @@ -103,6 +59,13 @@ class ImageWithSizeCalculation extends PureComponent { this.setState({isLoading: false}); } + imageLoadedSuccessfully(event) { + this.props.onMeasure({ + width: event.nativeEvent.width, + height: event.nativeEvent.height, + }); + } + render() { return ( {this.state.isLoading && (