Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)$/,
Expand Down
5 changes: 5 additions & 0 deletions src/components/Image/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof CONST.IMAGE_LOADING_PRIORITY> | null;
};

type ImageOwnProps = BaseImageProps & {
Expand Down
32 changes: 28 additions & 4 deletions src/components/Lightbox/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Dimensions | undefined>();

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<Attachment, 'attachmentID'> & {
/** Whether source url requires authentication */
isAuthTokenRequired?: boolean;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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) => {
Expand All @@ -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 (
<View
testID="lightbox-wrapper"
style={[StyleSheet.absoluteFill, style]}
onLayout={updateCanvasSize}
>
Expand All @@ -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);
Expand All @@ -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 && (
<View style={StyleUtils.getFullscreenCenteredContentStyles()}>
<Image
source={{uri}}
resizeMode="contain"
style={[fallbackSize ?? styles.invisibleImage]}
isAuthTokenRequired={isAuthTokenRequired}
priority={imagePriority}
onLoad={(e) => {
updateContentSize(e);
setFallbackImageLoaded(true);
Expand Down
165 changes: 165 additions & 0 deletions tests/unit/LightboxTest.tsx

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose this is still WIP, right? I'm seeing several errors

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but today I'm going to fully publish ready to review PR

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR is ready for review :)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like we are missing tests for the NORMAL priority

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, added them in the latest commit

Original file line number Diff line number Diff line change
@@ -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),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ CONSISTENCY-5 (docs)

The eslint-disable-next-line comment here lacks a justification explaining why the rule is disabled. Please add a brief comment explaining the reason.

// eslint-disable-next-line @typescript-eslint/naming-convention -- __esModule is required by Jest to properly mock ES modules with default exports
__esModule: true,

Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

DEFAULT_ZOOM_RANGE: {min: 1, max: 5},
};
});

function createSharedValue<T>(value: T): SharedValue<T> {
return {
value,
get: jest.fn(() => value),
set: jest.fn(),
addListener: jest.fn(),
removeListener: jest.fn(),
modify: jest.fn(),
} as unknown as SharedValue<T>;
}

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(
<AttachmentCarouselPagerContext.Provider value={contextValue}>
<Lightbox
attachmentID={attachmentID}
uri={uri}
/>
</AttachmentCarouselPagerContext.Provider>,
);

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);
}
});
});
});
Loading