diff --git a/packages/components/src/mobile/image/index.native.js b/packages/components/src/mobile/image/index.native.js index 09b21014a04b09..0b9588eb3ac8ac 100644 --- a/packages/components/src/mobile/image/index.native.js +++ b/packages/components/src/mobile/image/index.native.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { Image as RNImage, Text, View } from 'react-native'; +import { Animated, Image as RNImage, Text, View } from 'react-native'; import FastImage from 'react-native-fast-image'; /** @@ -11,7 +11,7 @@ import { __ } from '@wordpress/i18n'; import { Icon } from '@wordpress/components'; import { image, offline } from '@wordpress/icons'; import { usePreferredColorSchemeStyle } from '@wordpress/compose'; -import { useEffect, useState, Platform } from '@wordpress/element'; +import { useEffect, useState, useRef, Platform } from '@wordpress/element'; /** * Internal dependencies @@ -54,6 +54,9 @@ const ImageComponent = ( { } ) => { const [ imageData, setImageData ] = useState( null ); const [ containerSize, setContainerSize ] = useState( null ); + const [ localURL, setLocalURL ] = useState( null ); + const [ networkURL, setNetworkURL ] = useState( null ); + const [ networkImageLoaded, setNetworkImageLoaded ] = useState( false ); // Disabled for Android due to https://github.com/WordPress/gutenberg/issues/43149 const Image = @@ -80,6 +83,33 @@ const ImageComponent = ( { onImageDataLoad( metaData ); } } ); + + if ( url.startsWith( 'file:///' ) ) { + setLocalURL( url ); + setNetworkURL( null ); + setNetworkImageLoaded( false ); + } else if ( url.startsWith( 'https://' ) ) { + if ( Platform.isIOS ) { + setNetworkURL( url ); + } else if ( Platform.isAndroid ) { + RNImage.prefetch( url ).then( + () => { + if ( ! isCurrent ) { + return; + } + setNetworkURL( url ); + setNetworkImageLoaded( true ); + }, + () => { + // This callback is called when the image fails to load, + // but these events are handled by `isUploadFailed` + // and `isUploadPaused` events instead. + // + // Ignoring the error event will persist the local image URI. + } + ); + } + } } return () => ( isCurrent = false ); // Disable reason: deferring this refactor to the native team. @@ -188,9 +218,19 @@ const ImageComponent = ( { focalPoint && styles.focalPointContainer, ]; + const opacityValue = useRef( new Animated.Value( 1 ) ).current; + + useEffect( () => { + Animated.timing( opacityValue, { + toValue: isUploadInProgress ? 0.3 : 1, + duration: 100, + useNativeDriver: true, + } ).start(); + }, [ isUploadInProgress, opacityValue ] ); + const imageStyles = [ { - opacity: isUploadInProgress ? 0.3 : 1, + opacity: opacityValue, height: containerSize?.height, }, ! resizeMode && { @@ -214,12 +254,29 @@ const ImageComponent = ( { imageHeight && { height: imageHeight }, shapeStyle, ]; + + // On iOS, add 1 to height to account for the 1px non-visible image + // that is used to determine when the network image has loaded + // We also must verify that it is not NaN, as it can be NaN when the image is loading. + // This is not necessary on Android as the non-visible image is not used. + let calculatedSelectedHeight; + if ( Platform.isIOS ) { + calculatedSelectedHeight = + containerSize && ! isNaN( containerSize.height ) + ? containerSize.height + 1 + : 0; + } else { + calculatedSelectedHeight = containerSize?.height; + } + const imageSelectedStyles = [ usePreferredColorSchemeStyle( styles.imageBorder, styles.imageBorderDark ), - { height: containerSize?.height }, + { + height: calculatedSelectedHeight, + }, ]; return ( @@ -259,14 +316,62 @@ const ImageComponent = ( { ) : ( - + { Platform.isAndroid && ( + <> + { networkImageLoaded && networkURL && ( + + ) } + { ! networkImageLoaded && ! networkURL && ( + + ) } + + ) } + { Platform.isIOS && ( + <> + + { + setNetworkImageLoaded( true ); + } } + /> + + ) } ) } diff --git a/packages/components/src/mobile/image/style.native.scss b/packages/components/src/mobile/image/style.native.scss index 040a8e507667e8..cebc097de8cdc5 100644 --- a/packages/components/src/mobile/image/style.native.scss +++ b/packages/components/src/mobile/image/style.native.scss @@ -171,3 +171,9 @@ .wide { width: 100%; } + +.nonVisibleImage { + height: 1; + width: 1; + opacity: 0; +} diff --git a/packages/edit-post/src/test/editor.native.js b/packages/edit-post/src/test/editor.native.js index 20eacc4bdf1db3..0de2c528b2452a 100644 --- a/packages/edit-post/src/test/editor.native.js +++ b/packages/edit-post/src/test/editor.native.js @@ -103,8 +103,10 @@ describe( 'Editor', () => { await initializeEditor(); // Act - await act( () => mediaAppendCallback( MEDIA[ 0 ] ) ); - await act( () => mediaAppendCallback( MEDIA[ 2 ] ) ); + act( () => mediaAppendCallback( MEDIA[ 0 ] ) ); + act( () => mediaAppendCallback( MEDIA[ 2 ] ) ); + await screen.findByTestId( `network-image-${ MEDIA[ 0 ].serverUrl }` ); + await screen.findByTestId( `network-image-${ MEDIA[ 2 ].serverUrl }` ); // Assert expect( getEditorHtml() ).toMatchSnapshot(); @@ -122,10 +124,11 @@ describe( 'Editor', () => { await initializeEditor(); // Act - await act( () => mediaAppendCallback( MEDIA[ 0 ] ) ); + act( () => mediaAppendCallback( MEDIA[ 0 ] ) ); // Unsupported type (PDF file) - await act( () => mediaAppendCallback( MEDIA[ 1 ] ) ); - await act( () => mediaAppendCallback( MEDIA[ 3 ] ) ); + act( () => mediaAppendCallback( MEDIA[ 1 ] ) ); + act( () => mediaAppendCallback( MEDIA[ 3 ] ) ); + await screen.findByTestId( `network-image-${ MEDIA[ 0 ].serverUrl }` ); // Assert expect( getEditorHtml() ).toMatchSnapshot(); diff --git a/packages/react-native-editor/CHANGELOG.md b/packages/react-native-editor/CHANGELOG.md index 7a1f6997d2a130..00eb77b12fb6aa 100644 --- a/packages/react-native-editor/CHANGELOG.md +++ b/packages/react-native-editor/CHANGELOG.md @@ -13,6 +13,7 @@ For each user feature we should also add a importance categorization label to i - [**] Image block media uploads display a custom error message when there is no internet connection [#56937] - [*] Fix missing custom color indicator for custom gradients [#57605] - [**] Display a notice when a network connection unavailable [#56934] +- [**] Prevent images from temporarily disappearing when uploading media [#57869] ## 1.110.0 - [*] [internal] Move InserterButton from components package to block-editor package [#56494] diff --git a/test/native/setup.js b/test/native/setup.js index 4fa76845d6e8e0..493f6902ee3571 100644 --- a/test/native/setup.js +++ b/test/native/setup.js @@ -284,6 +284,14 @@ jest.spyOn( Image, 'getSize' ).mockImplementation( ( url, success ) => success( 0, 0 ) ); +jest.spyOn( Image, 'prefetch' ).mockImplementation( + ( url, callback = () => {} ) => { + const mockRequestId = `mockRequestId-${ url }`; + callback( mockRequestId ); + return Promise.resolve( true ); + } +); + jest.mock( 'react-native/Libraries/Utilities/BackHandler', () => { return jest.requireActual( 'react-native/Libraries/Utilities/__mocks__/BackHandler.js'