diff --git a/src/libs/AttachmentAnchorUtils.ts b/src/libs/AttachmentAnchorUtils.ts
index 60367c48692a..3f40b5e0880d 100644
--- a/src/libs/AttachmentAnchorUtils.ts
+++ b/src/libs/AttachmentAnchorUtils.ts
@@ -39,6 +39,10 @@ function isOwnAttachmentURL(href: string, reportActionID: string | undefined): b
return !!reportActionID && attachmentAnchorURLRegex.test(href) && href.match(CONST.REGEX.ATTACHMENT.ATTACHMENT_SOURCE_ID)?.at(1) === reportActionID;
}
+function isFileCardAnchor(attributes: string, label: string, reportActionID: string | undefined): boolean {
+ return !holdsMedia(label) && (isAttachmentAnchorTag(attributes) || isOwnAttachmentURL(getAnchorHref(attributes) ?? '', reportActionID));
+}
+
/**
* A file anchor renders as a card, and native Text cannot lay a card out inside an inline run: it collapses, overlaps
* the line above or hides the "(edited)" label after it. Each file anchor gets its own block instead, which is how an
@@ -50,17 +54,25 @@ function wrapAttachmentAnchorsInBlocks(html: string, reportActionID: string | un
return html;
}
return html.replaceAll(ANCHOR_TAG_REGEX, (match: string, attributes: string, label: string) => {
- if (holdsMedia(label)) {
+ if (!isFileCardAnchor(attributes, label, reportActionID)) {
return match;
}
- if (isAttachmentAnchorTag(attributes)) {
- return `${label}`;
- }
- if (!isOwnAttachmentURL(getAnchorHref(attributes) ?? '', reportActionID)) {
+ const restoredID = isAttachmentAnchorTag(attributes) ? '' : ` ${CONST.ATTACHMENT_ID_ATTRIBUTE}="${reportActionID}"`;
+ return `${label}`;
+ });
+}
+
+// The parser only reads an anchor as "[Attachment]" while it carries `data-expensify-source`, which the server drops on edit.
+function replaceAttachmentAnchorsWithText(html: string, reportActionID: string | undefined): string {
+ if (!html.includes(' {
+ if (!isFileCardAnchor(attributes, label, reportActionID)) {
return match;
}
- return `${label}`;
+ return `${CONST.ATTACHMENT_MESSAGE_TEXT}${match.endsWith('
') ? '
' : ''}`;
});
}
-export {getAnchorHref, isAttachmentAnchor, isAttachmentAnchorTag, isOwnAttachmentURL, wrapAttachmentAnchorsInBlocks};
+export {getAnchorHref, isAttachmentAnchor, isAttachmentAnchorTag, isOwnAttachmentURL, replaceAttachmentAnchorsWithText, wrapAttachmentAnchorsInBlocks};
diff --git a/src/libs/ReportActionMessageUtils.ts b/src/libs/ReportActionMessageUtils.ts
index 3dddbe6da1e5..2dc98025d657 100644
--- a/src/libs/ReportActionMessageUtils.ts
+++ b/src/libs/ReportActionMessageUtils.ts
@@ -8,6 +8,7 @@ import type ReportActionName from '@src/types/onyx/ReportActionName';
import type {PartialReportAction} from './ReportUtils';
+import {replaceAttachmentAnchorsWithText} from './AttachmentAnchorUtils';
import Parser from './Parser';
import stripFollowupListFromHtml from './ReportActionFollowupUtils/stripFollowupListFromHtml';
@@ -41,8 +42,8 @@ function getReportActionText(reportAction: PartialReportAction): string {
return text ? Parser.htmlToText(text) : '';
}
-function getTextFromHtml(html?: string): string {
- return html ? Parser.htmlToText(html) : '';
+function getTextFromHtml(html?: string, reportActionID?: string): string {
+ return html ? Parser.htmlToText(replaceAttachmentAnchorsWithText(html, reportActionID)) : '';
}
export {getOriginalMessage, getReportActionHtml, getReportActionMessage, getReportActionText, getTextFromHtml};
diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts
index 0aaa542227fe..839dd2d97e18 100644
--- a/src/libs/ReportActionsUtils.ts
+++ b/src/libs/ReportActionsUtils.ts
@@ -2879,7 +2879,7 @@ function getReportActionMessageText(reportAction: OnyxEntry): stri
return getReportActionText(reportAction);
}
// Sometime html can be an empty string
- return reportAction?.message?.reduce((acc, curr) => `${acc}${getTextFromHtml(curr?.html || curr?.text)}`, '') ?? '';
+ return reportAction?.message?.reduce((acc, curr) => `${acc}${getTextFromHtml(curr?.html || curr?.text, reportAction.reportActionID)}`, '') ?? '';
}
function getAgentPromptUpdatedMessage(translate: LocalizedTranslate, reportAction: ReportAction): string {
diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts
index 1ea9111ccd68..382293f80ba5 100644
--- a/tests/unit/ReportActionsUtilsTest.ts
+++ b/tests/unit/ReportActionsUtilsTest.ts
@@ -8296,6 +8296,88 @@ describe('ReportActionsUtils', () => {
expect(ReportActionsUtils.getLatestConciergeFeedbackActionIDFromReportActions(collection, CONST.ACCOUNT_ID.CONCIERGE)).toBe('100');
});
});
+ describe('getReportActionMessageText', () => {
+ it('reads an edited file attachment as the attachment token', () => {
+ // Given an edited text-and-file comment as the server returns it, which drops data-expensify-source but keeps the attachment ID
+ const html =
+ 'test 2
renamed.csv';
+ const reportAction = {
+ reportActionID: '1',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT,
+ created: '2026-09-21 10:00:00.000',
+ message: [{type: 'COMMENT', html, text: 'test 2 renamed.csv'}],
+ } as ReportAction;
+
+ // When the text for the chat list row is read
+ const text = ReportActionsUtils.getReportActionMessageText(reportAction);
+
+ // Then the file reads as an attachment, as it does before the edit, instead of its file name
+ expect(text).toContain(CONST.ATTACHMENT_MESSAGE_TEXT);
+ expect(text).not.toContain('renamed.csv');
+ });
+
+ it('keeps a plain link as its label', () => {
+ // Given a comment with an ordinary link
+ const reportAction = {
+ reportActionID: '1',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT,
+ created: '2026-09-21 10:00:00.000',
+ message: [{type: 'COMMENT', html: 'a.csv', text: 'a.csv'}],
+ } as ReportAction;
+
+ // When the text is read
+ // Then the label is kept
+ expect(ReportActionsUtils.getReportActionMessageText(reportAction)).toBe('a.csv');
+ });
+
+ it('keeps a link to another host as its label even when it carries an attachment ID', () => {
+ // Given a file anchor edited to point at another host, which the server returns with only the attachment ID
+ const reportAction = {
+ reportActionID: '1',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT,
+ created: '2026-09-21 10:00:00.000',
+ message: [{type: 'COMMENT', html: 'see the file', text: 'see the file'}],
+ } as ReportAction;
+
+ // When the text for the chat list row is read
+ // Then the label is kept, because the chat draws it as a link and not a file card
+ expect(ReportActionsUtils.getReportActionMessageText(reportAction)).toBe('see the file');
+ });
+
+ it("reads a link to the action's own uploaded file as the attachment token", () => {
+ // Given an edited file anchor with no attachment attributes left, whose URL names the action it was uploaded to
+ const reportAction = {
+ reportActionID: '123',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT,
+ created: '2026-09-21 10:00:00.000',
+ message: [{type: 'COMMENT', html: 'renamed.csv
notes', text: 'renamed.csv\nnotes'}],
+ } as ReportAction;
+
+ // When the text for the chat list row is read
+ // Then the file reads as an attachment and the text after it stays on its own line
+ expect(ReportActionsUtils.getReportActionMessageText(reportAction)).toBe(`${CONST.ATTACHMENT_MESSAGE_TEXT}\nnotes`);
+ });
+
+ it('keeps a pasted attachment link as its label', () => {
+ // Given a typed link to an uploaded file, which the server stores without an attachment ID
+ const reportAction = {
+ reportActionID: '1',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT,
+ created: '2026-09-21 10:00:00.000',
+ message: [
+ {
+ type: 'COMMENT',
+ html: 'see old sheet',
+ text: 'see old sheet',
+ },
+ ],
+ } as ReportAction;
+
+ // When the text for the chat list row is read
+ // Then the link keeps its label, because only an uploaded file is an attachment
+ expect(ReportActionsUtils.getReportActionMessageText(reportAction)).toBe('see old sheet');
+ });
+ });
describe('getWorkspaceRulesDocumentUpdatedMessage', () => {
const buildRulesDocumentAction = (isReplacement?: boolean) =>