Skip to content
1 change: 1 addition & 0 deletions src/libs/API/parameters/AddCommentOrAttachmentParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type AddCommentOrAttachmentParams = {
clientCreatedTime?: string;
isOldDotConciergeChat?: boolean;
idempotencyKey?: string;
pageHTML?: string;
};

export default AddCommentOrAttachmentParams;
10 changes: 10 additions & 0 deletions src/libs/PageHTMLCapture/index.native.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Captures simplified HTML content from the main app window.
* On native platforms, this is not applicable, so we return an empty string.
* @returns Empty string on native platforms
*/
function capturePageHTML(): string {
return '';
}

export default capturePageHTML;
137 changes: 137 additions & 0 deletions src/libs/PageHTMLCapture/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import Navigation from '@libs/Navigation/Navigation';

/**
* Captures simplified HTML content from the main app window.
* Extracts semantic HTML elements, visible text, and useful attributes.
* @returns Simplified HTML string wrapped with page URL
*/
function capturePageHTML(): string {
try {
const currentPath = Navigation.getActiveRoute();
const pageURL = currentPath || '/';

const mainContent = document.querySelector<HTMLElement>('#root');
if (!mainContent) {
return '';
}

const simplifiedHTML = extractSimplifiedHTML(mainContent);

return `<page url="${escapeHtml(pageURL)}">${simplifiedHTML}</page>`;
} catch (error) {
console.error('[PageHTMLCapture] Error capturing page HTML:', error);
return '';
}
}

/**
* Recursively extracts simplified HTML from an element.
* Includes semantic elements, visible text, and useful attributes.
*/
function extractSimplifiedHTML(element: HTMLElement): string {
const result: string[] = [];

if (
element.classList.contains('side-panel') ||
element.getAttribute('data-testid') === 'side-panel' ||
element.getAttribute('role') === 'dialog' ||
element.classList.contains('modal')
) {
return '';
}

const childNodes = Array.from(element.childNodes);
for (const node of childNodes) {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent?.trim();
if (text && text.length > 1 && !isIconText(text)) {
result.push(escapeHtml(text));
}
} else if (node.nodeType === Node.ELEMENT_NODE) {
const childElement = node as HTMLElement;

const style = window.getComputedStyle(childElement);
if (style.display === 'none' || style.visibility === 'hidden') {
continue;
}

const tagName = childElement.tagName.toLowerCase();

if (tagName === 'svg' || tagName === 'img' || tagName === 'picture' || childElement.classList.contains('icon')) {
continue;
}

const semanticElements = ['button', 'a', 'input', 'textarea', 'select', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'nav', 'header', 'footer', 'main', 'form', 'label'];

if (semanticElements.includes(tagName)) {
const attrs: string[] = [];

if (tagName === 'a') {
const href = childElement.getAttribute('href');
if (href && href.length > 0 && href !== '#') {
attrs.push(`href="${escapeHtml(href)}"`);
}
}

if (tagName === 'input') {
const type = childElement.getAttribute('type');
if (type && type !== 'hidden') {
attrs.push(`type="${escapeHtml(type)}"`);
}
const placeholder = childElement.getAttribute('placeholder');
if (placeholder) {
attrs.push(`placeholder="${escapeHtml(placeholder)}"`);
}
}

if (tagName === 'textarea') {
const placeholder = childElement.getAttribute('placeholder');
if (placeholder) {
attrs.push(`placeholder="${escapeHtml(placeholder)}"`);
}
}

if (['button', 'a'].includes(tagName)) {
const ariaLabel = childElement.getAttribute('aria-label');
if (ariaLabel && !isIconText(ariaLabel)) {
attrs.push(`aria-label="${escapeHtml(ariaLabel)}"`);
}
}

const innerContent = extractSimplifiedHTML(childElement);

if (innerContent || attrs.length > 0) {
const attrString = attrs.length > 0 ? ` ${attrs.join(' ')}` : '';
if (innerContent) {
result.push(`<${tagName}${attrString}>${innerContent}</${tagName}>`);
} else if (attrs.length > 0) {
result.push(`<${tagName}${attrString}></${tagName}>`);
}
}
} else {
const innerContent = extractSimplifiedHTML(childElement);
if (innerContent) {
result.push(innerContent);
}
}
}
}

return result.join(' ').replaceAll(/\s+/g, ' ').trim();
}

function isIconText(text: string): boolean {
if (text.length === 1) {
return true;
}

const iconPatterns = [/[\u2000-\u2BFF]/, /[\u2600-\u27BF]/, /[\uE000-\uF8FF]/, /[\uD800-\uDFFF]/, /^[\u00A0\s]+$/];

return iconPatterns.some((pattern) => pattern.test(text));
}

