diff --git a/src/libs/API/parameters/AddCommentOrAttachmentParams.ts b/src/libs/API/parameters/AddCommentOrAttachmentParams.ts index 8815e5c85eb6..6f87172def2d 100644 --- a/src/libs/API/parameters/AddCommentOrAttachmentParams.ts +++ b/src/libs/API/parameters/AddCommentOrAttachmentParams.ts @@ -10,6 +10,7 @@ type AddCommentOrAttachmentParams = { clientCreatedTime?: string; isOldDotConciergeChat?: boolean; idempotencyKey?: string; + pageHTML?: string; }; export default AddCommentOrAttachmentParams; diff --git a/src/libs/PageHTMLCapture/index.native.ts b/src/libs/PageHTMLCapture/index.native.ts new file mode 100644 index 000000000000..63c0bd6e6c26 --- /dev/null +++ b/src/libs/PageHTMLCapture/index.native.ts @@ -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; diff --git a/src/libs/PageHTMLCapture/index.ts b/src/libs/PageHTMLCapture/index.ts new file mode 100644 index 000000000000..8a20f7be1d14 --- /dev/null +++ b/src/libs/PageHTMLCapture/index.ts @@ -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('#root'); + if (!mainContent) { + return ''; + } + + const simplifiedHTML = extractSimplifiedHTML(mainContent); + + return `${simplifiedHTML}`; + } 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}`); + } else if (attrs.length > 0) { + result.push(`<${tagName}${attrString}>`); + } + } + } 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('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); +} + +export default capturePageHTML; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 05d4935f0fe7..aaf7e35f4d90 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -83,6 +83,7 @@ import NetworkConnection from '@libs/NetworkConnection'; 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'; @@ -150,6 +151,7 @@ import { getReportViolations, getTitleReportField, hasOutstandingChildRequest, + isAdminRoom, isChatThread as isChatThreadReportUtils, isConciergeChatReport, isCurrentUserSubmitter, @@ -543,8 +545,9 @@ function notifyNewAction(reportID: string | undefined, accountID: number | undef * * @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, notifyReportID: string, ancestors: Ancestor[], timezoneParam: Timezone, text = '', file?: FileObject) { +function addActions(report: OnyxEntry, notifyReportID: string, ancestors: Ancestor[], timezoneParam: Timezone, text = '', file?: FileObject, isInSidePanel = false) { if (!report?.reportID) { return; } @@ -621,6 +624,13 @@ function addActions(report: OnyxEntry, notifyReportID: string, ancestors parameters.isOldDotConciergeChat = true; } + if (isInSidePanel && (isConciergeChatReport(report) || isAdminRoom(report))) { + const pageHTML = capturePageHTML(); + if (pageHTML) { + parameters.pageHTML = pageHTML; + } + } + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -718,6 +728,7 @@ function addAttachmentWithComment( text = '', timezone: Timezone = CONST.DEFAULT_TIME_ZONE, shouldPlaySound = false, + isInSidePanel = false, ) { if (!report?.reportID) { return; @@ -732,17 +743,17 @@ function addAttachmentWithComment( // 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 @@ -750,11 +761,11 @@ function addAttachmentWithComment( } /** Add a single comment to a report */ -function addComment(report: OnyxEntry, notifyReportID: string, ancestors: Ancestor[], text: string, timezoneParam: Timezone, shouldPlaySound?: boolean) { +function addComment(report: OnyxEntry, 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 { diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index 3f83a343778e..113198869b8e 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -113,6 +113,9 @@ type ReportActionComposeProps = Pick