From 53337f5ec8419c5e633bf72fe8a730a6aee66e59 Mon Sep 17 00:00:00 2001 From: Peter Velkov Date: Mon, 20 Nov 2023 20:22:57 +0200 Subject: [PATCH 1/3] Add react-native-web patch for image header support (cherry picked from commit 19b605e4e52c1c71d1515065c0ba2de7af0c61b1) (cherry picked from commit 37df3e3dd10cd130ac60b179023ee339e3d3b8d6) --- ...-web+0.19.9+005+image-header-support.patch | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 patches/react-native-web+0.19.9+005+image-header-support.patch diff --git a/patches/react-native-web+0.19.9+005+image-header-support.patch b/patches/react-native-web+0.19.9+005+image-header-support.patch new file mode 100644 index 000000000000..4652e22662f0 --- /dev/null +++ b/patches/react-native-web+0.19.9+005+image-header-support.patch @@ -0,0 +1,200 @@ +diff --git a/node_modules/react-native-web/dist/exports/Image/index.js b/node_modules/react-native-web/dist/exports/Image/index.js +index 95355d5..19109fc 100644 +--- a/node_modules/react-native-web/dist/exports/Image/index.js ++++ b/node_modules/react-native-web/dist/exports/Image/index.js +@@ -135,7 +135,22 @@ function resolveAssetUri(source) { + } + return uri; + } +-var Image = /*#__PURE__*/React.forwardRef((props, ref) => { ++function raiseOnErrorEvent(uri, _ref) { ++ var onError = _ref.onError, ++ onLoadEnd = _ref.onLoadEnd; ++ if (onError) { ++ onError({ ++ nativeEvent: { ++ error: "Failed to load resource " + uri + " (404)" ++ } ++ }); ++ } ++ if (onLoadEnd) onLoadEnd(); ++} ++function hasSourceDiff(a, b) { ++ return a.uri !== b.uri || JSON.stringify(a.headers) !== JSON.stringify(b.headers); ++} ++var BaseImage = /*#__PURE__*/React.forwardRef((props, ref) => { + var ariaLabel = props['aria-label'], + blurRadius = props.blurRadius, + defaultSource = props.defaultSource, +@@ -236,16 +251,10 @@ var Image = /*#__PURE__*/React.forwardRef((props, ref) => { + } + }, function error() { + updateState(ERRORED); +- if (onError) { +- onError({ +- nativeEvent: { +- error: "Failed to load resource " + uri + " (404)" +- } +- }); +- } +- if (onLoadEnd) { +- onLoadEnd(); +- } ++ raiseOnErrorEvent(uri, { ++ onError, ++ onLoadEnd ++ }); + }); + } + function abortPendingRequest() { +@@ -277,10 +286,78 @@ var Image = /*#__PURE__*/React.forwardRef((props, ref) => { + suppressHydrationWarning: true + }), hiddenImage, createTintColorSVG(tintColor, filterRef.current)); + }); +-Image.displayName = 'Image'; ++BaseImage.displayName = 'Image'; ++ ++/** ++ * This component handles specifically loading an image source with headers ++ * default source is never loaded using headers ++ */ ++var ImageWithHeaders = /*#__PURE__*/React.forwardRef((props, ref) => { ++ // $FlowIgnore: This component would only be rendered when `source` matches `ImageSource` ++ var nextSource = props.source; ++ var _React$useState3 = React.useState(''), ++ blobUri = _React$useState3[0], ++ setBlobUri = _React$useState3[1]; ++ var request = React.useRef({ ++ cancel: () => {}, ++ source: { ++ uri: '', ++ headers: {} ++ }, ++ promise: Promise.resolve('') ++ }); ++ var onError = props.onError, ++ onLoadStart = props.onLoadStart, ++ onLoadEnd = props.onLoadEnd; ++ React.useEffect(() => { ++ if (!hasSourceDiff(nextSource, request.current.source)) { ++ return; ++ } ++ ++ // When source changes we want to clean up any old/running requests ++ request.current.cancel(); ++ if (onLoadStart) { ++ onLoadStart(); ++ } ++ ++ // Store a ref for the current load request so we know what's the last loaded source, ++ // and so we can cancel it if a different source is passed through props ++ request.current = ImageLoader.loadWithHeaders(nextSource); ++ request.current.promise.then(uri => setBlobUri(uri)).catch(() => raiseOnErrorEvent(request.current.source.uri, { ++ onError, ++ onLoadEnd ++ })); ++ }, [nextSource, onLoadStart, onError, onLoadEnd]); ++ ++ // Cancel any request on unmount ++ React.useEffect(() => request.current.cancel, []); ++ var propsToPass = _objectSpread(_objectSpread({}, props), {}, { ++ // `onLoadStart` is called from the current component ++ // We skip passing it down to prevent BaseImage raising it a 2nd time ++ onLoadStart: undefined, ++ // Until the current component resolves the request (using headers) ++ // we skip forwarding the source so the base component doesn't attempt ++ // to load the original source ++ source: blobUri ? _objectSpread(_objectSpread({}, nextSource), {}, { ++ uri: blobUri ++ }) : undefined ++ }); ++ return /*#__PURE__*/React.createElement(BaseImage, _extends({ ++ ref: ref ++ }, propsToPass)); ++}); + + // $FlowIgnore: This is the correct type, but casting makes it unhappy since the variables aren't defined yet +-var ImageWithStatics = Image; ++var ImageWithStatics = /*#__PURE__*/React.forwardRef((props, ref) => { ++ if (props.source && props.source.headers) { ++ return /*#__PURE__*/React.createElement(ImageWithHeaders, _extends({ ++ ref: ref ++ }, props)); ++ } ++ return /*#__PURE__*/React.createElement(BaseImage, _extends({ ++ ref: ref ++ }, props)); ++}); + ImageWithStatics.getSize = function (uri, success, failure) { + ImageLoader.getSize(uri, success, failure); + }; +diff --git a/node_modules/react-native-web/dist/modules/ImageLoader/index.js b/node_modules/react-native-web/dist/modules/ImageLoader/index.js +index bc06a87..e309394 100644 +--- a/node_modules/react-native-web/dist/modules/ImageLoader/index.js ++++ b/node_modules/react-native-web/dist/modules/ImageLoader/index.js +@@ -76,7 +76,7 @@ var ImageLoader = { + var image = requests["" + requestId]; + if (image) { + var naturalHeight = image.naturalHeight, +- naturalWidth = image.naturalWidth; ++ naturalWidth = image.naturalWidth; + if (naturalHeight && naturalWidth) { + success(naturalWidth, naturalHeight); + complete = true; +@@ -102,11 +102,19 @@ var ImageLoader = { + id += 1; + var image = new window.Image(); + image.onerror = onError; +- image.onload = e => { ++ image.onload = nativeEvent => { + // avoid blocking the main thread +- var onDecode = () => onLoad({ +- nativeEvent: e +- }); ++ var onDecode = () => { ++ // Append `source` to match RN's ImageLoadEvent interface ++ nativeEvent.source = { ++ uri: image.src, ++ width: image.naturalWidth, ++ height: image.naturalHeight ++ }; ++ onLoad({ ++ nativeEvent ++ }); ++ }; + if (typeof image.decode === 'function') { + // Safari currently throws exceptions when decoding svgs. + // We want to catch that error and allow the load handler +@@ -120,6 +128,32 @@ var ImageLoader = { + requests["" + id] = image; + return id; + }, ++ loadWithHeaders(source) { ++ var uri; ++ var abortController = new AbortController(); ++ var request = new Request(source.uri, { ++ headers: source.headers, ++ signal: abortController.signal ++ }); ++ request.headers.append('accept', 'image/*'); ++ var promise = fetch(request).then(response => response.blob()).then(blob => { ++ uri = URL.createObjectURL(blob); ++ return uri; ++ }).catch(error => { ++ if (error.name === 'AbortError') { ++ return ''; ++ } ++ throw error; ++ }); ++ return { ++ promise, ++ source, ++ cancel: () => { ++ abortController.abort(); ++ URL.revokeObjectURL(uri); ++ } ++ }; ++ }, + prefetch(uri) { + return new Promise((resolve, reject) => { + ImageLoader.load(uri, () => { From ba9e75e7f01dc0feed99ffa3c819fbd7007dd348 Mon Sep 17 00:00:00 2001 From: Peter Velkov Date: Mon, 6 Nov 2023 09:32:15 +0200 Subject: [PATCH 2/3] Refactor Image for Web/Desktop with Source Headers - Introduced `BaseImage` component that branches between native and web implementations. - **Native**: Utilizes `expo-image` directly. - **Web**: Minor adjustments made to the `onLoad` event signature for compatibility. - Eliminated `Image/index.native.js` as both native and web components now leverage a unified high-level implementation for image loading and rendering. - Added `BaseImage` specific props - Adapt to `expo-image` deprecation of `event.nativeEvent` usage. - Ensure compatibility with components using the `onLoad` prop. --- src/components/Image/BaseImage.native.tsx | 32 ++++++++++++++ src/components/Image/BaseImage.tsx | 32 ++++++++++++++ src/components/Image/index.native.tsx | 51 ----------------------- src/components/Image/index.tsx | 40 +++++------------- src/components/Image/types.ts | 18 ++++---- 5 files changed, 85 insertions(+), 88 deletions(-) create mode 100644 src/components/Image/BaseImage.native.tsx create mode 100644 src/components/Image/BaseImage.tsx delete mode 100644 src/components/Image/index.native.tsx diff --git a/src/components/Image/BaseImage.native.tsx b/src/components/Image/BaseImage.native.tsx new file mode 100644 index 000000000000..3be1933af50c --- /dev/null +++ b/src/components/Image/BaseImage.native.tsx @@ -0,0 +1,32 @@ +import {Image as ExpoImage} from 'expo-image'; +import type {ImageLoadEventData} from 'expo-image'; +import {useCallback} from 'react'; +import type {BaseImageProps} from './types'; + +function BaseImage({onLoad, ...props}: BaseImageProps) { + const imageLoadedSuccessfully = useCallback( + (event: ImageLoadEventData) => { + if (!onLoad) { + return; + } + + // We override `onLoad`, so both web and native have the same signature + const {width, height} = event.source; + onLoad({nativeEvent: {width, height}}); + }, + [onLoad], + ); + + return ( + + ); +} + +BaseImage.displayName = 'BaseImage'; + +export default BaseImage; diff --git a/src/components/Image/BaseImage.tsx b/src/components/Image/BaseImage.tsx new file mode 100644 index 000000000000..9585b3c18e3f --- /dev/null +++ b/src/components/Image/BaseImage.tsx @@ -0,0 +1,32 @@ +import React, {useCallback} from 'react'; +import {Image as RNImage} from 'react-native'; +import type {ImageLoadEventData} from 'react-native'; +import type {BaseImageProps} from './types'; + +function BaseImage({onLoad, ...props}: BaseImageProps) { + const imageLoadedSuccessfully = useCallback( + (event: {nativeEvent: ImageLoadEventData}) => { + if (!onLoad) { + return; + } + + // We override `onLoad`, so both web and native have the same signature + const {width, height} = event.nativeEvent.source; + onLoad({nativeEvent: {width, height}}); + }, + [onLoad], + ); + + return ( + + ); +} + +BaseImage.displayName = 'BaseImage'; + +export default BaseImage; diff --git a/src/components/Image/index.native.tsx b/src/components/Image/index.native.tsx deleted file mode 100644 index 63440ca96dc0..000000000000 --- a/src/components/Image/index.native.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import {Image as ImageComponent} from 'expo-image'; -import React from 'react'; -import {withOnyx} from 'react-native-onyx'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {ImageOnyxProps, ImageProps} from './types'; - -const dimensionsCache = new Map(); - -function Image({source, isAuthTokenRequired = false, session, onLoad, ...rest}: ImageProps) { - let imageSource = source; - if (typeof source === 'object' && 'uri' in source && typeof source.uri === 'number') { - imageSource = source.uri; - } - if (typeof imageSource === 'object' && typeof source === 'object' && isAuthTokenRequired) { - const authToken = session?.encryptedAuthToken ?? null; - imageSource = { - ...source, - headers: authToken - ? { - [CONST.CHAT_ATTACHMENT_TOKEN_KEY]: authToken, - } - : undefined, - }; - } - - return ( - { - const {width, height, url} = evt.source; - dimensionsCache.set(url, {width, height}); - if (onLoad) { - onLoad({nativeEvent: {width, height}}); - } - }} - /> - ); -} - -Image.displayName = 'Image'; - -const ImageWithOnyx = withOnyx({ - session: { - key: ONYXKEYS.SESSION, - }, -})(Image); - -export default ImageWithOnyx; diff --git a/src/components/Image/index.tsx b/src/components/Image/index.tsx index a2198223c12e..6884966fb81a 100644 --- a/src/components/Image/index.tsx +++ b/src/components/Image/index.tsx @@ -1,13 +1,11 @@ -import React, {useEffect, useMemo} from 'react'; -import {Image as RNImage} from 'react-native'; +import React, {useMemo} from 'react'; import {withOnyx} from 'react-native-onyx'; -import useNetwork from '@hooks/useNetwork'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import BaseImage from './BaseImage'; import type {ImageOnyxProps, ImageOwnProps, ImageProps} from './types'; -function Image({source: propsSource, isAuthTokenRequired = false, onLoad, session, ...forwardedProps}: ImageProps) { - const {isOffline} = useNetwork(); - +function Image({source: propsSource, isAuthTokenRequired = false, session, ...forwardedProps}: ImageProps) { /** * Check if the image source is a URL - if so the `encryptedAuthToken` is appended * to the source. @@ -15,36 +13,20 @@ function Image({source: propsSource, isAuthTokenRequired = false, onLoad, sessio const source = useMemo(() => { const authToken = session?.encryptedAuthToken ?? null; if (isAuthTokenRequired && typeof propsSource === 'object' && 'uri' in propsSource && authToken) { - // 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 - return {uri: `${propsSource?.uri}?encryptedAuthToken=${encodeURIComponent(authToken)}`}; + return { + ...propsSource, + headers: { + [CONST.CHAT_ATTACHMENT_TOKEN_KEY]: authToken, + }, + }; } return propsSource; // The session prop is not required, as it causes the image to reload whenever the session changes. For more information, please refer to issue #26034. // eslint-disable-next-line react-hooks/exhaustive-deps }, [propsSource, isAuthTokenRequired]); - /** - * The natural image dimensions are retrieved using the updated source - * and as a result the `onLoad` event needs to be manually invoked to return these dimensions - */ - useEffect(() => { - // If an onLoad callback was specified then manually call it and pass - // the natural image dimensions to match the native API - if (onLoad == null) { - return; - } - - if (typeof source === 'object' && 'uri' in source && source.uri) { - RNImage.getSize(source.uri, (width, height) => { - onLoad({nativeEvent: {width, height}}); - }); - } - }, [onLoad, source, isOffline]); - return ( - ; - +type BaseImageProps = { /** The static asset or URI source of the image */ source: ExpoImageSource | Omit | ImageRequireSource | undefined; + /** Event for when the image is fully loaded and returns the natural dimensions of the image */ + onLoad?: (event: ImageOnLoadEvent) => void; +}; + +type ImageOwnProps = BaseImageProps & { + /** Styles for the Image */ + style?: StyleProp; + /** Should an auth token be included in the image request */ isAuthTokenRequired?: boolean; @@ -39,13 +44,10 @@ type ImageOwnProps = { /** Error handler */ onError?: () => void; - /** Event for when the image is fully loaded and returns the natural dimensions of the image */ - onLoad?: (event: ImageOnLoadEvent) => void; - /** Progress events while the image is downloading */ onProgress?: () => void; }; type ImageProps = ImageOnyxProps & ImageOwnProps; -export type {ImageOwnProps, ImageOnyxProps, ImageProps, ExpoImageSource, ImageOnLoadEvent}; +export type {BaseImageProps, ImageOwnProps, ImageOnyxProps, ImageProps, ExpoImageSource, ImageOnLoadEvent}; From 22a1de03246f613e45c3bd7f2b0cac29ec5c0082 Mon Sep 17 00:00:00 2001 From: Peter Velkov Date: Thu, 15 Feb 2024 09:12:45 +0200 Subject: [PATCH 3/3] Refactor: Exclude Auth Token for External Avatar Images --- src/pages/DetailsPage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/DetailsPage.tsx b/src/pages/DetailsPage.tsx index b3b0f0782ba0..49b3e856c65d 100755 --- a/src/pages/DetailsPage.tsx +++ b/src/pages/DetailsPage.tsx @@ -101,7 +101,6 @@ function DetailsPage({personalDetails, route, session}: DetailsPageProps) {