function escapeHtml(text: string): string {
return text.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#039;');
}

export default capturePageHTML;
23 changes: 17 additions & 6 deletions src/libs/actions/Report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
import {buildNextStepNew, buildOptimisticNextStep} from '@libs/NextStepUtils';
import LocalNotification from '@libs/Notification/LocalNotification';
import {rand64} from '@libs/NumberUtils';
import capturePageHTML from '@libs/PageHTMLCapture';
import Parser from '@libs/Parser';
import {getParsedMessageWithShortMentions} from '@libs/ParsingUtils';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
Expand Down Expand Up @@ -150,6 +151,7 @@
getReportViolations,
getTitleReportField,
hasOutstandingChildRequest,
isAdminRoom,
isChatThread as isChatThreadReportUtils,
isConciergeChatReport,
isCurrentUserSubmitter,
Expand Down Expand Up @@ -276,7 +278,7 @@
let currentUserAccountID = -1;
let currentUserEmail: string | undefined;

Onyx.connect({

Check warning on line 281 in src/libs/actions/Report.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.SESSION,
callback: (value) => {
// When signed out, val is undefined
Expand All @@ -289,7 +291,7 @@
},
});

Onyx.connect({

Check warning on line 294 in src/libs/actions/Report.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.CONCIERGE_REPORT_ID,
callback: (value) => (conciergeReportID = value),
});
Expand All @@ -297,7 +299,7 @@
// map of reportID to all reportActions for that report
const allReportActions: OnyxCollection<ReportActions> = {};

