Skip to content
Merged
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
26 changes: 19 additions & 7 deletions src/libs/AttachmentAnchorUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 `<attachment-block><a ${attributes}>${label}</a></attachment-block>`;
}
if (!isOwnAttachmentURL(getAnchorHref(attributes) ?? '', reportActionID)) {
const restoredID = isAttachmentAnchorTag(attributes) ? '' : ` ${CONST.ATTACHMENT_ID_ATTRIBUTE}="${reportActionID}"`;
return `<attachment-block><a ${attributes}${restoredID}>${label}</a></attachment-block>`;
});
}

// 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('<a ')) {
return html;
}
return html.replaceAll(ANCHOR_TAG_REGEX, (match: string, attributes: string, label: string) => {
if (!isFileCardAnchor(attributes, label, reportActionID)) {
return match;
}
return `<attachment-block><a ${attributes} ${CONST.ATTACHMENT_ID_ATTRIBUTE}="${reportActionID}">${label}</a></attachment-block>`;
return `${CONST.ATTACHMENT_MESSAGE_TEXT}${match.endsWith('<br />') ? '<br />' : ''}`;
});
}

export {getAnchorHref, isAttachmentAnchor, isAttachmentAnchorTag, isOwnAttachmentURL, wrapAttachmentAnchorsInBlocks};
export {getAnchorHref, isAttachmentAnchor, isAttachmentAnchorTag, isOwnAttachmentURL, replaceAttachmentAnchorsWithText, wrapAttachmentAnchorsInBlocks};
5 changes: 3 additions & 2 deletions src/libs/ReportActionMessageUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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};
2 changes: 1 addition & 1 deletion src/libs/ReportActionsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2879,7 +2879,7 @@ function getReportActionMessageText(reportAction: OnyxEntry<ReportAction>): 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)}`, '') ?? '';

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.

NAB: when message is a single object, the early return above uses getReportActionText, which still calls Parser.htmlToText directly. Can we run replaceAttachmentAnchorsWithText there too, so the row shows [Attachment] for both shapes?

}

function getAgentPromptUpdatedMessage(translate: LocalizedTranslate, reportAction: ReportAction<typeof CONST.REPORT.ACTIONS.TYPE.AGENT_PROMPT_UPDATED>): string {
Expand Down
82 changes: 82 additions & 0 deletions tests/unit/ReportActionsUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<br /><br /><a href="https://www.expensify.com/chat-attachments/123/w_abc.csv" data-attachment-id="456" target="_blank" rel="noreferrer noopener">renamed.csv</a>';
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 href="https://example.com/a.csv">a.csv</a>', 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 <a href="https://example.com/file.csv" data-attachment-id="456">the file</a>', 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: '<a href="https://www.expensify.com/chat-attachments/123/w_abc.csv">renamed.csv</a><br />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 <a href="https://www.expensify.com/chat-attachments/123/w_abc.csv" target="_blank" rel="noreferrer noopener">old sheet</a>',
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) =>
Expand Down
Loading