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) =>