diff --git a/src/CONST/index.ts b/src/CONST/index.ts index ed32f1906578..4a874f7e37be 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2201,6 +2201,12 @@ const CONST = { INITIAL: 'initial', }, + IMAGE_LOADING_PRIORITY: { + LOW: 'low', + NORMAL: 'normal', + HIGH: 'high', + }, + FILE_TYPE_REGEX: { // Image MimeTypes allowed by iOS photos app. IMAGE: /\.(jpg|jpeg|png|webp|gif|tiff|bmp|heic|heif)$/, diff --git a/src/components/Image/types.ts b/src/components/Image/types.ts index 7a57250b9848..5dc10f1b90be 100644 --- a/src/components/Image/types.ts +++ b/src/components/Image/types.ts @@ -27,6 +27,11 @@ type BaseImageProps = { /** The image cache policy */ cachePolicy?: ImagePrefetchOptions['cachePolicy']; + + /** Priorities for completing loads. If more than one load is queued at a time, + * the load with the higher priority will be started first. + * Maps to SDWebImageHighPriority (iOS) and Glide.Priority.IMMEDIATE (Android). */ + priority?: ValueOf | null; }; type ImageOwnProps = BaseImageProps & { diff --git a/src/components/Lightbox/index.tsx b/src/components/Lightbox/index.tsx index ee6329027e1f..181a3cb6f88f 100644 --- a/src/components/Lightbox/index.tsx +++ b/src/components/Lightbox/index.tsx @@ -20,8 +20,20 @@ import CONST from '@src/CONST'; import type {Dimensions} from '@src/types/utils/Layout'; import NUMBER_OF_CONCURRENT_LIGHTBOXES from './numberOfConcurrentLightboxes'; +const FALLBACK_OFFSET = 2; + const cachedImageDimensions = new Map(); +function getImagePriority(isActive: boolean, isLightboxVisible: boolean) { + if (isActive) { + return CONST.IMAGE_LOADING_PRIORITY.HIGH; + } + if (isLightboxVisible) { + return CONST.IMAGE_LOADING_PRIORITY.NORMAL; + } + return CONST.IMAGE_LOADING_PRIORITY.LOW; +} + type LightboxProps = Pick & { /** Whether source url requires authentication */ isAuthTokenRequired?: boolean; @@ -144,10 +156,16 @@ function Lightbox({attachmentID, isAuthTokenRequired = false, uri, onScaleChange const indexOutOfRange = page > activePage + indexCanvasOffset || page < activePage - indexCanvasOffset; return !indexOutOfRange; }, [activePage, hasSiblingCarouselItems, page]); + + // Limits fallback image rendering to only a few pages around the active page. + // This prevents distant carousel items from queuing unnecessary image downloads, + // which would starve the active image of network bandwidth. + const isFallbackInRange = !hasSiblingCarouselItems || Math.abs(page - activePage) <= FALLBACK_OFFSET; + const [isLightboxImageLoaded, setLightboxImageLoaded] = useState(false); const [isLoading, setIsLoading] = useState(true); - const [isFallbackVisible, setFallbackVisible] = useState(!isLightboxVisible); + const [isFallbackVisible, setFallbackVisible] = useState(!isLightboxVisible && isFallbackInRange); const [isFallbackImageLoaded, setFallbackImageLoaded] = useState(false); const previousUri = usePrevious(uri); @@ -214,10 +232,11 @@ function Lightbox({attachmentID, isAuthTokenRequired = false, uri, onScaleChange } // If the carousel item has become inactive and the lightbox is not continued to be rendered, we want to show the fallback image + // but only if the page is within the fallback range to avoid unnecessary image downloads if (!isActive && !isLightboxVisible) { - setFallbackVisible(true); + setFallbackVisible(isFallbackInRange); } - }, [hasSiblingCarouselItems, isActive, isFallbackVisible, isLightboxImageLoaded, isLightboxVisible]); + }, [hasSiblingCarouselItems, isActive, isFallbackInRange, isFallbackVisible, isLightboxImageLoaded, isLightboxVisible]); const scaleChange = useCallback( (scale: number) => { @@ -227,11 +246,14 @@ function Lightbox({attachmentID, isAuthTokenRequired = false, uri, onScaleChange [onScaleChangedContext, onScaleChangedProp], ); + const imagePriority = getImagePriority(isActive, isLightboxVisible); + const isALocalFile = isLocalFile(uri); const shouldShowOfflineIndicator = isOffline && !isLoading && !isALocalFile; return ( @@ -257,6 +279,7 @@ function Lightbox({attachmentID, isAuthTokenRequired = false, uri, onScaleChange source={{uri}} style={[contentSize ?? styles.invisibleImage]} isAuthTokenRequired={isAuthTokenRequired} + priority={imagePriority} onError={onError} onLoad={(e) => { updateContentSize(e); @@ -279,13 +302,14 @@ function Lightbox({attachmentID, isAuthTokenRequired = false, uri, onScaleChange )} {/* Keep rendering the image without gestures as fallback if the carousel item is not active and while the lightbox is loading the image */} - {isFallbackVisible && ( + {isFallbackVisible && isFallbackInRange && ( { updateContentSize(e); setFallbackImageLoaded(true); diff --git a/tests/unit/LightboxTest.tsx b/tests/unit/LightboxTest.tsx new file mode 100644 index 000000000000..1f70d2180781 --- /dev/null +++ b/tests/unit/LightboxTest.tsx @@ -0,0 +1,165 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; +import React from 'react'; +import type {View as RNView} from 'react-native'; +import type {SharedValue} from 'react-native-reanimated'; +import AttachmentCarouselPagerContext from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext'; +import type {AttachmentCarouselPagerContextValue} from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext'; +import Lightbox from '@components/Lightbox'; +import CONST from '@src/CONST'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +const TEST_URI = 'https://example.com/image.png'; + +jest.mock('@components/Image', () => { + const MockReact = require('react') as typeof React; + const {View} = require('react-native') as {View: typeof RNView}; + function MockImage({priority, testID, ...props}: {priority?: string; testID?: string}) { + return MockReact.createElement(View, { + testID: testID ?? 'image', + accessibilityHint: priority ?? 'none', + ...props, + }); + } + return { + // eslint-disable-next-line @typescript-eslint/naming-convention -- __esModule is required by Jest to properly mock ES modules with default exports + __esModule: true, + default: MockReact.memo(MockImage), + }; +}); + +jest.mock('@components/Lightbox/numberOfConcurrentLightboxes', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention -- __esModule is required by Jest to properly mock ES modules with default exports + __esModule: true, + default: 3, +})); + +jest.mock('@components/MultiGestureCanvas', () => { + const MockReact = require('react') as typeof React; + const {View} = require('react-native') as {View: typeof RNView}; + return { + // eslint-disable-next-line @typescript-eslint/naming-convention -- __esModule is required by Jest to properly mock ES modules with default exports + __esModule: true, + default: ({children}: {children: React.ReactNode}) => MockReact.createElement(View, {testID: 'multi-gesture-canvas'}, children), + DEFAULT_ZOOM_RANGE: {min: 1, max: 5}, + }; +}); + +function createSharedValue(value: T): SharedValue { + return { + value, + get: jest.fn(() => value), + set: jest.fn(), + addListener: jest.fn(), + removeListener: jest.fn(), + modify: jest.fn(), + } as unknown as SharedValue; +} + +function createPagerItems(count: number) { + return Array.from({length: count}, (_, i) => ({ + source: `https://example.com/image-${i}.png`, + previewSource: `https://example.com/image-${i}-preview.png`, + index: i, + isActive: false, + attachmentID: `attachment-${i}`, + })); +} + +function createContextValue(activePage: number, itemCount: number): AttachmentCarouselPagerContextValue { + return { + pagerItems: createPagerItems(itemCount), + activePage, + isPagerScrolling: createSharedValue(false), + isScrollEnabled: createSharedValue(true), + pagerRef: {current: null}, + onTap: jest.fn(), + onScaleChanged: jest.fn(), + onSwipeDown: jest.fn(), + }; +} + +async function renderLightboxInCarousel(attachmentID: string, uri: string, contextValue: AttachmentCarouselPagerContextValue) { + render( + + + , + ); + + await waitForBatchedUpdatesWithAct(); + + fireEvent(screen.getByTestId('lightbox-wrapper'), 'onLayout', { + nativeEvent: {layout: {width: 400, height: 800}}, + }); + + await waitForBatchedUpdatesWithAct(); +} + +describe('Lightbox', () => { + describe('fallback rendering range', () => { + it('should not render any image for distant pages outside FALLBACK_OFFSET range', async () => { + const contextValue = createContextValue(15, 30); + + await renderLightboxInCarousel('attachment-0', TEST_URI, contextValue); + + expect(screen.queryAllByTestId('image')).toHaveLength(0); + }); + + it('should render fallback image for pages within FALLBACK_OFFSET range', async () => { + const contextValue = createContextValue(15, 30); + + await renderLightboxInCarousel('attachment-13', TEST_URI, contextValue); + + expect(screen.getAllByTestId('image').length).toBeGreaterThan(0); + }); + + it('should render lightbox image for the active page', async () => { + const contextValue = createContextValue(15, 30); + + await renderLightboxInCarousel('attachment-15', TEST_URI, contextValue); + + expect(screen.getByTestId('multi-gesture-canvas')).toBeTruthy(); + expect(screen.getAllByTestId('image').length).toBeGreaterThan(0); + }); + }); + + describe('image priority', () => { + it('should assign HIGH priority to the active page image', async () => { + const contextValue = createContextValue(5, 30); + + await renderLightboxInCarousel('attachment-5', TEST_URI, contextValue); + + const images = screen.getAllByTestId('image'); + expect(images.length).toBeGreaterThan(0); + for (const image of images) { + expect(image.props.accessibilityHint).toBe(CONST.IMAGE_LOADING_PRIORITY.HIGH); + } + }); + + it('should assign NORMAL priority to non-active pages within the lightbox visible range', async () => { + const contextValue = createContextValue(5, 30); + + await renderLightboxInCarousel('attachment-4', TEST_URI, contextValue); + + const images = screen.getAllByTestId('image'); + expect(images.length).toBeGreaterThan(0); + for (const image of images) { + expect(image.props.accessibilityHint).toBe(CONST.IMAGE_LOADING_PRIORITY.NORMAL); + } + }); + + it('should assign LOW priority to fallback images outside the lightbox window', async () => { + const contextValue = createContextValue(15, 30); + + await renderLightboxInCarousel('attachment-13', TEST_URI, contextValue); + + const images = screen.getAllByTestId('image'); + expect(images.length).toBeGreaterThan(0); + for (const image of images) { + expect(image.props.accessibilityHint).toBe(CONST.IMAGE_LOADING_PRIORITY.LOW); + } + }); + }); +});