Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {getMicroSecondOnyxErrorObject, getMicroSecondOnyxErrorWithTranslationKey
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute';
import {isGroupPolicyByType} from '@libs/PolicyUtils';
import ReceiptStorage from '@libs/ReceiptStorage';
import {getThumbnailAndImageURIs} from '@libs/ReceiptUtils';
import {getOriginalMessage, isMoneyRequestAction, wasActionTakenByCurrentUser} from '@libs/ReportActionsUtils';
import {isMarkAsCashActionForTransaction} from '@libs/ReportPrimaryActionUtils';
Expand Down Expand Up @@ -222,7 +223,7 @@ function MoneyRequestReceiptView({
}
}, [isLoading, hoverBind]);

const displayedReceiptSource = transaction?.receipt?.localSource ?? transaction?.receipt?.source;
const displayedReceiptSource = ReceiptStorage.resolve(transaction?.receipt?.localSource) ?? transaction?.receipt?.source;
const prevDisplayedReceiptSource = usePrevious(displayedReceiptSource);

useEffect(() => {
Expand Down
2 changes: 2 additions & 0 deletions src/components/ReportActionItem/ReportActionItemImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,8 @@ function ReportActionItemImage({
shouldUseThumbnailImage: shouldUseThumbnailImage ?? true,
isAuthTokenRequired: false,
source: shouldUseThumbnailImage ? (effectiveThumbnail ?? effectiveImage ?? '') : originalImageSource,
fallbackIcon: icons.Receipt,
fallbackIconSize: isSingleImage ? variables.iconSizeSuperLarge : variables.iconSizeExtraLarge,

// If the image is full height, use initial position to make sure it will grow properly to fill the container
shouldUseInitialObjectPosition: isMapDistanceRequest && !shouldUseFullHeight,
Expand Down
4 changes: 3 additions & 1 deletion src/libs/ReceiptStorage/index.native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ const adopt: ReceiptStorage['adopt'] = async (uriOrPath, fileName) => {

const toLocalUri: ReceiptStorage['toLocalUri'] = (durableName) => `file://${getReceiptsUploadFolderPath()}/${durableName}`;

const retain: ReceiptStorage['retain'] = () => {};

const resolve: ReceiptStorage['resolve'] = (source) => {
if (typeof source !== 'string') {
return undefined;
Expand All @@ -63,6 +65,6 @@ const resolve: ReceiptStorage['resolve'] = (source) => {
return durableName ? toLocalUri(durableName) : source;
};

const receiptStorage: ReceiptStorage = {adopt, toLocalUri, resolve};
const receiptStorage: ReceiptStorage = {adopt, toLocalUri, retain, resolve};

export default receiptStorage;
36 changes: 34 additions & 2 deletions src/libs/ReceiptStorage/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,42 @@
import type ReceiptStorage from './types';

/** Web has no filesystem to move receipts into, and a blob URL already lives as long as the document. */
/**
* Web has no filesystem to move receipts into. A blob:/file: object URL is only valid for this
* document. Onyx may still restore the string after a reload, but the Blob is gone. Track the
* URIs this document created so resolve() can refuse stale ones and callers fall back to the
* server receipt.
*
* Prefer a blob:/file: prefix check over isLocalFile(): that helper also matches root-relative
* remote URLs like `/staging/chat-attachments/...`.
*/
const sessionLocalSources = new Set<string>();

function isSessionLocalUri(source: string): boolean {
return source.startsWith('blob:') || source.startsWith('file:');
}

const retain: ReceiptStorage['retain'] = (source) => {
if (!isSessionLocalUri(source)) {
return;
}
sessionLocalSources.add(source);
};

const resolve: ReceiptStorage['resolve'] = (source) => {
if (typeof source !== 'string') {
return undefined;
}
if (isSessionLocalUri(source)) {
return sessionLocalSources.has(source) ? source : undefined;
}
return source;
};

const receiptStorage: ReceiptStorage = {
adopt: (uriOrPath) => Promise.resolve(uriOrPath),
toLocalUri: (durableName) => durableName,
resolve: (source) => (typeof source === 'string' ? source : undefined),
retain,
resolve,
};

export default receiptStorage;
6 changes: 6 additions & 0 deletions src/libs/ReceiptStorage/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ type ReceiptStorage = {
/** Valid for this launch only, so never store the result. */
toLocalUri: (durableName: string) => string;

/**
* Claims a local source that is about to be stored in Onyx so resolve() will accept it this session.
* No-op on native (files are durable). On web, object URLs die with the document.
*/
retain: (source: string) => void;

/** Re-roots a stored source onto the current folder. A remote source passes through unchanged. */
resolve: (source: ReceiptSource | null | undefined) => string | undefined;
};
Expand Down
5 changes: 4 additions & 1 deletion src/libs/actions/IOU/MoneyRequestBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {buildOptimisticNextStep} from '@libs/NextStepUtils';
import {rand64} from '@libs/NumberUtils';
import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber';
import {getDistanceRateCustomUnit, hasDependentTags, isGroupPolicy} from '@libs/PolicyUtils';
import ReceiptStorage from '@libs/ReceiptStorage';
import {getOriginalMessage, getReportActionHtml, getReportActionText, isReportPreviewAction} from '@libs/ReportActionsUtils';
import type {OptimisticChatReport, OptimisticCreatedReportAction, OptimisticIOUReportAction} from '@libs/ReportUtils';
import {
Expand Down Expand Up @@ -444,7 +445,9 @@ function buildOnyxDataForTestDriveIOU(

function getTransactionWithPreservedLocalReceiptSource(transaction: OnyxTypes.Transaction, isScanRequest: boolean): OnyxTypes.Transaction {
if (isScanRequest && isLocalFile(transaction.receipt?.source)) {
return {...transaction, receipt: {...transaction.receipt, localSource: String(transaction.receipt?.source)}};
const localSource = String(transaction.receipt?.source);
ReceiptStorage.retain(localSource);
return {...transaction, receipt: {...transaction.receipt, localSource}};
}
return transaction;
}
Expand Down
37 changes: 37 additions & 0 deletions tests/unit/libs/receiptStorageTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ jest.mock('@libs/getReceiptsUploadFolderPath', () => ({

// Import the native implementation by path. Jest resolves the bare specifier to the web implementation.
const {default: ReceiptStorage}: {default: ReceiptStorageType} = jest.requireActual('@libs/ReceiptStorage/index.native.ts');
const {default: WebReceiptStorage}: {default: ReceiptStorageType} = jest.requireActual('@libs/ReceiptStorage/index.ts');

describe('ReceiptStorage', () => {
beforeEach(() => {
Expand Down Expand Up @@ -103,4 +104,40 @@ describe('ReceiptStorage', () => {
expect(ReceiptStorage.resolve('https://www.expensify.com/receipts/w_9.jpg')).toBe('https://www.expensify.com/receipts/w_9.jpg');
});
});

describe('retain (native)', () => {
it('is a no-op that does not change resolve behaviour', () => {
const path = `file://${FOLDER}/receipt_9.jpg`;
ReceiptStorage.retain(path);
expect(ReceiptStorage.resolve(path)).toBe(path);
});
});
});

describe('ReceiptStorage (web)', () => {
it('returns a retained blob: URL from resolve for the rest of the session', () => {
const blobURL = `blob:https://new.expensify.com/${Math.random()}`;
WebReceiptStorage.retain(blobURL);

expect(WebReceiptStorage.resolve(blobURL)).toBe(blobURL);
});

it('returns undefined for a blob: URL that was never retained this session (e.g. restored from Onyx after reload)', () => {
expect(WebReceiptStorage.resolve('blob:https://new.expensify.com/stale-from-previous-document')).toBeUndefined();
});

it('returns undefined for a file: URL that was never retained this session', () => {
expect(WebReceiptStorage.resolve('file:///tmp/stale-receipt.jpg')).toBeUndefined();
});

it('passes remote and root-relative sources through without requiring retain', () => {
expect(WebReceiptStorage.resolve('https://www.expensify.com/receipts/w_9.jpg')).toBe('https://www.expensify.com/receipts/w_9.jpg');
expect(WebReceiptStorage.resolve('/staging/chat-attachments/abc.jpg')).toBe('/staging/chat-attachments/abc.jpg');
});

it('ignores retain for non-session-local URIs', () => {
const remote = 'https://www.expensify.com/receipts/w_9.jpg';
WebReceiptStorage.retain(remote);
expect(WebReceiptStorage.resolve(remote)).toBe(remote);
});
});
Loading