From ab9d07b851813095bf54f3988bb5d9fa7ff335e2 Mon Sep 17 00:00:00 2001 From: NJ-2020 Date: Wed, 18 Mar 2026 02:44:24 +0700 Subject: [PATCH 1/7] fix: adjust/add preview logic --- src/CONST/index.ts | 5 +- .../Attachments/AttachmentIDContext.tsx | 16 ++++ .../Attachments/AttachmentView/index.tsx | 5 +- .../HTMLRenderers/ImageRenderer.tsx | 27 +++--- .../useCachedImageSource/index.native.ts | 0 .../index.ts} | 55 ++++-------- src/libs/CacheAPI/index.ts | 6 +- src/libs/actions/Attachment/index.native.ts | 49 +++++++---- src/libs/actions/Attachment/index.ts | 87 ++++++++++++------- src/libs/actions/Attachment/types.ts | 11 +-- src/libs/actions/Report/index.ts | 8 +- .../AttachmentModalBaseContent/index.tsx | 1 + tests/actions/AttachmentTest.ts | 2 +- 13 files changed, 163 insertions(+), 109 deletions(-) create mode 100644 src/components/Attachments/AttachmentIDContext.tsx create mode 100644 src/hooks/useCachedImageSource/index.native.ts rename src/hooks/{useCachedImageSource.ts => useCachedImageSource/index.ts} (61%) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index d3ffa2172987..5bb4a170ad4c 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7150,10 +7150,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', @@ -9283,6 +9279,7 @@ const CONST = { CACHE_NAME: { AUTH_IMAGES: 'auth-images', + ATTACHMENTS: 'attachments', }, } as const; diff --git a/src/components/Attachments/AttachmentIDContext.tsx b/src/components/Attachments/AttachmentIDContext.tsx new file mode 100644 index 000000000000..1f4c70a3a14a --- /dev/null +++ b/src/components/Attachments/AttachmentIDContext.tsx @@ -0,0 +1,16 @@ +import React, {createContext, useMemo} from 'react'; + +type AttachmentIDContextValue = { + attachmentID?: string; +}; + +const AttachmentIDContext = createContext({ + attachmentID: undefined, +}); + +function AttachmentIDContextProvider({attachmentID, children}: AttachmentIDContextValue & {children: React.ReactNode}) { + const value = useMemo(() => ({attachmentID}), [attachmentID]); + return {children}; +} + +export {AttachmentIDContext, AttachmentIDContextProvider}; diff --git a/src/components/Attachments/AttachmentView/index.tsx b/src/components/Attachments/AttachmentView/index.tsx index b0a0cd27a51e..cecc827e4d9d 100644 --- a/src/components/Attachments/AttachmentView/index.tsx +++ b/src/components/Attachments/AttachmentView/index.tsx @@ -4,6 +4,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'; @@ -329,7 +330,7 @@ function AttachmentView({ } return ( - <> + )} - + ); } diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx index 55fd1ad0eb39..fb57c70fe7f5 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx @@ -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'; @@ -76,18 +77,20 @@ function ImageRenderer({tnode}: CustomRendererProps) { } const thumbnailImageComponent = ( - + + + ); const {anchor, report, isReportArchived, action, isDisabled, shouldDisplayContextMenu} = useShowContextMenuState(); diff --git a/src/hooks/useCachedImageSource/index.native.ts b/src/hooks/useCachedImageSource/index.native.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/hooks/useCachedImageSource.ts b/src/hooks/useCachedImageSource/index.ts similarity index 61% rename from src/hooks/useCachedImageSource.ts rename to src/hooks/useCachedImageSource/index.ts index b806c080cab1..8d00554947e6 100644 --- a/src/hooks/useCachedImageSource.ts +++ b/src/hooks/useCachedImageSource/index.ts @@ -1,7 +1,11 @@ import type {ImageSource} from 'expo-image'; -import {useEffect, useState} from 'react'; +import {useContext, useEffect, useState} from 'react'; +import {useOnyx} from 'react-native-onyx'; +import {AttachmentIDContext} from '@components/Attachments/AttachmentIDContext'; +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)) { @@ -20,62 +24,41 @@ function useCachedImageSource(source: ImageSource | undefined): ImageSource | nu const hasHeaders = typeof source === 'object' && !!source.headers; const [cachedUri, setCachedUri] = useState(null); const [hasError, setHasError] = useState(false); + const {attachmentID} = useContext(AttachmentIDContext); + const [attachment] = useOnyx(`${ONYXKEYS.COLLECTION.ATTACHMENT}${attachmentID}`); useEffect(() => { setCachedUri(null); setHasError(false); - if (!hasHeaders || !uri) { + if ((!hasHeaders && !attachmentID) || !uri) { 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((error) => { if (!revoked) { setHasError(true); } - } - })(); + const errorMessage = error.message ?? error?.toString(); + Log.hmmm(errorMessage); + }); return () => { revoked = true; @@ -87,7 +70,7 @@ function useCachedImageSource(source: ImageSource | undefined): ImageSource | nu // 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; } diff --git a/src/libs/CacheAPI/index.ts b/src/libs/CacheAPI/index.ts index 1a01275747aa..ae1da6b691e4 100644 --- a/src/libs/CacheAPI/index.ts +++ b/src/libs/CacheAPI/index.ts @@ -2,7 +2,7 @@ import type {ValueOf} from 'type-fest'; import Log from '@libs/Log'; import CONST from '@src/CONST'; -type CacheNameType = ValueOf; +type CacheNameType = ValueOf; function init() { // Exit early if the Cache API is not supported in the current browser. @@ -10,7 +10,7 @@ function init() { 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) { @@ -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); diff --git a/src/libs/actions/Attachment/index.native.ts b/src/libs/actions/Attachment/index.native.ts index a321e329ecd5..a6e5011b31e8 100644 --- a/src/libs/actions/Attachment/index.native.ts +++ b/src/libs/actions/Attachment/index.native.ts @@ -5,11 +5,19 @@ import {getImageCacheFileExtension} from '@libs/AttachmentUtils'; import Log from '@libs/Log'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {CacheAttachmentProps, GetCachedAttachmentProps, RemoveCachedAttachmentProps} from './types'; const ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/attachments`; -async function cacheAttachment({attachmentID, uri, mimeType}: CacheAttachmentProps) { +async function cacheAttachment({attachmentID, source, mimeType}: CacheAttachmentProps): Promise { + const uri = source.uri; + const isAuthRemoteAttachment = !isEmptyObject(source.headers); + + if (!uri || isAuthRemoteAttachment || !attachmentID) { + return; + } + const isLocalFile = uri.startsWith('file://'); const fileExtension = getImageCacheFileExtension(mimeType ?? ''); @@ -24,11 +32,11 @@ async function cacheAttachment({attachmentID, uri, mimeType}: CacheAttachmentPro attachmentID, source: destPath, }); + + return destPath; } catch (error) { - Log.warn('[AttachmentCache] Failed to cache attachment', {error}); + throw new Error('[AttachmentCache] Failed to cache attachment'); } - - return; } try { @@ -39,16 +47,14 @@ async function cacheAttachment({attachmentID, uri, mimeType}: CacheAttachmentPro // Exit if the attachment size is too large if (contentSize > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { - Log.warn('[AttachmentCache] Attachment is too large, skipping cache', {attachmentID, contentSize}); - return; + throw new Error('[AttachmentCache] Attachment is too large, skipping cache'); } const attachmentFileExtension = getImageCacheFileExtension(contentType ?? ''); // If attachmentFileExtension is not set properly / or doesn't exist in our lists, then we need to exit if (!attachmentFileExtension) { - Log.warn('[AttachmentCache] Unsupported file type, skipping cache', {attachmentID, contentType}); - return; + throw new Error('[AttachmentCache] Unsupported file type, skipping cache'); } const fileName = `${attachmentID}.${attachmentFileExtension}`; @@ -60,17 +66,30 @@ async function cacheAttachment({attachmentID, uri, mimeType}: CacheAttachmentPro source: filePath, remoteSource: uri, }); + + return filePath; } catch (error) { - Log.warn('[AttachmentCache] Failed to cache attachment', {error}); + throw new Error('[AttachmentCache] Failed to cache attachment'); } } -async function getCachedAttachment({attachmentID, attachment, currentSource}: GetCachedAttachmentProps) { - const isStale = attachment ? attachment?.remoteSource && attachment.remoteSource !== currentSource : false; +async function getCachedAttachment({attachmentID, attachment, source}: GetCachedAttachmentProps) { + if (isEmptyObject(source) || !source.uri || !isEmptyObject(source.headers)) { + return; + } + const imageSource = source.uri; + + if (!attachmentID) { + return imageSource; + } + + const isStale = attachment ? attachment?.remoteSource && attachment.remoteSource !== imageSource : false; if (isStale) { - // Only re-cache the [markdown-attachment] if it is outdated (updated) - cacheAttachment({attachmentID, uri: currentSource}); - return currentSource; + // Only re-cache the [markdown-attachment] if it's outdated + const cachedUri = await cacheAttachment({attachmentID, source: {uri: imageSource}}).catch(() => { + throw new Error('[AttachmentCache] Failed to re-cache markdown attachment'); + }); + return cachedUri; } const localSource = attachment?.source; @@ -78,7 +97,7 @@ async function getCachedAttachment({attachmentID, attachment, currentSource}: Ge return localSource; } - return currentSource; + return imageSource; } async function removeCachedAttachment({attachmentID, localSource}: RemoveCachedAttachmentProps): Promise { diff --git a/src/libs/actions/Attachment/index.ts b/src/libs/actions/Attachment/index.ts index 804b747f23ad..35681dc63947 100644 --- a/src/libs/actions/Attachment/index.ts +++ b/src/libs/actions/Attachment/index.ts @@ -5,82 +5,111 @@ import {isLocalFile} from '@libs/fileDownload/FileUtils'; import Log from '@libs/Log'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {CacheAttachmentProps, GetCachedAttachmentProps, RemoveCachedAttachmentProps} from './types'; -async function cacheAttachment({attachmentID, uri}: CacheAttachmentProps): Promise { +async function cacheAttachment({attachmentID, source}: CacheAttachmentProps): Promise { + const uri = source.uri; + if (!uri) { + return; + } + + const isAuthRemoteAttachment = !isEmptyObject(source.headers); + + // attachmentID is required for non-auth remote attachments + if (!isAuthRemoteAttachment && !attachmentID) { + return; + } + try { - const response = await fetch(uri); + const response = await fetch(uri, isAuthRemoteAttachment ? {headers: source.headers} : {}); if (!response.ok) { - Log.warn('[AttachmentCache] Failed to fetch attachment'); - return; + throw new Error('[AttachmentCache] Failed to fetch attachment'); } const contentType = response.headers.get('content-type') ?? ''; if (contentType === 'image/heic') { - Log.warn('[AttachmentCache] HEIC is not supported, skipping cache', {attachmentID, contentType}); - return; + throw new Error('[AttachmentCache] HEIC is not supported, skipping cache'); } const fileExtension = getImageCacheFileExtension(contentType); - - // If the image file type doesn't exist in our list, then we need to exit if (!fileExtension) { - Log.warn('[AttachmentCache] Unsupported file type, skipping cache', {attachmentID, contentType}); - return; + throw new Error('[AttachmentCache] Unsupported file type, skipping cache'); } - try { - await CacheAPI.put(CONST.CACHE_API_KEYS.ATTACHMENTS, attachmentID, response); + if (isAuthRemoteAttachment) { + await CacheAPI.put(CONST.CACHE_NAME.AUTH_IMAGES, uri, response); + } else if (attachmentID) { + await CacheAPI.put(CONST.CACHE_NAME.ATTACHMENTS, attachmentID, response); await Onyx.set(`${ONYXKEYS.COLLECTION.ATTACHMENT}${attachmentID}`, { attachmentID, remoteSource: isLocalFile(uri) ? '' : uri, }); - } catch (error) { - Log.warn('[AttachmentCache] Failed to cache attachment', {error}); } + + const cachedSource = await response.blob(); + return URL.createObjectURL(cachedSource); } catch (error) { - Log.warn('[AttachmentCache] Failed to fetch attachment', {error}); + throw new Error('[AttachmentCache] Failed to fetch attachment'); } } -async function getCachedAttachment({attachmentID, attachment, currentSource}: GetCachedAttachmentProps): Promise { - const isStale = attachment ? attachment?.remoteSource && attachment.remoteSource !== currentSource : false; - if (isStale) { - // Only re-cache the [markdown-attachment] if it is outdated (updated) - cacheAttachment({attachmentID, uri: currentSource}); - return currentSource; +async function getCachedAttachment({attachmentID, attachment, source}: GetCachedAttachmentProps): Promise { + if (isEmptyObject(source) || !source.uri) { + return; + } + const isAuthRemoteAttachment = !isEmptyObject(source.headers); + const imageSource = source.uri; + + // For non-auth remote attachments, check if the cached source is stale and re-cache if needed + if (!isAuthRemoteAttachment && attachmentID) { + const isStale = attachment?.remoteSource && attachment.remoteSource !== imageSource; + if (isStale) { + const cachedUri = await cacheAttachment({attachmentID, source: {uri: imageSource}}).catch((error) => { + Log.hmmm('[AttachmentCache] Failed to re-cache markdown attachment', {error}); + return imageSource; + }); + return cachedUri; + } } - const cachedAttachment = await CacheAPI.get(CONST.CACHE_API_KEYS.ATTACHMENTS, attachmentID); + const cacheKey = isAuthRemoteAttachment ? imageSource : attachmentID; + if (!cacheKey) { + return imageSource; + } + const cachedAttachment = await CacheAPI.get(isAuthRemoteAttachment ? CONST.CACHE_NAME.AUTH_IMAGES : CONST.CACHE_NAME.ATTACHMENTS, cacheKey); const isUncached = !cachedAttachment; if (isUncached) { - return currentSource; + const cachedUri = await cacheAttachment({attachmentID, source}).catch((error) => { + Log.hmmm('[AttachmentCache] Failed to cache attachment', {error}); + return imageSource; + }); + return cachedUri; } try { const attachmentFile = await cachedAttachment.blob(); return URL.createObjectURL(attachmentFile); } catch (error) { - Log.warn('[AttachmentCache] Failed to get attachment', {error}); - return currentSource; + throw new Error('[AttachmentCache] Failed to get cached attachment', {cause: error}); } } async function removeCachedAttachment({attachmentID}: RemoveCachedAttachmentProps) { try { - await CacheAPI.remove(CONST.CACHE_API_KEYS.ATTACHMENTS, attachmentID); + await CacheAPI.remove(CONST.CACHE_NAME.ATTACHMENTS, attachmentID); await Onyx.set(`${ONYXKEYS.COLLECTION.ATTACHMENT}${attachmentID}`, null); } catch (error) { - Log.warn('[AttachmentCache] Failed to remove cached attachment', {error}); + throw new Error('[AttachmentCache] Failed to remove cached attachment', {cause: error}); } } async function clearCachedAttachments() { try { - await CacheAPI.clear(CONST.CACHE_API_KEYS.ATTACHMENTS); + await CacheAPI.clear(); await Onyx.setCollection(ONYXKEYS.COLLECTION.ATTACHMENT, {}); } catch (error) { - Log.warn('[AttachmentCache] Failed to clear cached attachments', {error}); + throw new Error('[AttachmentCache] Failed to clear cached attachments', {cause: error}); } } diff --git a/src/libs/actions/Attachment/types.ts b/src/libs/actions/Attachment/types.ts index eb0dad30ae5d..002f94789c52 100644 --- a/src/libs/actions/Attachment/types.ts +++ b/src/libs/actions/Attachment/types.ts @@ -1,12 +1,13 @@ +import type {ImageSource} from 'expo-image'; import type {OnyxEntry} from 'react-native-onyx'; import type {Attachment} from '@src/types/onyx'; type CacheAttachmentProps = { - /** Attachment ID based on the data-attachment-id attribute */ - attachmentID: string; + /** Attachment ID based on the data-attachment-id attribute (only required for non-auth remote attachments) */ + attachmentID?: string; /** URI of the given attachment either external or local source */ - uri: string; + source: ImageSource; /** MIME type of the given attachment (native-only) */ mimeType?: string; @@ -14,13 +15,13 @@ type CacheAttachmentProps = { type GetCachedAttachmentProps = { /** Attachment ID based on the data-attachment-id attribute */ - attachmentID: string; + attachmentID?: string; /** Attachment data from Onyx */ attachment: OnyxEntry; /** Current source of the attachment */ - currentSource: string; + source: ImageSource | undefined; }; type RemoveCachedAttachmentProps = { diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 31d47f7dd603..9ee86caba922 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -731,7 +731,9 @@ function addActions({ commandName = WRITE_COMMANDS.ADD_ATTACHMENT; const attachment = buildOptimisticAddCommentReportAction({text, file, reportID, attachmentID}); attachmentAction = attachment.reportAction; - cacheAttachment({attachmentID, uri: file.uri ?? '', mimeType: file.type}); + cacheAttachment({attachmentID, source: {uri: file.uri ?? ''}, mimeType: file.type}).catch((error) => { + Log.hmmm("[AttachmentCache] Failed to cache attachment", {error}) + }) } if (text && file) { @@ -764,7 +766,9 @@ function addActions({ }); for (const attachment of attachments) { - cacheAttachment({attachmentID: attachment.attachmentID, uri: attachment.uri ?? ''}); + cacheAttachment({attachmentID: attachment.attachmentID, source: {uri: attachment.uri ?? ''}}).catch((error) => { + Log.hmmm("[AttachmentCache] Failed to cache markdown attachment", {error}) + }) } // Always prefer the file as the last action over text diff --git a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx index b30a17fcdbc5..ce7d9b628723 100644 --- a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx +++ b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx @@ -261,6 +261,7 @@ function AttachmentModalBaseContent({ { // Given the new markdown attachment link const newSourceURL = 'https://images.unsplash.com/photo-1726066012751-2adfb5485977?w=100'; - getCachedAttachment({attachmentID, attachment, currentSource: newSourceURL}); + getCachedAttachment({attachmentID, attachment, source: {uri: newSourceURL}}); await waitForBatchedUpdates(); From b0611323f37ca4a7f99012ae95bbc613425acb1d Mon Sep 17 00:00:00 2001 From: NJ-2020 Date: Thu, 19 Mar 2026 01:34:19 +0700 Subject: [PATCH 2/7] fix: native attachments are not cached --- .../useCachedImageSource/index.native.ts | 79 +++++++++++++++++++ src/libs/actions/Attachment/index.native.ts | 12 +++ 2 files changed, 91 insertions(+) diff --git a/src/hooks/useCachedImageSource/index.native.ts b/src/hooks/useCachedImageSource/index.native.ts index e69de29bb2d1..b352284b7817 100644 --- a/src/hooks/useCachedImageSource/index.native.ts +++ b/src/hooks/useCachedImageSource/index.native.ts @@ -0,0 +1,79 @@ +import type {ImageSource} from 'expo-image'; +import {useContext, useEffect, useState} from 'react'; +import {useOnyx} from 'react-native-onyx'; +import {AttachmentIDContext} from '@components/Attachments/AttachmentIDContext'; +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 [cachedUri, setCachedUri] = useState(null); + const [hasError, setHasError] = useState(false); + const {attachmentID} = useContext(AttachmentIDContext); + const [attachment] = 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; + } + + let revoked = false; + + getCachedAttachment({attachmentID, attachment, source}) + .then((cachedSource) => { + if (!cachedSource) { + if (!revoked) { + setHasError(true); + } + return; + } + if (!revoked) { + setCachedUri(cachedSource); + } + }) + .catch((error) => { + if (!revoked) { + setHasError(true); + } + const errorMessage = error.message ?? error?.toString(); + Log.hmmm(errorMessage); + }); + + 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; diff --git a/src/libs/actions/Attachment/index.native.ts b/src/libs/actions/Attachment/index.native.ts index a6e5011b31e8..10a8af14ff22 100644 --- a/src/libs/actions/Attachment/index.native.ts +++ b/src/libs/actions/Attachment/index.native.ts @@ -18,6 +18,12 @@ async function cacheAttachment({attachmentID, source, mimeType}: CacheAttachment return; } + // create attachment directory if it's not yet exists, else don't + const isAttachmentDirExists = await RNFS.exists(ATTACHMENT_DIR); + if (!isAttachmentDirExists) { + await RNFS.mkdir(ATTACHMENT_DIR); + } + const isLocalFile = uri.startsWith('file://'); const fileExtension = getImageCacheFileExtension(mimeType ?? ''); @@ -128,4 +134,10 @@ async function clearCachedAttachments(): Promise { } } +function init() { + RNFS.mkdir(ATTACHMENT_DIR).catch(() => { + console.error('[AttachmentCache] Failed to create attachment directory'); + }); +} + export {cacheAttachment, getCachedAttachment, removeCachedAttachment, clearCachedAttachments}; From 28d12ef9c2e1546bba9ae83b9df5ff425cb42442 Mon Sep 17 00:00:00 2001 From: NJ-2020 Date: Mon, 6 Apr 2026 08:07:42 +0700 Subject: [PATCH 3/7] fix: prettier & useOnyx imports --- src/hooks/useCachedImageSource/index.native.ts | 2 +- src/hooks/useCachedImageSource/index.ts | 2 +- src/libs/actions/Report/index.ts | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/hooks/useCachedImageSource/index.native.ts b/src/hooks/useCachedImageSource/index.native.ts index b352284b7817..ded3c72ac770 100644 --- a/src/hooks/useCachedImageSource/index.native.ts +++ b/src/hooks/useCachedImageSource/index.native.ts @@ -1,7 +1,7 @@ import type {ImageSource} from 'expo-image'; import {useContext, useEffect, useState} from 'react'; -import {useOnyx} from 'react-native-onyx'; 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'; diff --git a/src/hooks/useCachedImageSource/index.ts b/src/hooks/useCachedImageSource/index.ts index 8d00554947e6..ce35f9986677 100644 --- a/src/hooks/useCachedImageSource/index.ts +++ b/src/hooks/useCachedImageSource/index.ts @@ -1,7 +1,7 @@ import type {ImageSource} from 'expo-image'; import {useContext, useEffect, useState} from 'react'; -import {useOnyx} from 'react-native-onyx'; 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'; diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index d3bc72a75930..aea0ea93c0fb 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -690,8 +690,8 @@ function addActions({ const attachment = buildOptimisticAddCommentReportAction({text, file, reportID, attachmentID}); attachmentAction = attachment.reportAction; cacheAttachment({attachmentID, source: {uri: file.uri ?? ''}, mimeType: file.type}).catch((error) => { - Log.hmmm("[AttachmentCache] Failed to cache attachment", {error}) - }) + Log.hmmm('[AttachmentCache] Failed to cache attachment', {error}); + }); } if (text && file) { @@ -725,8 +725,8 @@ function addActions({ for (const attachment of attachments) { cacheAttachment({attachmentID: attachment.attachmentID, source: {uri: attachment.uri ?? ''}}).catch((error) => { - Log.hmmm("[AttachmentCache] Failed to cache markdown attachment", {error}) - }) + Log.hmmm('[AttachmentCache] Failed to cache markdown attachment', {error}); + }); } // Always prefer the file as the last action over text From 9e60a733308be5ab5cb92fee6e204c7b802393c8 Mon Sep 17 00:00:00 2001 From: NJ-2020 Date: Wed, 15 Apr 2026 11:15:57 +0700 Subject: [PATCH 4/7] fix some issues --- src/hooks/useCachedImageSource/index.ts | 10 +++++++--- src/libs/actions/Attachment/index.ts | 20 +++++++++++--------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/hooks/useCachedImageSource/index.ts b/src/hooks/useCachedImageSource/index.ts index ce35f9986677..c4a1ea5e4eaa 100644 --- a/src/hooks/useCachedImageSource/index.ts +++ b/src/hooks/useCachedImageSource/index.ts @@ -22,10 +22,10 @@ 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(null); const [hasError, setHasError] = useState(false); - const {attachmentID} = useContext(AttachmentIDContext); - const [attachment] = useOnyx(`${ONYXKEYS.COLLECTION.ATTACHMENT}${attachmentID}`); + const [attachment, attachmentMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.ATTACHMENT}${attachmentID ?? CONST.DEFAULT_NUMBER_ID}`); useEffect(() => { setCachedUri(null); @@ -35,6 +35,10 @@ function useCachedImageSource(source: ImageSource | undefined): ImageSource | nu return; } + if (attachmentMetadata.status === 'loading') { + return; + } + let revoked = false; let objectURL: string | undefined; @@ -66,7 +70,7 @@ function useCachedImageSource(source: ImageSource | undefined): ImageSource | nu 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 diff --git a/src/libs/actions/Attachment/index.ts b/src/libs/actions/Attachment/index.ts index 35681dc63947..d41da09fe4cf 100644 --- a/src/libs/actions/Attachment/index.ts +++ b/src/libs/actions/Attachment/index.ts @@ -14,9 +14,9 @@ async function cacheAttachment({attachmentID, source}: CacheAttachmentProps): Pr return; } - const isAuthRemoteAttachment = !isEmptyObject(source.headers); + const isAuthRemoteAttachment = !isEmptyObject(source.headers) && !attachmentID; - // attachmentID is required for non-auth remote attachments + // If both are empty, then return early if (!isAuthRemoteAttachment && !attachmentID) { return; } @@ -38,9 +38,9 @@ async function cacheAttachment({attachmentID, source}: CacheAttachmentProps): Pr } if (isAuthRemoteAttachment) { - await CacheAPI.put(CONST.CACHE_NAME.AUTH_IMAGES, uri, response); + await CacheAPI.put(CONST.CACHE_NAME.AUTH_IMAGES, uri, response.clone()); } else if (attachmentID) { - await CacheAPI.put(CONST.CACHE_NAME.ATTACHMENTS, attachmentID, response); + await CacheAPI.put(CONST.CACHE_NAME.ATTACHMENTS, attachmentID, response.clone()); await Onyx.set(`${ONYXKEYS.COLLECTION.ATTACHMENT}${attachmentID}`, { attachmentID, remoteSource: isLocalFile(uri) ? '' : uri, @@ -50,19 +50,20 @@ async function cacheAttachment({attachmentID, source}: CacheAttachmentProps): Pr const cachedSource = await response.blob(); return URL.createObjectURL(cachedSource); } catch (error) { - throw new Error('[AttachmentCache] Failed to fetch attachment'); + console.log('DEBUG ERROR', error); + throw new Error('[AttachmentCache] Failed to cache attachment'); } } async function getCachedAttachment({attachmentID, attachment, source}: GetCachedAttachmentProps): Promise { - if (isEmptyObject(source) || !source.uri) { + if (isEmptyObject(source) || !source.uri || source.uri.startsWith('blob:')) { return; } - const isAuthRemoteAttachment = !isEmptyObject(source.headers); + const isAuthRemoteAttachment = !isEmptyObject(source.headers) && !attachmentID; const imageSource = source.uri; // For non-auth remote attachments, check if the cached source is stale and re-cache if needed - if (!isAuthRemoteAttachment && attachmentID) { + if (!isAuthRemoteAttachment) { const isStale = attachment?.remoteSource && attachment.remoteSource !== imageSource; if (isStale) { const cachedUri = await cacheAttachment({attachmentID, source: {uri: imageSource}}).catch((error) => { @@ -73,11 +74,12 @@ async function getCachedAttachment({attachmentID, attachment, source}: GetCached } } + const cacheName = isAuthRemoteAttachment ? CONST.CACHE_NAME.AUTH_IMAGES : CONST.CACHE_NAME.ATTACHMENTS; const cacheKey = isAuthRemoteAttachment ? imageSource : attachmentID; if (!cacheKey) { return imageSource; } - const cachedAttachment = await CacheAPI.get(isAuthRemoteAttachment ? CONST.CACHE_NAME.AUTH_IMAGES : CONST.CACHE_NAME.ATTACHMENTS, cacheKey); + const cachedAttachment = await CacheAPI.get(cacheName, cacheKey); const isUncached = !cachedAttachment; if (isUncached) { const cachedUri = await cacheAttachment({attachmentID, source}).catch((error) => { From 1793308800186acf832220495dc8799cc0d8f12d Mon Sep 17 00:00:00 2001 From: NJ-2020 Date: Wed, 15 Apr 2026 17:24:52 +0700 Subject: [PATCH 5/7] fix: markdown attachment caching --- src/libs/actions/Attachment/index.ts | 38 ++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/Attachment/index.ts b/src/libs/actions/Attachment/index.ts index d41da09fe4cf..43e75bc5cbd9 100644 --- a/src/libs/actions/Attachment/index.ts +++ b/src/libs/actions/Attachment/index.ts @@ -8,6 +8,35 @@ import ONYXKEYS from '@src/ONYXKEYS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {CacheAttachmentProps, GetCachedAttachmentProps, RemoveCachedAttachmentProps} from './types'; +async function fetchExternalAttachment(source: string): Promise { + const img = await new Promise((resolve, reject) => { + const image = new Image(); + image.crossOrigin = 'anonymous'; + image.onload = () => resolve(image); + image.onerror = () => reject(new Error(`Failed to load image: ${source}`)); + image.src = source; + }); + + if (img.naturalWidth === 0 || img.naturalHeight === 0) throw new Error('Image has zero dimensions'); + + const canvas = document.createElement('canvas'); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Failed to get canvas context'); + ctx.drawImage(img, 0, 0); + + const blob = await new Promise((resolve, reject) => { + canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('Canvas toBlob returned null'))), 'image/png'); + }); + + const response = new Response(blob, { + headers: {'Content-Type': blob.type || 'image/png'}, + }); + + return response; +} + async function cacheAttachment({attachmentID, source}: CacheAttachmentProps): Promise { const uri = source.uri; if (!uri) { @@ -15,6 +44,7 @@ async function cacheAttachment({attachmentID, source}: CacheAttachmentProps): Pr } const isAuthRemoteAttachment = !isEmptyObject(source.headers) && !attachmentID; + const isMarkdownAttachment = !isEmptyObject(source.headers) && !isLocalFile(source.uri); // If both are empty, then return early if (!isAuthRemoteAttachment && !attachmentID) { @@ -22,7 +52,12 @@ async function cacheAttachment({attachmentID, source}: CacheAttachmentProps): Pr } try { - const response = await fetch(uri, isAuthRemoteAttachment ? {headers: source.headers} : {}); + let response: Response; + if (isMarkdownAttachment) { + response = await fetchExternalAttachment(uri); + } else { + response = await fetch(uri, isAuthRemoteAttachment ? {headers: source.headers} : {}); + } if (!response.ok) { throw new Error('[AttachmentCache] Failed to fetch attachment'); } @@ -50,7 +85,6 @@ async function cacheAttachment({attachmentID, source}: CacheAttachmentProps): Pr const cachedSource = await response.blob(); return URL.createObjectURL(cachedSource); } catch (error) { - console.log('DEBUG ERROR', error); throw new Error('[AttachmentCache] Failed to cache attachment'); } } From f8eae3578980cc5667967ab8e5728484d5e5d849 Mon Sep 17 00:00:00 2001 From: NJ-2020 Date: Thu, 16 Apr 2026 14:03:10 +0700 Subject: [PATCH 6/7] fix & improvement --- src/hooks/useCachedImageSource/index.native.ts | 14 +++++++++----- src/hooks/useCachedImageSource/index.ts | 10 +++++----- src/libs/actions/Attachment/index.native.ts | 14 ++++---------- src/libs/actions/Attachment/index.ts | 3 ++- 4 files changed, 20 insertions(+), 21 deletions(-) diff --git a/src/hooks/useCachedImageSource/index.native.ts b/src/hooks/useCachedImageSource/index.native.ts index ded3c72ac770..475905841b5c 100644 --- a/src/hooks/useCachedImageSource/index.native.ts +++ b/src/hooks/useCachedImageSource/index.native.ts @@ -10,10 +10,10 @@ 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(null); const [hasError, setHasError] = useState(false); - const {attachmentID} = useContext(AttachmentIDContext); - const [attachment] = useOnyx(`${ONYXKEYS.COLLECTION.ATTACHMENT}${attachmentID}`); + const [attachment, attachmentMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.ATTACHMENT}${attachmentID}`); useEffect(() => { setCachedUri(null); @@ -25,6 +25,10 @@ function useCachedImageSource(source: ImageSource | undefined): ImageSource | nu return; } + if (attachmentID && attachmentMetadata.status === 'loading') { + return; + } + let revoked = false; getCachedAttachment({attachmentID, attachment, source}) @@ -39,12 +43,12 @@ function useCachedImageSource(source: ImageSource | undefined): ImageSource | nu setCachedUri(cachedSource); } }) - .catch((error) => { + .catch(() => { if (!revoked) { setHasError(true); } - const errorMessage = error.message ?? error?.toString(); - Log.hmmm(errorMessage); + // TODO: Improve error loging + Log.hmmm('[AttachmentCache] Failed to get cached attachment'); }); return () => { diff --git a/src/hooks/useCachedImageSource/index.ts b/src/hooks/useCachedImageSource/index.ts index c4a1ea5e4eaa..7941bfdbd5f4 100644 --- a/src/hooks/useCachedImageSource/index.ts +++ b/src/hooks/useCachedImageSource/index.ts @@ -25,7 +25,7 @@ function useCachedImageSource(source: ImageSource | undefined): ImageSource | nu const {attachmentID} = useContext(AttachmentIDContext); const [cachedUri, setCachedUri] = useState(null); const [hasError, setHasError] = useState(false); - const [attachment, attachmentMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.ATTACHMENT}${attachmentID ?? CONST.DEFAULT_NUMBER_ID}`); + const [attachment, attachmentMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.ATTACHMENT}${attachmentID}`); useEffect(() => { setCachedUri(null); @@ -35,7 +35,7 @@ function useCachedImageSource(source: ImageSource | undefined): ImageSource | nu return; } - if (attachmentMetadata.status === 'loading') { + if (attachmentID && attachmentMetadata.status === 'loading') { return; } @@ -56,12 +56,12 @@ function useCachedImageSource(source: ImageSource | undefined): ImageSource | nu URL.revokeObjectURL(cachedSource); } }) - .catch((error) => { + .catch(() => { if (!revoked) { setHasError(true); } - const errorMessage = error.message ?? error?.toString(); - Log.hmmm(errorMessage); + // TODO: Improve error loging + Log.hmmm('[AttachmentCache] Failed to get cached attachment'); }); return () => { diff --git a/src/libs/actions/Attachment/index.native.ts b/src/libs/actions/Attachment/index.native.ts index 10a8af14ff22..2b6fb94fdd3b 100644 --- a/src/libs/actions/Attachment/index.native.ts +++ b/src/libs/actions/Attachment/index.native.ts @@ -18,7 +18,7 @@ async function cacheAttachment({attachmentID, source, mimeType}: CacheAttachment return; } - // create attachment directory if it's not yet exists, else don't + // Create attachment directory if it's not yet exists const isAttachmentDirExists = await RNFS.exists(ATTACHMENT_DIR); if (!isAttachmentDirExists) { await RNFS.mkdir(ATTACHMENT_DIR); @@ -98,9 +98,9 @@ async function getCachedAttachment({attachmentID, attachment, source}: GetCached return cachedUri; } - const localSource = attachment?.source; - if (localSource) { - return localSource; + const cachedSource = attachment?.source; + if (cachedSource) { + return cachedSource; } return imageSource; @@ -134,10 +134,4 @@ async function clearCachedAttachments(): Promise { } } -function init() { - RNFS.mkdir(ATTACHMENT_DIR).catch(() => { - console.error('[AttachmentCache] Failed to create attachment directory'); - }); -} - export {cacheAttachment, getCachedAttachment, removeCachedAttachment, clearCachedAttachments}; diff --git a/src/libs/actions/Attachment/index.ts b/src/libs/actions/Attachment/index.ts index 43e75bc5cbd9..1aede537933f 100644 --- a/src/libs/actions/Attachment/index.ts +++ b/src/libs/actions/Attachment/index.ts @@ -27,7 +27,7 @@ async function fetchExternalAttachment(source: string): Promise { ctx.drawImage(img, 0, 0); const blob = await new Promise((resolve, reject) => { - canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('Canvas toBlob returned null'))), 'image/png'); + canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('Canvas toBlob returned null')))); }); const response = new Response(blob, { @@ -93,6 +93,7 @@ async function getCachedAttachment({attachmentID, attachment, source}: GetCached if (isEmptyObject(source) || !source.uri || source.uri.startsWith('blob:')) { return; } + const isAuthRemoteAttachment = !isEmptyObject(source.headers) && !attachmentID; const imageSource = source.uri; From 07aac23047dd5b24c8ad1290a4b7ed8d28783380 Mon Sep 17 00:00:00 2001 From: NJ-2020 Date: Sun, 19 Apr 2026 14:04:09 +0700 Subject: [PATCH 7/7] fix attachment caching & improvement --- src/hooks/useCachedImageSource/index.ts | 2 +- src/libs/actions/Attachment/index.ts | 101 ++++++++++++++---------- 2 files changed, 62 insertions(+), 41 deletions(-) diff --git a/src/hooks/useCachedImageSource/index.ts b/src/hooks/useCachedImageSource/index.ts index 7941bfdbd5f4..96667dd63666 100644 --- a/src/hooks/useCachedImageSource/index.ts +++ b/src/hooks/useCachedImageSource/index.ts @@ -35,7 +35,7 @@ function useCachedImageSource(source: ImageSource | undefined): ImageSource | nu return; } - if (attachmentID && attachmentMetadata.status === 'loading') { + if (attachmentMetadata.status === 'loading') { return; } diff --git a/src/libs/actions/Attachment/index.ts b/src/libs/actions/Attachment/index.ts index 1aede537933f..e43cfb68f9ad 100644 --- a/src/libs/actions/Attachment/index.ts +++ b/src/libs/actions/Attachment/index.ts @@ -8,56 +8,65 @@ import ONYXKEYS from '@src/ONYXKEYS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {CacheAttachmentProps, GetCachedAttachmentProps, RemoveCachedAttachmentProps} from './types'; -async function fetchExternalAttachment(source: string): Promise { - const img = await new Promise((resolve, reject) => { - const image = new Image(); - image.crossOrigin = 'anonymous'; - image.onload = () => resolve(image); - image.onerror = () => reject(new Error(`Failed to load image: ${source}`)); - image.src = source; - }); - - if (img.naturalWidth === 0 || img.naturalHeight === 0) throw new Error('Image has zero dimensions'); - - const canvas = document.createElement('canvas'); - canvas.width = img.naturalWidth; - canvas.height = img.naturalHeight; - const ctx = canvas.getContext('2d'); - if (!ctx) throw new Error('Failed to get canvas context'); - ctx.drawImage(img, 0, 0); - - const blob = await new Promise((resolve, reject) => { - canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('Canvas toBlob returned null')))); - }); - - const response = new Response(blob, { - headers: {'Content-Type': blob.type || 'image/png'}, - }); - - return response; +let currentCachingUrl = ''; + +async function fetchExternalAttachment(source: string): Promise { + try { + const img = await new Promise((resolve, reject) => { + const image = new Image(); + image.crossOrigin = 'anonymous'; + image.onload = () => resolve(image); + image.onerror = () => { + reject(new Error(`Failed to load image: ${source}`)); + }; + image.src = source; + }); + + await img.decode(); + + if (img.naturalWidth === 0 || img.naturalHeight === 0) { + throw new Error('Image has zero dimensions'); + } + + const canvas = document.createElement('canvas'); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Failed to get canvas context'); + ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight); + + const blob = await new Promise((resolve, reject) => { + canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed')))); + }); + + return URL.createObjectURL(blob); + } catch { + throw new Error('Failed to fetch the attachment'); + } } async function cacheAttachment({attachmentID, source}: CacheAttachmentProps): Promise { - const uri = source.uri; + let uri = source.uri; if (!uri) { return; } const isAuthRemoteAttachment = !isEmptyObject(source.headers) && !attachmentID; - const isMarkdownAttachment = !isEmptyObject(source.headers) && !isLocalFile(source.uri); + const isMarkdownAttachment = isEmptyObject(source.headers) && !isLocalFile(source.uri); // If both are empty, then return early if (!isAuthRemoteAttachment && !attachmentID) { return; } + currentCachingUrl = uri; + try { - let response: Response; if (isMarkdownAttachment) { - response = await fetchExternalAttachment(uri); - } else { - response = await fetch(uri, isAuthRemoteAttachment ? {headers: source.headers} : {}); + uri = await fetchExternalAttachment(uri); } + + const response = await fetch(uri, !isEmptyObject(source.headers) ? {headers: source.headers} : {}); if (!response.ok) { throw new Error('[AttachmentCache] Failed to fetch attachment'); } @@ -72,19 +81,24 @@ async function cacheAttachment({attachmentID, source}: CacheAttachmentProps): Pr throw new Error('[AttachmentCache] Unsupported file type, skipping cache'); } + const cachedAttachment = response.clone(); + if (isAuthRemoteAttachment) { - await CacheAPI.put(CONST.CACHE_NAME.AUTH_IMAGES, uri, response.clone()); + await CacheAPI.put(CONST.CACHE_NAME.AUTH_IMAGES, uri, cachedAttachment); + currentCachingUrl = ''; } else if (attachmentID) { - await CacheAPI.put(CONST.CACHE_NAME.ATTACHMENTS, attachmentID, response.clone()); + await CacheAPI.put(CONST.CACHE_NAME.ATTACHMENTS, attachmentID, cachedAttachment); await Onyx.set(`${ONYXKEYS.COLLECTION.ATTACHMENT}${attachmentID}`, { attachmentID, - remoteSource: isLocalFile(uri) ? '' : uri, + remoteSource: isMarkdownAttachment ? source.uri : undefined, }); + currentCachingUrl = ''; } const cachedSource = await response.blob(); return URL.createObjectURL(cachedSource); } catch (error) { + currentCachingUrl = ''; throw new Error('[AttachmentCache] Failed to cache attachment'); } } @@ -94,12 +108,18 @@ async function getCachedAttachment({attachmentID, attachment, source}: GetCached return; } - const isAuthRemoteAttachment = !isEmptyObject(source.headers) && !attachmentID; const imageSource = source.uri; + const isCachingInProgress = currentCachingUrl === imageSource; - // For non-auth remote attachments, check if the cached source is stale and re-cache if needed - if (!isAuthRemoteAttachment) { - const isStale = attachment?.remoteSource && attachment.remoteSource !== imageSource; + if (isCachingInProgress) { + return; + } + + const isAuthRemoteAttachment = !isEmptyObject(source.headers) && !attachmentID; + const isMarkdownAttachment = isEmptyObject(source.headers) && !isLocalFile(source.uri); + // For markdown attachments, check if the cached source is stale and re-cache if needed + if (isMarkdownAttachment && attachment?.remoteSource) { + const isStale = attachment.remoteSource !== imageSource; if (isStale) { const cachedUri = await cacheAttachment({attachmentID, source: {uri: imageSource}}).catch((error) => { Log.hmmm('[AttachmentCache] Failed to re-cache markdown attachment', {error}); @@ -116,6 +136,7 @@ async function getCachedAttachment({attachmentID, attachment, source}: GetCached } const cachedAttachment = await CacheAPI.get(cacheName, cacheKey); const isUncached = !cachedAttachment; + console.log('isUncached', isUncached); if (isUncached) { const cachedUri = await cacheAttachment({attachmentID, source}).catch((error) => { Log.hmmm('[AttachmentCache] Failed to cache attachment', {error});