Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
aa1b8bc
fix: Attachment - Edit comment option is shown for .doc file
TaduJR May 10, 2026
f3df7d2
Merge branch 'main' of https://github.com/TaduJR/App into fix-Attachm…
TaduJR May 15, 2026
0d4e9d2
refactor: use htmlparser2 for attachment-only detection
TaduJR May 15, 2026
21b9a79
chore: whitelist ontext in cspell.json
TaduJR May 15, 2026
d6c224c
test: add coverage for attachment-only detection and edit gating
TaduJR May 15, 2026
d42f03f
fix: treat caption appended after attachment as attachment+text
TaduJR May 15, 2026
b091a7e
Merge branch 'main' of https://github.com/TaduJR/App into fix-Attachm…
TaduJR May 17, 2026
fabe04b
refactor: rename openAttachmentTag and add multi-attachment-only test
TaduJR May 17, 2026
791171f
perf: fast-path + memoize isReportMessageAttachment
TaduJR May 18, 2026
4edca7a
test: cover guards, translationKey semantics, and full attachment matrix
TaduJR May 18, 2026
6b0b059
fix: drop PrivatePersonalDetails merge collateral
TaduJR May 18, 2026
3b2d71d
Merge branch 'main' of https://github.com/TaduJR/App into fix-Attachm…
TaduJR May 18, 2026
db4f659
Merge branch 'main' of https://github.com/TaduJR/App into fix-Attachm…
TaduJR May 21, 2026
36ddf8e
Merge branch 'main' of https://github.com/TaduJR/App into fix-Attachm…
TaduJR May 22, 2026
cc520ca
Merge branch 'main' of https://github.com/TaduJR/App into fix-Attachm…
TaduJR May 25, 2026
9dcb9bb
Merge branch 'main' of https://github.com/TaduJR/App into fix-Attachm…
TaduJR May 25, 2026
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
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,7 @@
"onloaderror",
"onopentag",
"onplayerror",
"ontext",
"onxy",
"openxmlformats",
"ordinality",
Expand Down
13 changes: 7 additions & 6 deletions src/libs/ReportUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,6 @@ import {getMicroSecondOnyxErrorWithTranslationKey, isReceiptError} from './Error
import getAttachmentDetails from './fileDownload/getAttachmentDetails';
import type {FormulaContext} from './Formula';
import getBase62ReportID from './getBase62ReportID';
import {isReportMessageAttachment} from './isReportMessageAttachment';
import {formatPhoneNumber as formatPhoneNumberPhoneUtils} from './LocalePhoneNumber';
import {translateLocal} from './Localize';
import Log from './Log';
Expand Down Expand Up @@ -5063,13 +5062,16 @@ function canEditFieldOfMoneyRequest({
* Can only edit if:
*
* - It was written by the current user
* - It's an ADD_COMMENT that is not an attachment
* - It's an ADD_COMMENT or IOU action
* - It's not an optimistic attachment (still uploading)
* - It's an expense where conditions for modifications are defined in canEditMoneyRequest method
* - It's not pending deletion
*/
function canEditReportAction(reportAction: OnyxInputOrEntry<ReportAction>, linkedTransaction: OnyxEntry<Transaction>): boolean {
const isCommentOrIOU = reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT || reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU;
const message = reportAction ? getReportActionMessageReportUtils(reportAction) : undefined;

// Block editing only while the attachment is still uploading; once synced, attachments are editable.
const isOptimisticAttachment = !!reportAction?.isOptimisticAction && (!!reportAction?.isAttachmentOnly || !!reportAction?.isAttachmentWithText);
Comment thread
TaduJR marked this conversation as resolved.

// For money request actions on settled/approved/closed expense reports, the action cannot be edited.
// canEditMoneyRequest has an admin/manager bypass for field-level edits (via canEditFieldOfMoneyRequest),
Expand All @@ -5087,9 +5089,8 @@ function canEditReportAction(reportAction: OnyxInputOrEntry<ReportAction>, linke
return !!(
reportAction?.actorAccountID === deprecatedCurrentUserAccountID &&
isCommentOrIOU &&
(!isMoneyRequestAction(reportAction) || canEditMoneyRequest(reportAction, linkedTransaction)) && // Returns true for non-IOU actions
!isReportMessageAttachment(message) &&
((!reportAction.isAttachmentWithText && !reportAction.isAttachmentOnly) || !reportAction.isOptimisticAction) &&
(!isMoneyRequestAction(reportAction) || canEditMoneyRequest(reportAction, linkedTransaction)) &&
!isOptimisticAttachment &&
Comment thread
TaduJR marked this conversation as resolved.
!isDeletedAction(reportAction) &&
!isCreatedTaskReportAction(reportAction) &&
reportAction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE
Expand Down
89 changes: 70 additions & 19 deletions src/libs/isReportMessageAttachment.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,88 @@
import {Str} from 'expensify-common';
import {Parser as HtmlParser} from 'htmlparser2';
import CONST from '@src/CONST';
import type {Message} from '@src/types/onyx/ReportAction';

const attachmentRegex = new RegExp(` ${CONST.ATTACHMENT_SOURCE_ATTRIBUTE}="(.*)"`, 'i');
const ATTACHMENT_TAGS = new Set(['a', 'img', 'video']);
// Leading space + `="` (not a bare substring) so a URL query param like `?data-expensify-source=` isn't a false positive.
const ATTACHMENT_SOURCE_TOKEN = ` ${CONST.ATTACHMENT_SOURCE_ATTRIBUTE}="`;

/**
* Check whether a report action is Attachment or not.
* Ignore messages containing [Attachment] as the main content. Attachments are actions with only text as [Attachment].
*
* @param message report action's message as text, html and translationKey
*/
function isReportMessageAttachment(message: Message | undefined): boolean {
if (!message?.text || !message.html) {
// Keyed by Onyx message identity: re-renders pass the same immutable object, so parse at most once per message.
const resultCache = new WeakMap<Message, boolean>();

function computeIsReportMessageAttachment(html: string, text: string, translationKey: string | undefined): boolean {
// Optimistic attachment-only messages carry this translationKey; the server drops it after sync.
if (translationKey === CONST.TRANSLATION_KEYS.ATTACHMENT) {
return true;
}

// Fast path: no source attribute → not an attachment (avoids the parser for most messages).
if (!html.includes(ATTACHMENT_SOURCE_TOKEN)) {
return false;
}

if (message.translationKey) {
return message.text === CONST.ATTACHMENT_MESSAGE_TEXT && message.translationKey === CONST.TRANSLATION_KEYS.ATTACHMENT;
Comment thread
TaduJR marked this conversation as resolved.
// image/video attachment-only keep text "[Attachment]"; only .doc/.pdf (text = "filename URL") need the parser.
if (text === CONST.ATTACHMENT_MESSAGE_TEXT) {
return true;
}

const hasAttachmentHtml = attachmentRegex.test(message.html);
let hasAttachment = false;
let hasOtherContent = false;
// Depth >0 = inside the attachment's subtree; its inner nodes (filename/markup) aren't user content.
let depth = 0;

if (!hasAttachmentHtml) {
const parser = new HtmlParser({

@situchan situchan May 17, 2026

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.

Minor concern — per-call parser instantiation: A new HtmlParser is created on every call. This function is called in hot paths: PureReportActionItem (list renders), ReportActionItemFragment, OptionsListUtils (LHN). For typical volumes this is fine — the codebase already accepts this pattern in extractAttachments.ts — but worth noting for future perf profiling if list-heavy chats feel slow.

@MelvinBot suggest changes for code optimization to reduce performance impact.
Especially, early return if possible to avoid calling HtmlParser unnecessarily.

ontext: (nodeText) => {
if (depth > 0 || !nodeText.trim()) {
return;
}
hasOtherContent = true;
},
onopentag: (name, attribs) => {
if (depth > 0) {
depth += 1;
return;
}
if (name === 'br') {
return;
}
if (ATTACHMENT_TAGS.has(name) && !!attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE]) {
hasAttachment = true;
if (name === 'a' || name === 'video') {
depth = 1;
}
return;
}
hasOtherContent = true;
},
onclosetag: () => {
if (depth <= 0) {
return;
}
depth -= 1;
},
});
parser.write(html);
parser.end();

return hasAttachment && !hasOtherContent;
}

/**
* Returns true for attachment-only messages (no user-typed text), false for attachment+text and non-attachment messages.
*/
function isReportMessageAttachment(message: Message | undefined): boolean {
if (!message?.text || !message.html) {
return false;
}

const isAttachmentMessageText = message.text === CONST.ATTACHMENT_MESSAGE_TEXT;

if (isAttachmentMessageText) {
return true;
const cached = resultCache.get(message);
if (cached !== undefined) {
return cached;
}

return Str.isVideo(message.text);
const result = computeIsReportMessageAttachment(message.html, message.text, message.translationKey);
resultCache.set(message, result);
return result;
}

// eslint-disable-next-line import/prefer-default-export
Expand Down
37 changes: 37 additions & 0 deletions tests/perf-test/isReportMessageAttachment.perf-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {measureFunction} from 'reassure';
import type {Message} from '@src/types/onyx/ReportAction';
import {isReportMessageAttachment} from '../../src/libs/isReportMessageAttachment';

const SAMPLE_MESSAGES: Message[] = [
// .doc attachment-only after OpenReport: text = "filename URL" (the parser-bound case)
{
text: 'sample.doc https://www.expensify.com/chat-attachments/123/w_abc.doc',
html: '<a href="https://www.expensify.com/chat-attachments/123/w_abc.doc" data-expensify-source="https://www.expensify.com/chat-attachments/123/w_abc.doc">sample.doc</a>',
type: '',
},
// image attachment-only (translationKey path)
{
text: '[Attachment]',
html: '<img src="https://www.expensify.com/chat-attachments/123/img.jpg" data-expensify-source="https://www.expensify.com/chat-attachments/123/img.jpg" />',
type: '',
translationKey: 'common.attachment',
},
{
text: 'Here is the file\nsample.pdf https://www.expensify.com/chat-attachments/123/s.pdf',
html: 'Here is the file<br /><br /><a href="https://www.expensify.com/chat-attachments/123/s.pdf" data-expensify-source="https://www.expensify.com/chat-attachments/123/s.pdf">sample.pdf</a>',
type: '',
},
{text: 'Just a normal chat message with some words in it', html: 'Just a normal chat message with some words in it', type: ''},
{text: 'https://example.com', html: '<a href="https://example.com" data-raw-href="https://example.com" target="_blank" rel="noreferrer noopener">https://example.com</a>', type: ''},
];

// One classification pass over a long chat history (called per message on list renders / LHN).
const LONG_CHAT_HISTORY: Message[] = Array.from({length: 2000}, () => SAMPLE_MESSAGES).flat();

test('[isReportMessageAttachment] classify a 10k-message chat history', async () => {
await measureFunction(() => {
for (const message of LONG_CHAT_HISTORY) {
isReportMessageAttachment(message);
}
});
});
105 changes: 105 additions & 0 deletions tests/unit/ReportUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5745,6 +5745,111 @@ describe('ReportUtils', () => {

expect(canEditReportAction(moneyRequestAction, transaction)).toEqual(false);
});

it('should return false for an optimistic attachment-only action (still uploading)', () => {
const transaction = createRandomTransaction(300);
const reportAction: ReportAction = {
reportActionID: '300',
actorAccountID: currentUserAccountID,
actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT,
message: [
{
type: 'COMMENT',
html: '<a href="blob:..." data-expensify-source="blob:...">file.doc</a>',
text: '[Attachment]',
},
],
isAttachmentOnly: true,
isOptimisticAction: true,
created: '2025-03-05 16:34:27',
};

expect(canEditReportAction(reportAction, transaction)).toEqual(false);
});

it('should return false for an optimistic attachment+text action (still uploading)', () => {
const transaction = createRandomTransaction(301);
const reportAction: ReportAction = {
reportActionID: '301',
actorAccountID: currentUserAccountID,
actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT,
message: [
{
type: 'COMMENT',
html: 'Hi<br /><br /><a href="blob:..." data-expensify-source="blob:...">file.doc</a>',
text: 'Hi\n[Attachment]',
},
],
isAttachmentWithText: true,
isOptimisticAction: true,
created: '2025-03-05 16:34:27',
};

expect(canEditReportAction(reportAction, transaction)).toEqual(false);
});

it('should return true for a synced attachment-only action (optimistic flags cleared)', () => {
const transaction = createRandomTransaction(302);
// Post-sync state: successData clears isOptimisticAction (null in Onyx, undefined here); isAttachmentOnly persists.
const reportAction: ReportAction = {
reportActionID: '302',
actorAccountID: currentUserAccountID,
actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT,
message: [
{
type: 'COMMENT',
html: '<a href="https://www.expensify.com/chat-attachments/123/file.doc" data-expensify-source="https://www.expensify.com/chat-attachments/123/file.doc">file.doc</a>',
text: 'file.doc https://www.expensify.com/chat-attachments/123/file.doc',
},
],
isAttachmentOnly: true,
isOptimisticAction: undefined,
created: '2025-03-05 16:34:27',
};

expect(canEditReportAction(reportAction, transaction)).toEqual(true);
});

it('should return true for a synced attachment+text action', () => {
const transaction = createRandomTransaction(303);
const reportAction: ReportAction = {
reportActionID: '303',
actorAccountID: currentUserAccountID,
actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT,
message: [
{
type: 'COMMENT',
html: 'Hi<br /><br /><a href="https://www.expensify.com/chat-attachments/123/file.doc" data-expensify-source="https://www.expensify.com/chat-attachments/123/file.doc">file.doc</a>',
text: 'Hi\nfile.doc https://www.expensify.com/chat-attachments/123/file.doc',
},
],
isAttachmentWithText: true,
isOptimisticAction: undefined,
created: '2025-03-05 16:34:27',
};

expect(canEditReportAction(reportAction, transaction)).toEqual(true);
});

it('should return true for an optimistic plain-text comment (no attachment)', () => {
const transaction = createRandomTransaction(304);
const reportAction: ReportAction = {
reportActionID: '304',
actorAccountID: currentUserAccountID,
actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT,
message: [
{
type: 'COMMENT',
html: 'hello',
text: 'hello',
},
],
isOptimisticAction: true,
created: '2025-03-05 16:34:27',
};

expect(canEditReportAction(reportAction, transaction)).toEqual(true);
});
});

describe('getChatByParticipants', () => {
Expand Down
Loading
Loading