diff --git a/src/CONST.ts b/src/CONST.ts index cd8b5d26c745b..e4d6d23c96df8 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1436,6 +1436,8 @@ const CONST = { UNKNOWN: 'unknown', }, }, + // The number of milliseconds for an idle session to expire + SESSION_EXPIRATION_TIME_MS: 2 * 3600 * 1000, // 2 hours WEEK_STARTS_ON: 1, // Monday DEFAULT_TIME_ZONE: {automatic: true, selected: 'America/Los_Angeles'}, DEFAULT_ACCOUNT_DATA: {errors: null, success: '', isLoading: false}, @@ -1551,6 +1553,7 @@ const CONST = { ATTACHMENT_PREVIEW_ATTRIBUTE: 'src', ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE: 'data-name', ATTACHMENT_LOCAL_URL_PREFIX: ['blob:', 'file:'], + ATTACHMENT_OR_RECEIPT_LOCAL_URL: /^https:\/\/(www\.)?([a-z0-9_-]+\.)*expensify.com(:[0-9]+)?\/(chat-attachments|receipts)/, ATTACHMENT_THUMBNAIL_URL_ATTRIBUTE: 'data-expensify-thumbnail-url', ATTACHMENT_THUMBNAIL_WIDTH_ATTRIBUTE: 'data-expensify-width', ATTACHMENT_THUMBNAIL_HEIGHT_ATTRIBUTE: 'data-expensify-height', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index c8e8ae582a5b2..1ade25d54c04c 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -327,7 +327,7 @@ const ROUTES = { ATTACHMENTS: { route: 'attachment', getRoute: ( - reportID: string, + reportID: string | undefined, type: ValueOf, url: string, accountID?: number, diff --git a/src/components/Attachments/AttachmentCarousel/extractAttachments.ts b/src/components/Attachments/AttachmentCarousel/extractAttachments.ts index c0010af468af1..62660980f56f8 100644 --- a/src/components/Attachments/AttachmentCarousel/extractAttachments.ts +++ b/src/components/Attachments/AttachmentCarousel/extractAttachments.ts @@ -58,7 +58,7 @@ function extractAttachments( } if (name === 'img' && attribs.src) { - const expensifySource = attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE]; + const expensifySource = attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE] ?? (new RegExp(CONST.ATTACHMENT_OR_RECEIPT_LOCAL_URL, 'i').test(attribs.src) ? attribs.src : null); const source = tryResolveUrlFromApiRoot(expensifySource || attribs.src); const previewSource = tryResolveUrlFromApiRoot(attribs.src); const sourceLinkKey = `${source}|${currentLink}`; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx index c27eef1de91e4..36b7085275e0e 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx @@ -53,7 +53,8 @@ function ImageRenderer({tnode}: ImageRendererProps) { // Concierge responder attachments are uploaded to S3 without any access // control and thus require no authToken to verify access. // - const attachmentSourceAttribute = htmlAttribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE]; + const attachmentSourceAttribute = + htmlAttribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE] ?? (new RegExp(CONST.ATTACHMENT_OR_RECEIPT_LOCAL_URL, 'i').test(htmlAttribs.src) ? htmlAttribs.src : null); const isAttachmentOrReceipt = !!attachmentSourceAttribute; // Files created/uploaded/hosted by App should resolve from API ROOT. Other URLs aren't modified @@ -105,14 +106,14 @@ function ImageRenderer({tnode}: ImageRendererProps) { } const attachmentLink = tnode.parent?.attributes?.href; - const route = ROUTES.ATTACHMENTS?.getRoute(reportID ?? '-1', type, source, accountID, isAttachmentOrReceipt, fileName, attachmentLink); + const route = ROUTES.ATTACHMENTS?.getRoute(reportID, type, source, accountID, isAttachmentOrReceipt, fileName, attachmentLink); Navigation.navigate(route); }} onLongPress={(event) => { if (isDisabled) { return; } - showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report, reportNameValuePairs)); + showContextMenuForReport(event, anchor, report?.reportID, action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report, reportNameValuePairs)); }} shouldUseHapticsOnLongPress accessibilityRole={CONST.ROLE.BUTTON} diff --git a/src/components/Image/index.tsx b/src/components/Image/index.tsx index 5fe1ba3064000..2c01ebe9b6177 100644 --- a/src/components/Image/index.tsx +++ b/src/components/Image/index.tsx @@ -1,14 +1,17 @@ -import React, {useCallback, useContext, useMemo, useState} from 'react'; -import {withOnyx} from 'react-native-onyx'; +import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import {useSession} from '@components/OnyxProvider'; +import {isExpiredSession} from '@libs/actions/Session'; +import activateReauthenticator from '@libs/actions/Session/AttachmentImageReauthenticator'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; import BaseImage from './BaseImage'; import {ImageBehaviorContext} from './ImageBehaviorContextProvider'; -import type {ImageOnLoadEvent, ImageOnyxProps, ImageOwnProps, ImageProps} from './types'; +import type {ImageOnLoadEvent, ImageProps} from './types'; -function Image({source: propsSource, isAuthTokenRequired = false, session, onLoad, objectPosition = CONST.IMAGE_OBJECT_POSITION.INITIAL, style, ...forwardedProps}: ImageProps) { +function Image({source: propsSource, isAuthTokenRequired = false, onLoad, objectPosition = CONST.IMAGE_OBJECT_POSITION.INITIAL, style, ...forwardedProps}: ImageProps) { const [aspectRatio, setAspectRatio] = useState(null); const isObjectPositionTop = objectPosition === CONST.IMAGE_OBJECT_POSITION.TOP; + const session = useSession(); const {shouldSetAspectRatioInStyle} = useContext(ImageBehaviorContext); @@ -37,6 +40,49 @@ function Image({source: propsSource, isAuthTokenRequired = false, session, onLoa }, [onLoad, updateAspectRatio], ); + + // accepted sessions are sessions of a certain criteria that we think can necessitate a reload of the images + // because images sources barely changes unless specific events occur like network issues (offline/online) per example. + // Here we target new session received less than 60s after the previous session (that could be from fresh reauthentication, the previous session was not necessarily expired) + // or new session after the previous session was expired (based on timestamp gap between the 2 creationDate and the freshness of the new session). + const isAcceptedSession = useCallback((sessionCreationDateDiff: number, sessionCreationDate: number) => { + return sessionCreationDateDiff < 60000 || (sessionCreationDateDiff >= CONST.SESSION_EXPIRATION_TIME_MS && new Date().getTime() - sessionCreationDate < 60000); + }, []); + + /** + * trying to figure out if the current session is expired or fresh from a necessary reauthentication + */ + const previousSessionAge = useRef(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const validSessionAge: number | undefined = useMemo(() => { + // Authentication is required only for certain types of images (attachments and receipts), + // so we only calculate the session age for those + if (!isAuthTokenRequired) { + return undefined; + } + if (session?.creationDate) { + if (previousSessionAge.current) { + // Most likely a reauthentication happened, but unless the calculated source is different from the previous, the image won't reload + if (isAcceptedSession(session.creationDate - previousSessionAge.current, session.creationDate)) { + return session.creationDate; + } + return previousSessionAge.current; + } + if (isExpiredSession(session.creationDate)) { + // reset the countdown to now so future sessions creationDate can be compared to that time + return new Date().getTime(); + } + return session.creationDate; + } + return undefined; + }, [session, isAuthTokenRequired, isAcceptedSession]); + useEffect(() => { + if (!isAuthTokenRequired) { + return; + } + previousSessionAge.current = validSessionAge; + }); + /** * Check if the image source is a URL - if so the `encryptedAuthToken` is appended * to the source. @@ -48,24 +94,44 @@ function Image({source: propsSource, isAuthTokenRequired = false, session, onLoa } const authToken = session?.encryptedAuthToken ?? null; if (isAuthTokenRequired && authToken) { - return { - ...propsSource, - headers: { - [CONST.CHAT_ATTACHMENT_TOKEN_KEY]: authToken, - }, - }; + if (!!session?.creationDate && !isExpiredSession(session.creationDate)) { + return { + ...propsSource, + headers: { + [CONST.CHAT_ATTACHMENT_TOKEN_KEY]: authToken, + }, + }; + } + if (session) { + activateReauthenticator(session); + } + return undefined; } } return propsSource; // The session prop is not required, as it causes the image to reload whenever the session changes. For more information, please refer to issue #26034. + // but we still need the image to reload sometimes (example : when the current session is expired) + // by forcing a recalculation of the source (which value could indeed change) through the modification of the variable validSessionAge // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, [propsSource, isAuthTokenRequired]); + }, [propsSource, isAuthTokenRequired, validSessionAge]); + useEffect(() => { + if (!isAuthTokenRequired || source !== undefined) { + return; + } + forwardedProps?.waitForSession?.(); + }, [source, isAuthTokenRequired, forwardedProps]); /** * If the image fails to load and the object position is top, we should hide the image by setting the opacity to 0. */ const shouldOpacityBeZero = isObjectPositionTop && !aspectRatio; + if (source === undefined && !!forwardedProps?.waitForSession) { + return undefined; + } + if (source === undefined) { + return ; + } return ( ({ - session: { - key: ONYXKEYS.SESSION, - }, - })(Image), - imagePropsAreEqual, -); +const ImageWithOnyx = React.memo(Image, imagePropsAreEqual); ImageWithOnyx.displayName = 'Image'; diff --git a/src/components/Image/types.ts b/src/components/Image/types.ts index 27964d8a67643..d6f2a0e51ff93 100644 --- a/src/components/Image/types.ts +++ b/src/components/Image/types.ts @@ -1,19 +1,12 @@ import type {ImageSource} from 'expo-image'; import type {ImageRequireSource, ImageResizeMode, ImageStyle, ImageURISource, StyleProp} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import type CONST from '@src/CONST'; -import type {Session} from '@src/types/onyx'; type ExpoImageSource = ImageSource | number | ImageSource[]; type ImageObjectPosition = ValueOf; -type ImageOnyxProps = { - /** Session info for the currently logged in user. */ - session: OnyxEntry; -}; - type ImageOnLoadEvent = { nativeEvent: { width: number; @@ -53,8 +46,15 @@ type ImageOwnProps = BaseImageProps & { /** The object position of image */ objectPosition?: ImageObjectPosition; + + /** + * Called when the image should wait for a valid session to reload + * At the moment this function is called, the image is not in cache anymore + * cf https://github.com/Expensify/App/issues/51888 + */ + waitForSession?: () => void; }; -type ImageProps = ImageOnyxProps & ImageOwnProps; +type ImageProps = ImageOwnProps; -export type {BaseImageProps, ImageOwnProps, ImageOnyxProps, ImageProps, ExpoImageSource, ImageOnLoadEvent, ImageObjectPosition}; +export type {BaseImageProps, ImageOwnProps, ImageProps, ExpoImageSource, ImageOnLoadEvent, ImageObjectPosition}; diff --git a/src/components/ImageView/index.tsx b/src/components/ImageView/index.tsx index 0bce2fd384321..319e81ac8c643 100644 --- a/src/components/ImageView/index.tsx +++ b/src/components/ImageView/index.tsx @@ -212,6 +212,7 @@ function ImageView({isAuthTokenRequired = false, url, fileName, onError}: ImageV /> ); } + return ( { + setIsLoading(true); + setZoomScale(0); + setIsZoomed(false); + }} onError={onError} /> diff --git a/src/components/ImageWithSizeCalculation.tsx b/src/components/ImageWithSizeCalculation.tsx index ebea1a90efcac..f4b26597fd696 100644 --- a/src/components/ImageWithSizeCalculation.tsx +++ b/src/components/ImageWithSizeCalculation.tsx @@ -111,6 +111,13 @@ function ImageWithSizeCalculation({url, altText, style, onMeasure, onLoadFailure }} onError={onError} onLoad={imageLoadedSuccessfully} + waitForSession={() => { + // Called when the image should wait for a valid session to reload + // At the moment this function is called, the image is not in cache anymore + isLoadedRef.current = false; + setIsImageCached(false); + setIsLoading(true); + }} objectPosition={objectPosition} /> {isLoading && !isImageCached && !isOffline && } diff --git a/src/components/Lightbox/index.tsx b/src/components/Lightbox/index.tsx index 9e1b007321cc5..ec0c6e5efcaaf 100644 --- a/src/components/Lightbox/index.tsx +++ b/src/components/Lightbox/index.tsx @@ -234,6 +234,14 @@ function Lightbox({isAuthTokenRequired = false, uri, onScaleChanged: onScaleChan updateContentSize(e); setLightboxImageLoaded(true); }} + waitForSession={() => { + // only active lightbox should call this function + if (!isActive || isFallbackVisible || !isLightboxVisible) { + return; + } + setContentSize(cachedImageDimensions.get(uri)); + setLightboxImageLoaded(false); + }} /> diff --git a/src/components/ShowContextMenuContext.ts b/src/components/ShowContextMenuContext.ts index 6fefa987fac39..3bd98e90eec34 100644 --- a/src/components/ShowContextMenuContext.ts +++ b/src/components/ShowContextMenuContext.ts @@ -44,7 +44,7 @@ ShowContextMenuContext.displayName = 'ShowContextMenuContext'; function showContextMenuForReport( event: GestureResponderEvent | MouseEvent, anchor: ContextMenuAnchor, - reportID: string, + reportID: string | undefined, action: OnyxEntry, checkIfContextMenuActive: () => void, isArchivedRoom = false, @@ -60,7 +60,7 @@ function showContextMenuForReport( anchor, reportID, action?.reportActionID, - ReportUtils.getOriginalReportID(reportID, action), + reportID ? ReportUtils.getOriginalReportID(reportID, action) : undefined, undefined, checkIfContextMenuActive, checkIfContextMenuActive, diff --git a/src/components/TestToolMenu.tsx b/src/components/TestToolMenu.tsx index 89f3fbc528efa..83aba3d4b61e5 100644 --- a/src/components/TestToolMenu.tsx +++ b/src/components/TestToolMenu.tsx @@ -111,6 +111,15 @@ function TestToolMenu() { /> + {/* Sends an expired session to the FE and invalidates the session by the same time in the BE. Action is delayed for 15s */} + +