Skip to content
Draft
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
5 changes: 1 addition & 4 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7412,10 +7412,6 @@ const CONST = {
BOOK_MEETING_LINK: 'https://calendly.com/d/cqsm-2gm-fxr/expensify-product-team',
},

CACHE_API_KEYS: {
ATTACHMENTS: 'attachments',
},

SESSION_STORAGE_KEYS: {
INITIAL_URL: 'INITIAL_URL',
ACTIVE_WORKSPACE_ID: 'ACTIVE_WORKSPACE_ID',
Expand Down Expand Up @@ -9628,6 +9624,7 @@ const CONST = {

CACHE_NAME: {
AUTH_IMAGES: 'auth-images',
ATTACHMENTS: 'attachments',
},

MODAL_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO_LANDSCAPE_MODE: 0.75,
Expand Down
16 changes: 16 additions & 0 deletions src/components/Attachments/AttachmentIDContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import React, {createContext, useMemo} from 'react';

type AttachmentIDContextValue = {
attachmentID?: string;
};

const AttachmentIDContext = createContext<AttachmentIDContextValue>({
attachmentID: undefined,
});

function AttachmentIDContextProvider({attachmentID, children}: AttachmentIDContextValue & {children: React.ReactNode}) {
const value = useMemo(() => ({attachmentID}), [attachmentID]);
return <AttachmentIDContext.Provider value={value}>{children}</AttachmentIDContext.Provider>;
}

export {AttachmentIDContext, AttachmentIDContextProvider};
5 changes: 3 additions & 2 deletions src/components/Attachments/AttachmentView/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {GestureResponderEvent, ImageURISource, StyleProp, ViewStyle} from '
import {View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import {useAttachmentCarouselPagerActions} from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext';
import {AttachmentIDContextProvider} from '@components/Attachments/AttachmentIDContext';
import type {Attachment, AttachmentSource} from '@components/Attachments/types';
import Button from '@components/Button';
import DistanceEReceipt from '@components/DistanceEReceipt';
Expand Down Expand Up @@ -335,7 +336,7 @@ function AttachmentView({
}

return (
<>
<AttachmentIDContextProvider attachmentID={attachmentID}>
<View style={styles.imageModalImageCenterContainer}>
<AttachmentViewImage
// Forces remount of high resolution images when transitioning from blob URL (uploading) to server URL (uploaded).
Expand Down Expand Up @@ -363,7 +364,7 @@ function AttachmentView({
<HighResolutionInfo isUploaded={isUploaded} />
</View>
)}
</>
</AttachmentIDContextProvider>
);
}

Expand Down
27 changes: 15 additions & 12 deletions src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, {memo} from 'react';
import type {CustomRendererProps, TBlock} from 'react-native-render-html';
import {AttachmentContext} from '@components/AttachmentContext';
import {AttachmentIDContextProvider} from '@components/Attachments/AttachmentIDContext';
import {getButtonRole} from '@components/Button/utils';
import {isDeletedNode} from '@components/HTMLEngineProvider/htmlEngineUtils';
import PressableWithoutFocus from '@components/Pressable/PressableWithoutFocus';
Expand Down Expand Up @@ -76,18 +77,20 @@ function ImageRenderer({tnode}: CustomRendererProps<TBlock>) {
}

const thumbnailImageComponent = (
<ThumbnailImage
previewSourceURL={processedPreviewSource}
style={styles.webViewStyles.tagStyles.img}
isAuthTokenRequired={isAttachmentOrReceipt}
fallbackIcon={fallbackIcon}
imageWidth={imageWidth}
imageHeight={imageHeight}
isDeleted={isDeleted}
altText={alt}
fallbackIconBackground={theme.highlightBG}
fallbackIconColor={theme.border}
/>
<AttachmentIDContextProvider attachmentID={attachmentID}>
<ThumbnailImage
previewSourceURL={processedPreviewSource}
style={styles.webViewStyles.tagStyles.img}
isAuthTokenRequired={isAttachmentOrReceipt}
fallbackIcon={fallbackIcon}
imageWidth={imageWidth}
imageHeight={imageHeight}
isDeleted={isDeleted}
altText={alt}
fallbackIconBackground={theme.highlightBG}
fallbackIconColor={theme.border}
/>
</AttachmentIDContextProvider>
);

const {anchor, report, isReportArchived, action, isDisabled, shouldDisplayContextMenu, originalReportID} = useShowContextMenuState();
Expand Down
83 changes: 83 additions & 0 deletions src/hooks/useCachedImageSource/index.native.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type {ImageSource} from 'expo-image';
import {useContext, useEffect, useState} from 'react';
import {AttachmentIDContext} from '@components/Attachments/AttachmentIDContext';
import useOnyx from '@hooks/useOnyx';
import {getCachedAttachment} from '@libs/actions/Attachment';
import Log from '@libs/Log';
import ONYXKEYS from '@src/ONYXKEYS';
import {isEmptyObject} from '@src/types/utils/EmptyObject';

function useCachedImageSource(source: ImageSource | undefined): ImageSource | null | undefined {
const uri = typeof source === 'object' ? source.uri : undefined;
const hasHeaders = typeof source === 'object' && !isEmptyObject(source.headers);
const {attachmentID} = useContext(AttachmentIDContext);
const [cachedUri, setCachedUri] = useState<string | null>(null);
const [hasError, setHasError] = useState(false);
const [attachment, attachmentMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.ATTACHMENT}${attachmentID}`);

useEffect(() => {
setCachedUri(null);
setHasError(false);

// On native, expo-image handles auth headers natively — no caching needed
// Only cache non-auth attachments (images with attachmentID but no headers)
if (!attachmentID || hasHeaders || !uri) {
return;
}

if (attachmentID && attachmentMetadata.status === 'loading') {
return;
}

let revoked = false;

getCachedAttachment({attachmentID, attachment, source})
.then((cachedSource) => {
if (!cachedSource) {
if (!revoked) {
setHasError(true);
}
return;
}
if (!revoked) {
setCachedUri(cachedSource);
}
})
.catch(() => {
if (!revoked) {
setHasError(true);
}
// TODO: Improve error loging
Log.hmmm('[AttachmentCache] Failed to get cached attachment');
});

return () => {
revoked = true;
};
}, [uri, hasHeaders, attachmentID, attachment, source]);

// Auth images (with headers) are passed through directly — expo-image
// handles headers natively on React Native, so no caching needed
if (hasHeaders) {
return source;
}

// Non-auth images without attachmentID pass through as-is
if (!attachmentID) {
return source;
}

// If caching failed, fall back to the original source
if (hasError) {
return source;
}

// Cache fetch is still in progress
if (!cachedUri) {
return null;
}

return {uri: cachedUri};
}

export default useCachedImageSource;
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import type {ImageSource} from 'expo-image';
import {useEffect, useState} from 'react';
import {useContext, useEffect, useState} from 'react';
import {AttachmentIDContext} from '@components/Attachments/AttachmentIDContext';
import useOnyx from '@hooks/useOnyx';
import {getCachedAttachment} from '@libs/actions/Attachment';
import Log from '@libs/Log';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';

const clearAuthImagesCache = async () => {
if (!('caches' in window)) {
Expand All @@ -18,76 +22,59 @@ const clearAuthImagesCache = async () => {
function useCachedImageSource(source: ImageSource | undefined): ImageSource | null | undefined {
const uri = typeof source === 'object' ? source.uri : undefined;
const hasHeaders = typeof source === 'object' && !!source.headers;
const {attachmentID} = useContext(AttachmentIDContext);
const [cachedUri, setCachedUri] = useState<string | null>(null);
const [hasError, setHasError] = useState(false);
const [attachment, attachmentMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.ATTACHMENT}${attachmentID}`);

useEffect(() => {
setCachedUri(null);
setHasError(false);

if (!hasHeaders || !uri) {
if ((!hasHeaders && !attachmentID) || !uri) {
return;
}

if (attachmentMetadata.status === 'loading') {
return;
}

let revoked = false;
let objectURL: string | undefined;

(async () => {
try {
const cache = await caches.open(CONST.CACHE_NAME.AUTH_IMAGES);
const cachedResponse = await cache.match(uri);

if (cachedResponse) {
const blob = await cachedResponse.blob();
objectURL = URL.createObjectURL(blob);
if (!revoked) {
setCachedUri(objectURL);
} else {
URL.revokeObjectURL(objectURL);
}
return;
}

const response = await fetch(uri, {headers: source.headers});

if (!response.ok) {
getCachedAttachment({attachmentID, attachment, source})
.then((cachedSource) => {
if (!cachedSource) {
if (!revoked) {
setHasError(true);
}
return;
}

// Store in cache before consuming
await cache.put(uri, response.clone());

const blob = await response.blob();
objectURL = URL.createObjectURL(blob);
if (!revoked) {
setCachedUri(objectURL);
setCachedUri(cachedSource);
} else {
URL.revokeObjectURL(objectURL);
}
} catch (error) {
if (error instanceof DOMException && error.name === 'QuotaExceededError') {
await clearAuthImagesCache();
URL.revokeObjectURL(cachedSource);
}
})
.catch(() => {
if (!revoked) {
setHasError(true);
}
}
})();
// TODO: Improve error loging
Log.hmmm('[AttachmentCache] Failed to get cached attachment');
});

return () => {
revoked = true;
if (objectURL) {
URL.revokeObjectURL(objectURL);
}
};
}, [uri, hasHeaders, source?.headers]);
}, [uri, hasHeaders, source?.headers, attachment, attachmentMetadata.status, attachmentID, source]);

// Images without headers are cached natively by the browser,
// so pass them through as-is — no Cache API needed
if (!hasHeaders) {
if (!hasHeaders && !attachmentID) {
return source;
}

Expand Down
6 changes: 3 additions & 3 deletions src/libs/CacheAPI/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ import type {ValueOf} from 'type-fest';
import Log from '@libs/Log';
import CONST from '@src/CONST';

type CacheNameType = ValueOf<typeof CONST.CACHE_API_KEYS>;
type CacheNameType = ValueOf<typeof CONST.CACHE_NAME>;

function init() {
// Exit early if the Cache API is not supported in the current browser.
if (!('caches' in window)) {
Log.warn('Cache API is not supported');
return;
}
const keys = Object.values(CONST.CACHE_API_KEYS);
const keys = Object.values(CONST.CACHE_NAME);
for (const key of keys) {
caches.has(key).then((isExist) => {
if (isExist) {
Expand Down Expand Up @@ -39,7 +39,7 @@ function clear(cacheName?: CacheNameType) {
return caches.delete(cacheName);
}

const keys = Object.values(CONST.CACHE_API_KEYS);
const keys = Object.values(CONST.CACHE_NAME);
const deletePromises = keys.map((key) => caches.delete(key));

return Promise.all(deletePromises);
Expand Down
Loading
Loading