From 612de21b4ecb1542cf412891da4f94fccea9e82e Mon Sep 17 00:00:00 2001 From: ChinmayKarnik Date: Wed, 31 Dec 2025 10:24:32 +0530 Subject: [PATCH] Add page HTML context capture for Concierge in side panel - Created PageContextUtils with HTML sanitization to capture semantic page structure - Added pageHTML parameter to AddCommentOrAttachmentParams - Modified Report.addActions to capture page context when in side panel and talking to Concierge/admin rooms - Updated ReportScreen and ReportFooter to pass isInSidePanel prop - Captures only semantic HTML (buttons, links, forms, headings, etc.) - Excludes sensitive data, side panel content, and visual noise - Implements 50KB size limit with automatic truncation --- .../AddCommentOrAttachmentParams.ts | 1 + src/libs/PageContextUtils.ts | 214 ++++++++++++++++++ src/libs/actions/Report.ts | 19 +- src/pages/home/report/ReportFooter.tsx | 7 +- 4 files changed, 235 insertions(+), 6 deletions(-) create mode 100644 src/libs/PageContextUtils.ts 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/PageContextUtils.ts b/src/libs/PageContextUtils.ts new file mode 100644 index 000000000000..38cad9721459 --- /dev/null +++ b/src/libs/PageContextUtils.ts @@ -0,0 +1,214 @@ +const MAX_HTML_SIZE = 50 * 1024; // 50KB limit + +// Only semantic elements we want to capture +const SEMANTIC_ELEMENTS = new Set([ + 'button', + 'a', + 'input', + 'textarea', + 'select', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'nav', + 'header', + 'footer', + 'main', + 'form', + 'label', + 'ul', + 'ol', + 'li', +]); + +// Attributes we want to keep (everything else is stripped) +const ALLOWED_ATTRIBUTES: Record = { + a: ['href', 'aria-label'], + input: ['type', 'placeholder', 'aria-label'], + textarea: ['placeholder', 'aria-label'], + button: ['aria-label'], + select: ['aria-label'], + label: ['for'], +}; + +/** + * Check if an element is visible + */ +function isElementVisible(element: Element): boolean { + if (!(element instanceof HTMLElement)) { + return false; + } + + // Check if element is hidden + const style = window.getComputedStyle(element); + if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { + return false; + } + + // Check if element has dimensions + const rect = element.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) { + return false; + } + + return true; +} + +/** + * Check if element is part of side panel or modal + */ +function isExcludedContent(element: Element): boolean { + // Exclude side panel + if (element.closest('[data-testid="side-panel"]')) { + return true; + } + + // Exclude modals + if (element.closest('[role="dialog"]') || element.closest('[role="alertdialog"]')) { + return true; + } + + return false; +} + +/** + * Get direct text content only (not from child elements) + */ +function getDirectTextContent(element: Element): string { + let text = ''; + + // Only get text from direct text nodes, not from child elements + element.childNodes.forEach((node) => { + if (node.nodeType === Node.TEXT_NODE) { + text += node.textContent || ''; + } + }); + + return text.trim().replace(/\s+/g, ' ').slice(0, 100); +} + +/** + * Sanitize and simplify an element + */ +function sanitizeElement(element: Element): string { + const tagName = element.tagName.toLowerCase(); + + // Skip if not a semantic element + if (!SEMANTIC_ELEMENTS.has(tagName)) { + return ''; + } + + // Skip if not visible + if (!isElementVisible(element)) { + return ''; + } + + // Skip if in excluded content + if (isExcludedContent(element)) { + return ''; + } + + // Skip password inputs for security + if (tagName === 'input' && element.getAttribute('type') === 'password') { + return ''; + } + + // Get allowed attributes for this tag + const allowedAttrs = ALLOWED_ATTRIBUTES[tagName] || []; + const attributes: string[] = []; + + allowedAttrs.forEach((attr) => { + const value = element.getAttribute(attr); + if (value) { + // Escape quotes in attribute values + const escapedValue = value.replace(/"/g, '"'); + attributes.push(`${attr}="${escapedValue}"`); + } + }); + + // Get ONLY direct text content (not from children) + const textContent = getDirectTextContent(element); + + // Build the simplified element + const attrString = attributes.length > 0 ? ' ' + attributes.join(' ') : ''; + + // Self-closing tags + if (tagName === 'input') { + return `<${tagName}${attrString}>`; + } + + // Only include text if there is any + if (textContent) { + return `<${tagName}${attrString}>${textContent}`; + } + + return `<${tagName}${attrString}>`; +} + +/** + * Recursively process DOM nodes + */ +function processNode(node: Node, result: string[]): void { + if (node.nodeType === Node.ELEMENT_NODE) { + const element = node as Element; + const sanitized = sanitizeElement(element); + + if (sanitized) { + result.push(sanitized); + } + + // Process children + node.childNodes.forEach((child) => processNode(child, result)); + } +} + +/** + * Captures simplified HTML representation of the current page + * Only includes semantic elements and useful attributes + * Returns undefined if called on non-web platforms or if capture fails + */ +function capturePageHTMLContext(): string | undefined { + // Only works on web platform + if (typeof document === 'undefined' || typeof window === 'undefined') { + return undefined; + } + + try { + // Find main content area (exclude side panel and modals) + const root = document.getElementById('root'); + if (!root) { + return undefined; + } + + const result: string[] = []; + + // Process all nodes in the main content + processNode(root, result); + + // Remove duplicates (same elements appearing multiple times) + const uniqueElements = Array.from(new Set(result)); + + // Join all elements + let htmlContent = uniqueElements.join('\n'); + + // Truncate if exceeds size limit + if (htmlContent.length > MAX_HTML_SIZE) { + htmlContent = htmlContent.slice(0, MAX_HTML_SIZE) + '\n... [truncated]'; + } + + // Wrap with page URL + const pageURL = window.location.pathname + window.location.search; + return `\n${htmlContent}\n`; + } catch (error) { + // Fail silently, don't break the chat functionality + console.warn('[PageContext] Failed to capture page HTML:', error); + return undefined; + } +} + +export default { + capturePageHTMLContext, +}; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 173ba4bf75df..20b815cebd24 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -2,7 +2,7 @@ import {format as timezoneFormat, toZonedTime} from 'date-fns-tz'; import {Str} from 'expensify-common'; import isEmpty from 'lodash/isEmpty'; -import {DeviceEventEmitter, InteractionManager, Linking} from 'react-native'; +import {DeviceEventEmitter, InteractionManager, Linking, Platform} from 'react-native'; import type {NullishDeep, OnyxCollection, OnyxCollectionInputValue, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {PartialDeep, ValueOf} from 'type-fest'; @@ -11,6 +11,7 @@ import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleCon import * as ActiveClientManager from '@libs/ActiveClientManager'; import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; import * as API from '@libs/API'; +import PageContextUtils from '@libs/PageContextUtils'; import type { AddCommentOrAttachmentParams, AddEmojiReactionParams, @@ -152,6 +153,7 @@ import { hasOutstandingChildRequest, isChatThread as isChatThreadReportUtils, isConciergeChatReport, + isAdminRoom, isCurrentUserSubmitter, isExpenseReport, isGroupChat as isGroupChatReportUtils, @@ -550,8 +552,9 @@ function notifyNewAction(reportID: string | undefined, accountID: number | undef * * @param reportID - The report ID 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 sent from the side panel */ -function addActions(reportID: string, notifyReportID: string, ancestors: Ancestor[], timezoneParam: Timezone, text = '', file?: FileObject) { +function addActions(reportID: string, notifyReportID: string, ancestors: Ancestor[], timezoneParam: Timezone, text = '', file?: FileObject, isInSidePanel = false) { let reportCommentText = ''; let reportCommentAction: OptimisticAddCommentReportAction | undefined; let attachmentAction: OptimisticAddCommentReportAction | undefined; @@ -625,6 +628,14 @@ function addActions(reportID: string, notifyReportID: string, ancestors: Ancesto parameters.isOldDotConciergeChat = true; } + // Capture page HTML context when sending from side panel to Concierge or admin rooms + if (Platform.OS === 'web' && isInSidePanel && report && (isConciergeChatReport(report) || isAdminRoom(report))) { + const pageHTML = PageContextUtils.capturePageHTMLContext(); + if (pageHTML) { + parameters.pageHTML = pageHTML; + } + } + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -754,11 +765,11 @@ function addAttachmentWithComment( } /** Add a single comment to a report */ -function addComment(reportID: string, notifyReportID: string, ancestors: Ancestor[], text: string, timezoneParam: Timezone, shouldPlaySound?: boolean) { +function addComment(reportID: string, notifyReportID: string, ancestors: Ancestor[], text: string, timezoneParam: Timezone, shouldPlaySound?: boolean, isInSidePanel?: boolean) { if (shouldPlaySound) { playSound(SOUNDS.DONE); } - addActions(reportID, notifyReportID, ancestors, timezoneParam, text); + addActions(reportID, notifyReportID, ancestors, timezoneParam, text, undefined, isInSidePanel); } function reportActionsExist(reportID: string): boolean { diff --git a/src/pages/home/report/ReportFooter.tsx b/src/pages/home/report/ReportFooter.tsx index f125a0d0e667..c35c27ef469d 100644 --- a/src/pages/home/report/ReportFooter.tsx +++ b/src/pages/home/report/ReportFooter.tsx @@ -48,6 +48,9 @@ type ReportFooterProps = { /** Report object for the current report */ report?: OnyxTypes.Report; + /** Whether the report is being displayed in the side panel */ + isInSidePanel?: boolean; + /** Report metadata */ reportMetadata?: OnyxEntry; @@ -90,7 +93,7 @@ function ReportFooter({ onComposerFocus, reportTransactions, transactionThreadReportID, - isInSidePanel, + isInSidePanel = false, }: ReportFooterProps) { const styles = useThemeStyles(); const {isOffline} = useNetwork(); @@ -195,7 +198,7 @@ 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. const targetReportID = transactionThreadReportID ?? report.reportID; - addComment(targetReportID, report.reportID, targetReportAncestors, text, personalDetail.timezone ?? CONST.DEFAULT_TIME_ZONE, true); + addComment(targetReportID, 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, transactionThreadReportID, targetReportAncestors],