Onyx.connect({

Check warning on line 302 in src/libs/actions/Report.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
callback: (actions, key) => {
if (!key || !actions) {
Expand All @@ -309,7 +311,7 @@
});

let allReports: OnyxCollection<Report>;
Onyx.connect({

Check warning on line 314 in src/libs/actions/Report.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.REPORT,
waitForCollectionCallback: true,
callback: (value) => {
Expand All @@ -318,7 +320,7 @@
});

let allPersonalDetails: OnyxEntry<PersonalDetailsList> = {};
Onyx.connect({

Check warning on line 323 in src/libs/actions/Report.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
callback: (value) => {
allPersonalDetails = value ?? {};
Expand All @@ -333,7 +335,7 @@
});

let onboarding: OnyxEntry<Onboarding>;
Onyx.connect({

Check warning on line 338 in src/libs/actions/Report.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.NVP_ONBOARDING,
callback: (val) => {
if (Array.isArray(val)) {
Expand All @@ -344,13 +346,13 @@
});

let introSelected: OnyxEntry<IntroSelected> = {};
Onyx.connect({

Check warning on line 349 in src/libs/actions/Report.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.NVP_INTRO_SELECTED,
callback: (val) => (introSelected = val),
});

let allReportDraftComments: Record<string, string | undefined> = {};
Onyx.connect({

Check warning on line 355 in src/libs/actions/Report.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT,
waitForCollectionCallback: true,
callback: (value) => (allReportDraftComments = value),
Expand Down Expand Up @@ -543,8 +545,9 @@
*
* @param report - The report where the comment should be added
* @param notifyReportID - The report ID we should notify for new actions. This is usually the same as reportID, except when adding a comment to an expense report with a single transaction thread, in which case we want to notify the parent expense report.
* @param isInSidePanel - Whether the comment is being added from the side panel
*/
function addActions(report: OnyxEntry<Report>, notifyReportID: string, ancestors: Ancestor[], timezoneParam: Timezone, text = '', file?: FileObject) {
function addActions(report: OnyxEntry<Report>, notifyReportID: string, ancestors: Ancestor[], timezoneParam: Timezone, text = '', file?: FileObject, isInSidePanel = false) {
if (!report?.reportID) {
return;
}
Expand Down Expand Up @@ -621,6 +624,13 @@
parameters.isOldDotConciergeChat = true;
}

if (isInSidePanel && (isConciergeChatReport(report) || isAdminRoom(report))) {
const pageHTML = capturePageHTML();
if (pageHTML) {
parameters.pageHTML = pageHTML;
}
}

const optimisticData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
Expand Down Expand Up @@ -718,6 +728,7 @@
text = '',
timezone: Timezone = CONST.DEFAULT_TIME_ZONE,
shouldPlaySound = false,
isInSidePanel = false,
) {
if (!report?.reportID) {
return;
Expand All @@ -732,29 +743,29 @@

// Single attachment
if (!Array.isArray(attachments)) {
addActions(report, notifyReportID, ancestors, timezone, text, attachments);
addActions(report, notifyReportID, ancestors, timezone, text, attachments, isInSidePanel);
handlePlaySound();
return;
}

// Multiple attachments - first: combine text + first attachment as a single action
addActions(report, notifyReportID, ancestors, timezone, text, attachments?.at(0));
addActions(report, notifyReportID, ancestors, timezone, text, attachments?.at(0), isInSidePanel);

// Remaining: attachment-only actions (no text duplication)
for (let i = 1; i < attachments?.length; i += 1) {
addActions(report, notifyReportID, ancestors, timezone, '', attachments?.at(i));
addActions(report, notifyReportID, ancestors, timezone, '', attachments?.at(i), isInSidePanel);
}

// Play sound once
handlePlaySound();
}

/** Add a single comment to a report */
function addComment(report: OnyxEntry<Report>, notifyReportID: string, ancestors: Ancestor[], text: string, timezoneParam: Timezone, shouldPlaySound?: boolean) {
function addComment(report: OnyxEntry<Report>, notifyReportID: string, ancestors: Ancestor[], text: string, timezoneParam: Timezone, shouldPlaySound?: boolean, isInSidePanel?: boolean) {
if (shouldPlaySound) {
playSound(SOUNDS.DONE);
}
addActions(report, notifyReportID, ancestors, timezoneParam, text);
addActions(report, notifyReportID, ancestors, timezoneParam, text, undefined, isInSidePanel);
}

function reportActionsExist(reportID: string): boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ type ReportActionComposeProps = Pick<ComposerWithSuggestionsProps, 'reportID' |

/** Whether the main composer was hidden */
didHideComposerInput?: boolean;

/** Whether the report screen is being displayed in the side panel */
isInSidePanel?: boolean;
};

// We want consistent auto focus behavior on input between native and mWeb so we have some auto focus management code that will
Expand All @@ -136,6 +139,7 @@ function ReportActionCompose({
didHideComposerInput,
reportTransactions,
transactionThreadReportID,
isInSidePanel = false,
}: ReportActionComposeProps) {
const styles = useThemeStyles();
const theme = useTheme();
Expand Down Expand Up @@ -332,7 +336,7 @@ function ReportActionCompose({
}

if (attachmentFileRef.current) {
addAttachmentWithComment(transactionThreadReport ?? report, reportID, ancestors, attachmentFileRef.current, newCommentTrimmed, personalDetail.timezone, true);
addAttachmentWithComment(transactionThreadReport ?? report, reportID, ancestors, attachmentFileRef.current, newCommentTrimmed, personalDetail.timezone, true, isInSidePanel);
attachmentFileRef.current = null;
} else {
Performance.markStart(CONST.TIMING.SEND_MESSAGE, {message: newCommentTrimmed});
Expand All @@ -348,7 +352,7 @@ function ReportActionCompose({
onSubmit(newCommentTrimmed);
}
},
[isConciergeChat, kickoffWaitingIndicator, transactionThreadReport, report, reportID, ancestors, personalDetail.timezone, onSubmit],
[isConciergeChat, kickoffWaitingIndicator, transactionThreadReport, report, reportID, ancestors, personalDetail.timezone, onSubmit, isInSidePanel],
);

const onTriggerAttachmentPicker = useCallback(() => {
Expand Down
5 changes: 3 additions & 2 deletions src/pages/home/report/ReportFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,10 +194,10 @@ function ReportFooter({
}
// If we are adding an action on an expense report that only has a single transaction thread child report, we need to add the action to the transaction thread instead.
// This is because we need it to be associated with the transaction thread and not the expense report in order for conversational corrections to work as expected.
addComment(targetReport, report.reportID, targetReportAncestors, text, personalDetail.timezone ?? CONST.DEFAULT_TIME_ZONE, true);
addComment(targetReport, report.reportID, targetReportAncestors, text, personalDetail.timezone ?? CONST.DEFAULT_TIME_ZONE, true, isInSidePanel);
},
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
[report.reportID, handleCreateTask, targetReport, targetReportAncestors],
[report.reportID, handleCreateTask, targetReport, targetReportAncestors, isInSidePanel],
);

const [didHideComposerInput, setDidHideComposerInput] = useState(!shouldShowComposeInput);
Expand Down Expand Up @@ -256,6 +256,7 @@ function ReportFooter({
didHideComposerInput={didHideComposerInput}
reportTransactions={reportTransactions}
transactionThreadReportID={transactionThreadReportID}
isInSidePanel={isInSidePanel}
/>
</SwipeableView>
</View>
Expand Down
Loading