Skip to content
Draft
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
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;
214 changes: 214 additions & 0 deletions src/libs/PageContextUtils.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]> = {
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, '&quot;');
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}</${tagName}>`;
}

return `<${tagName}${attrString}></${tagName}>`;
}

/**
* 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 `<page url="${pageURL}">\n${htmlContent}\n</page>`;
} catch (error) {
// Fail silently, don't break the chat functionality
console.warn('[PageContext] Failed to capture page HTML:', error);
return undefined;
}
}

export default {
capturePageHTMLContext,
};
19 changes: 15 additions & 4 deletions src/libs/actions/Report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -152,6 +153,7 @@ import {
hasOutstandingChildRequest,
isChatThread as isChatThreadReportUtils,
isConciergeChatReport,
isAdminRoom,
isCurrentUserSubmitter,
isExpenseReport,
isGroupChat as isGroupChatReportUtils,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 5 additions & 2 deletions src/pages/home/report/ReportFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<OnyxTypes.ReportMetadata>;

Expand Down Expand Up @@ -90,7 +93,7 @@ function ReportFooter({
onComposerFocus,
reportTransactions,
transactionThreadReportID,
isInSidePanel,
isInSidePanel = false,
}: ReportFooterProps) {
const styles = useThemeStyles();
const {isOffline} = useNetwork();
Expand Down Expand Up @@ -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],
Expand Down