From ae0511b069114fdf8b542269b384ee268e22a60b Mon Sep 17 00:00:00 2001 From: Mukher Date: Thu, 1 Oct 2026 15:19:10 +0500 Subject: [PATCH 1/3] Show an edited file attachment as [Attachment] in the chat list The server drops data-expensify-source from a file link when the comment is edited and keeps only data-attachment-id, so the parser read the link as plain text and the row showed the file name. The row text and the attachment-only check now also accept data-attachment-id. A typed link to a chat-attachments URL never gets that attribute, so it keeps its label. --- src/libs/ReportActionMessageUtils.ts | 15 +++++- src/libs/isReportMessageAttachment.ts | 8 +-- tests/unit/ReportActionsUtilsTest.ts | 54 +++++++++++++++++++++ tests/unit/isReportMessageAttachmentTest.ts | 39 +++++++++++++++ 4 files changed, 112 insertions(+), 4 deletions(-) diff --git a/src/libs/ReportActionMessageUtils.ts b/src/libs/ReportActionMessageUtils.ts index 3316275afcf5..ea231e965732 100644 --- a/src/libs/ReportActionMessageUtils.ts +++ b/src/libs/ReportActionMessageUtils.ts @@ -1,6 +1,7 @@ /** * Readers for a report action's message: pull the message entry, its html/text, and the normalized original message. */ +import CONST from '@src/CONST'; import type {OnyxInputOrEntry} from '@src/types/onyx'; import type ReportAction from '@src/types/onyx/ReportAction'; import type {OriginalMessage} from '@src/types/onyx/ReportAction'; @@ -42,8 +43,20 @@ function getReportActionText(reportAction: PartialReportAction): string { return text ? Parser.htmlToText(text) : ''; } +const ATTACHMENT_ID_TOKEN = ` ${CONST.ATTACHMENT_ID_ATTRIBUTE}="`; +const ANCHOR_TAG_REGEX = /]*)>[\s\S]*?<\/a>/gi; + +// The parser only turns an anchor into "[Attachment]" while it still carries `data-expensify-source`, which the server drops on edit. +// The attachment ID survives the edit and is never set on a typed link, so it alone decides, not the URL. +function replaceAttachmentAnchorsWithText(html: string): string { + if (!html.includes(ATTACHMENT_ID_TOKEN)) { + return html; + } + return html.replaceAll(ANCHOR_TAG_REGEX, (match: string, attributes: string) => (` ${attributes}`.includes(ATTACHMENT_ID_TOKEN) ? CONST.ATTACHMENT_MESSAGE_TEXT : match)); +} + function getTextFromHtml(html?: string): string { - return html ? Parser.htmlToText(html) : ''; + return html ? Parser.htmlToText(replaceAttachmentAnchorsWithText(html)) : ''; } export {getOriginalMessage, getReportActionHtml, getReportActionMessage, getReportActionText, getTextFromHtml}; diff --git a/src/libs/isReportMessageAttachment.ts b/src/libs/isReportMessageAttachment.ts index 8b38d6fa9296..98788d47b738 100644 --- a/src/libs/isReportMessageAttachment.ts +++ b/src/libs/isReportMessageAttachment.ts @@ -6,6 +6,8 @@ import {Parser as HtmlParser} from 'htmlparser2'; 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}="`; +// The server re-serializes an edited comment without `data-expensify-source` but keeps the attachment ID on the anchor. +const ATTACHMENT_ID_TOKEN = ` ${CONST.ATTACHMENT_ID_ATTRIBUTE}="`; // Keyed by Onyx message identity: re-renders pass the same immutable object, so parse at most once per message. const resultCache = new WeakMap(); @@ -16,8 +18,8 @@ function computeIsReportMessageAttachment(html: string, text: string, translatio return true; } - // Fast path: no source attribute → not an attachment (avoids the parser for most messages). - if (!html.includes(ATTACHMENT_SOURCE_TOKEN)) { + // Fast path: no attachment marker → not an attachment (avoids the parser for most messages). + if (!html.includes(ATTACHMENT_SOURCE_TOKEN) && !html.includes(ATTACHMENT_ID_TOKEN)) { return false; } @@ -46,7 +48,7 @@ function computeIsReportMessageAttachment(html: string, text: string, translatio if (name === 'br') { return; } - if (ATTACHMENT_TAGS.has(name) && !!attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE]) { + if (ATTACHMENT_TAGS.has(name) && (!!attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE] || (name === 'a' && !!attribs[CONST.ATTACHMENT_ID_ATTRIBUTE]))) { hasAttachment = true; if (name === 'a' || name === 'video') { depth = 1; diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 7e59b93229c1..3b5a0486f543 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -7420,4 +7420,58 @@ describe('ReportActionsUtils', () => { expect(ReportActionsUtils.getLatestConciergeFeedbackActionID([], persisted([]))).toBeUndefined(); }); }); + 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 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'); + }); + }); }); diff --git a/tests/unit/isReportMessageAttachmentTest.ts b/tests/unit/isReportMessageAttachmentTest.ts index 140faea6aa5e..4f408b36b1d5 100644 --- a/tests/unit/isReportMessageAttachmentTest.ts +++ b/tests/unit/isReportMessageAttachmentTest.ts @@ -24,6 +24,45 @@ describe('isReportMessageAttachment', () => { expect(isReportMessageAttachment(message)).toBe(false); }); + it('recognizes a file anchor the server re-serialized without the source attribute', () => { + // Given an attachment-only comment after an edit, where only the attachment ID survived the round trip + const message: Message = { + text: '[Attachment]', + html: 'renamed_file.csv', + type: '', + }; + + // When it is checked + // Then it still counts as an attachment-only message, so the LHN keeps showing [Attachment] + expect(isReportMessageAttachment(message)).toBe(true); + }); + + it('keeps a pasted chat attachment link that carries no attachment ID as an ordinary link', () => { + // Given a message that is only a link to a chat attachment URL, with none of the attachment attributes + const message: Message = { + text: 'https://www.expensify.com/chat-attachments/123/w_abc.csv', + html: 'https://www.expensify.com/chat-attachments/123/w_abc.csv', + type: '', + }; + + // When it is checked + // Then it stays a text message, so the LHN and the context menu treat it as one + expect(isReportMessageAttachment(message)).toBe(false); + }); + + it('does not treat an edited attachment with text below it as attachment-only', () => { + // Given an edited comment holding a file anchor followed by typed text + const message: Message = { + text: '[Attachment]\nJ', + html: 'file.csv
J', + type: '', + }; + + // When it is checked + // Then the text makes it a mixed comment + expect(isReportMessageAttachment(message)).toBe(false); + }); + it('returns true for optimistic attachment-only via translationKey', () => { const message: Message = { text: '[Attachment]', From 6b779f008a87bdafabe824841b87a1cca3042fd7 Mon Sep 17 00:00:00 2001 From: Mukher Date: Fri, 23 Oct 2026 20:18:57 +0500 Subject: [PATCH 2/3] Read the chat list attachment text with the same rule as the file card The row text now treats a link as a file exactly when the chat draws it as a card: isAttachmentAnchorTag or isOwnAttachmentURL, both from AttachmentAnchorUtils. A file link edited to point at another host, or a pasted link to another message's file, keeps its label. isReportMessageAttachment goes back to main. Widening it moved edited file-only messages onto AttachmentCommentFragment instead of the TextCommentFragment path the card fix relies on, and the row text alone already reads them as [Attachment]. --- src/libs/AttachmentAnchorUtils.ts | 15 +++++++- src/libs/ReportActionMessageUtils.ts | 18 ++-------- src/libs/ReportActionsUtils.ts | 2 +- src/libs/isReportMessageAttachment.ts | 8 ++--- tests/unit/ReportActionsUtilsTest.ts | 28 +++++++++++++++ tests/unit/isReportMessageAttachmentTest.ts | 39 --------------------- 6 files changed, 49 insertions(+), 61 deletions(-) diff --git a/src/libs/AttachmentAnchorUtils.ts b/src/libs/AttachmentAnchorUtils.ts index 60367c48692a..9b101b2b6b43 100644 --- a/src/libs/AttachmentAnchorUtils.ts +++ b/src/libs/AttachmentAnchorUtils.ts @@ -63,4 +63,17 @@ function wrapAttachmentAnchorsInBlocks(html: string, reportActionID: string | un }); } -export {getAnchorHref, isAttachmentAnchor, isAttachmentAnchorTag, isOwnAttachmentURL, wrapAttachmentAnchorsInBlocks}; +// 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 (holdsMedia(label) || (!isAttachmentAnchorTag(attributes) && !isOwnAttachmentURL(getAnchorHref(attributes) ?? '', reportActionID))) { + return match; + } + return `${CONST.ATTACHMENT_MESSAGE_TEXT}${match.endsWith('
') ? '
' : ''}`; + }); +} + +export {getAnchorHref, isAttachmentAnchor, isAttachmentAnchorTag, isOwnAttachmentURL, replaceAttachmentAnchorsWithText, wrapAttachmentAnchorsInBlocks}; diff --git a/src/libs/ReportActionMessageUtils.ts b/src/libs/ReportActionMessageUtils.ts index 4e57a8cc19e6..2dc98025d657 100644 --- a/src/libs/ReportActionMessageUtils.ts +++ b/src/libs/ReportActionMessageUtils.ts @@ -1,7 +1,6 @@ /** * Readers for a report action's message: pull the message entry, its html/text, and the normalized original message. */ -import CONST from '@src/CONST'; import type {OnyxInputOrEntry} from '@src/types/onyx'; import type ReportAction from '@src/types/onyx/ReportAction'; import type {OriginalMessage} from '@src/types/onyx/ReportAction'; @@ -9,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'; @@ -42,20 +42,8 @@ function getReportActionText(reportAction: PartialReportAction): string { return text ? Parser.htmlToText(text) : ''; } -const ATTACHMENT_ID_TOKEN = ` ${CONST.ATTACHMENT_ID_ATTRIBUTE}="`; -const ANCHOR_TAG_REGEX = /]*)>[\s\S]*?<\/a>/gi; - -// The parser only turns an anchor into "[Attachment]" while it still carries `data-expensify-source`, which the server drops on edit. -// The attachment ID survives the edit and is never set on a typed link, so it alone decides, not the URL. -function replaceAttachmentAnchorsWithText(html: string): string { - if (!html.includes(ATTACHMENT_ID_TOKEN)) { - return html; - } - return html.replaceAll(ANCHOR_TAG_REGEX, (match: string, attributes: string) => (` ${attributes}`.includes(ATTACHMENT_ID_TOKEN) ? CONST.ATTACHMENT_MESSAGE_TEXT : match)); -} - -function getTextFromHtml(html?: string): string { - return html ? Parser.htmlToText(replaceAttachmentAnchorsWithText(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 32064512b8be..e7d04e9ebf72 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -2873,7 +2873,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/src/libs/isReportMessageAttachment.ts b/src/libs/isReportMessageAttachment.ts index 98788d47b738..8b38d6fa9296 100644 --- a/src/libs/isReportMessageAttachment.ts +++ b/src/libs/isReportMessageAttachment.ts @@ -6,8 +6,6 @@ import {Parser as HtmlParser} from 'htmlparser2'; 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}="`; -// The server re-serializes an edited comment without `data-expensify-source` but keeps the attachment ID on the anchor. -const ATTACHMENT_ID_TOKEN = ` ${CONST.ATTACHMENT_ID_ATTRIBUTE}="`; // Keyed by Onyx message identity: re-renders pass the same immutable object, so parse at most once per message. const resultCache = new WeakMap(); @@ -18,8 +16,8 @@ function computeIsReportMessageAttachment(html: string, text: string, translatio return true; } - // Fast path: no attachment marker → not an attachment (avoids the parser for most messages). - if (!html.includes(ATTACHMENT_SOURCE_TOKEN) && !html.includes(ATTACHMENT_ID_TOKEN)) { + // Fast path: no source attribute → not an attachment (avoids the parser for most messages). + if (!html.includes(ATTACHMENT_SOURCE_TOKEN)) { return false; } @@ -48,7 +46,7 @@ function computeIsReportMessageAttachment(html: string, text: string, translatio if (name === 'br') { return; } - if (ATTACHMENT_TAGS.has(name) && (!!attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE] || (name === 'a' && !!attribs[CONST.ATTACHMENT_ID_ATTRIBUTE]))) { + if (ATTACHMENT_TAGS.has(name) && !!attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE]) { hasAttachment = true; if (name === 'a' || name === 'video') { depth = 1; diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index f9c9cc4f4f73..6a96f0d2ef87 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -8330,6 +8330,34 @@ describe('ReportActionsUtils', () => { 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 = { diff --git a/tests/unit/isReportMessageAttachmentTest.ts b/tests/unit/isReportMessageAttachmentTest.ts index 4f408b36b1d5..140faea6aa5e 100644 --- a/tests/unit/isReportMessageAttachmentTest.ts +++ b/tests/unit/isReportMessageAttachmentTest.ts @@ -24,45 +24,6 @@ describe('isReportMessageAttachment', () => { expect(isReportMessageAttachment(message)).toBe(false); }); - it('recognizes a file anchor the server re-serialized without the source attribute', () => { - // Given an attachment-only comment after an edit, where only the attachment ID survived the round trip - const message: Message = { - text: '[Attachment]', - html: 'renamed_file.csv', - type: '', - }; - - // When it is checked - // Then it still counts as an attachment-only message, so the LHN keeps showing [Attachment] - expect(isReportMessageAttachment(message)).toBe(true); - }); - - it('keeps a pasted chat attachment link that carries no attachment ID as an ordinary link', () => { - // Given a message that is only a link to a chat attachment URL, with none of the attachment attributes - const message: Message = { - text: 'https://www.expensify.com/chat-attachments/123/w_abc.csv', - html: 'https://www.expensify.com/chat-attachments/123/w_abc.csv', - type: '', - }; - - // When it is checked - // Then it stays a text message, so the LHN and the context menu treat it as one - expect(isReportMessageAttachment(message)).toBe(false); - }); - - it('does not treat an edited attachment with text below it as attachment-only', () => { - // Given an edited comment holding a file anchor followed by typed text - const message: Message = { - text: '[Attachment]\nJ', - html: 'file.csv
J', - type: '', - }; - - // When it is checked - // Then the text makes it a mixed comment - expect(isReportMessageAttachment(message)).toBe(false); - }); - it('returns true for optimistic attachment-only via translationKey', () => { const message: Message = { text: '[Attachment]', From 6eedc15d24eef414444290126e980cd7603e3276 Mon Sep 17 00:00:00 2001 From: Mukher Date: Fri, 23 Oct 2026 20:44:28 +0500 Subject: [PATCH 3/3] Share the file card check between the card and the row text wrapAttachmentAnchorsInBlocks and replaceAttachmentAnchorsWithText both decided on their own whether an anchor is a file card, so a change to that rule had to be made twice. Both now call isFileCardAnchor. The card markup is unchanged. --- src/libs/AttachmentAnchorUtils.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/libs/AttachmentAnchorUtils.ts b/src/libs/AttachmentAnchorUtils.ts index 9b101b2b6b43..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,16 +54,11 @@ function wrapAttachmentAnchorsInBlocks(html: string, reportActionID: string | un return html; } return html.replaceAll(ANCHOR_TAG_REGEX, (match: string, attributes: string, label: string) => { - if (holdsMedia(label)) { - return match; - } - if (isAttachmentAnchorTag(attributes)) { - return `${label}`; - } - if (!isOwnAttachmentURL(getAnchorHref(attributes) ?? '', reportActionID)) { + if (!isFileCardAnchor(attributes, label, reportActionID)) { return match; } - return `${label}`; + const restoredID = isAttachmentAnchorTag(attributes) ? '' : ` ${CONST.ATTACHMENT_ID_ATTRIBUTE}="${reportActionID}"`; + return `${label}`; }); } @@ -69,7 +68,7 @@ function replaceAttachmentAnchorsWithText(html: string, reportActionID: string | return html; } return html.replaceAll(ANCHOR_TAG_REGEX, (match: string, attributes: string, label: string) => { - if (holdsMedia(label) || (!isAttachmentAnchorTag(attributes) && !isOwnAttachmentURL(getAnchorHref(attributes) ?? '', reportActionID))) { + if (!isFileCardAnchor(attributes, label, reportActionID)) { return match; } return `${CONST.ATTACHMENT_MESSAGE_TEXT}${match.endsWith('
') ? '
' : ''}`;