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
17 changes: 11 additions & 6 deletions src/components/ReportActionItem/IssueCardMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,29 @@ import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
import type {ReportAction} from '@src/types/onyx';
import type {IssueNewCardOriginalMessage} from '@src/types/onyx/OriginalMessage';
import {isEmptyObject} from '@src/types/utils/EmptyObject';

import type {OnyxEntry} from 'react-native-onyx';

import {useRoute} from '@react-navigation/native';
import React from 'react';

type IssueCardMessageProps = {
action: OnyxEntry<ReportAction>;
action: ReportAction<
| typeof CONST.REPORT.ACTIONS.TYPE.CARD_ISSUED
| typeof CONST.REPORT.ACTIONS.TYPE.CARD_ISSUED_VIRTUAL
| typeof CONST.REPORT.ACTIONS.TYPE.CARD_MISSING_ADDRESS
| typeof CONST.REPORT.ACTIONS.TYPE.CARD_ASSIGNED
| typeof CONST.REPORT.ACTIONS.TYPE.CARD_REPLACED_VIRTUAL
| typeof CONST.REPORT.ACTIONS.TYPE.CARD_REPLACED
>;
policyID: string | undefined;
};

function IssueCardMessage({action, policyID}: IssueCardMessageProps) {
const {translate} = useLocalize();
const styles = useThemeStyles();
const session = useSession();
const assigneeAccountID = (getOriginalMessage(action) as IssueNewCardOriginalMessage)?.assigneeAccountID;
const cardIssuedActionOriginalMessage = getOriginalMessage(action);
const assigneeAccountID = cardIssuedActionOriginalMessage?.assigneeAccountID;
const expensifyCard = useGetExpensifyCardFromReportAction({
reportAction: action,
policyID,
Expand All @@ -48,7 +53,7 @@ function IssueCardMessage({action, policyID}: IssueCardMessageProps) {
const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`);
const shouldNavigateToCardDetails = isPolicyAdmin(policy);
const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS);
const companyCard = cardList?.[(getOriginalMessage(action) as IssueNewCardOriginalMessage)?.cardID];
const companyCard = cardIssuedActionOriginalMessage?.cardID !== undefined ? cardList?.[cardIssuedActionOriginalMessage.cardID] : undefined;
const shouldShowAddMissingDetailsButton =
!!expensifyCard?.cardID && isAssigneeCurrentUser && shouldShowAddMissingDetails(action?.actionName, privatePersonalDetails, expensifyCard?.state);
const shouldShowActivateButton = isAssigneeCurrentUser && shouldShowActivateCard(action?.actionName, expensifyCard, privatePersonalDetails);
Expand Down
14 changes: 10 additions & 4 deletions src/components/ReportActionItem/receiptHoverUtils/index.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import isHTMLElement from '@libs/isHTMLElement';

import type {ComponentRef, RefObject} from 'react';
import type {View} from 'react-native';

/** Reset stale button hover/tooltip when file picker opens (browsers don't fire mouseleave). */
function resetButtonHoverState(addButtonRef: RefObject<ComponentRef<typeof View> | null>) {
const buttonEl = addButtonRef.current as unknown as HTMLElement;
buttonEl?.dispatchEvent(new PointerEvent('pointerleave'));
buttonEl?.dispatchEvent(new MouseEvent('mouseout', {bubbles: true, relatedTarget: document.body}));
const buttonEl = addButtonRef.current;
if (!isHTMLElement(buttonEl)) {
return;
}
buttonEl.dispatchEvent(new PointerEvent('pointerleave'));
buttonEl.dispatchEvent(new MouseEvent('mouseout', {bubbles: true, relatedTarget: document.body}));
}

/** Check if cursor is over the element (web only). */
function isElementHovered(ref: RefObject<ComponentRef<typeof View> | null>): boolean {
return !!(ref.current as unknown as HTMLElement)?.matches?.(':hover');
const element = ref.current;
return isHTMLElement(element) && element.matches(':hover');
}

export {resetButtonHoverState, isElementHovered};
12 changes: 10 additions & 2 deletions src/hooks/useTackInputFocus/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import useDebouncedState from '@hooks/useDebouncedState';
import useSidePanelState from '@hooks/useSidePanelState';

import isHTMLElement from '@libs/isHTMLElement';

import CONST from '@src/CONST';

import {useCallback, useEffect} from 'react';
Expand All @@ -14,7 +16,10 @@ export default function useTackInputFocus(enable = false): boolean {

const handleFocusIn = useCallback(
(event: FocusEvent) => {
const targetElement = event.target as HTMLElement;
const targetElement = event.target;
if (!isHTMLElement(targetElement)) {
return;
}
if (targetElement.tagName === CONST.ELEMENT_NAME.INPUT || targetElement.tagName === CONST.ELEMENT_NAME.TEXTAREA) {
setIsInputFocus(true);
}
Expand All @@ -24,7 +29,10 @@ export default function useTackInputFocus(enable = false): boolean {

const handleFocusOut = useCallback(
(event: FocusEvent) => {
const targetElement = event.target as HTMLElement;
const targetElement = event.target;
if (!isHTMLElement(targetElement)) {
return;
}
if (targetElement.tagName === CONST.ELEMENT_NAME.INPUT || targetElement.tagName === CONST.ELEMENT_NAME.TEXTAREA) {
setIsInputFocus(false);
}
Expand Down
8 changes: 5 additions & 3 deletions src/hooks/useTextWithMiddleEllipsis.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import isHTMLElement from '@libs/isHTMLElement';

import type {ComponentRef, RefObject} from 'react';
// eslint-disable-next-line no-restricted-imports
import type {Text as RNText} from 'react-native';
Expand Down Expand Up @@ -85,13 +87,13 @@ function useTextWithMiddleEllipsis(props: TruncateProps): string {

useEffect(() => {
const element = ref.current;
if (!element) {
if (!isHTMLElement(element)) {
return;
}

const measure = () => {
const style = window.getComputedStyle(element as unknown as Element);
const rect = (element as unknown as Element).getBoundingClientRect();
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
setFontStyle({
fontWeight: style.fontWeight,
fontStyle: style.fontStyle,
Expand Down
20 changes: 14 additions & 6 deletions src/libs/ReportActionFollowupUtils/tokenizeForReveal.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type {AnyNode, Document, Element as DomElement} from 'domhandler';
import type {AnyNode, Document} from 'domhandler';

import render from 'dom-serializer';
import {ElementType} from 'domelementtype';
Expand Down Expand Up @@ -78,17 +78,25 @@ function buildClippedNodes(doc: Document, anchor: Anchor): AnyNode[] {
}
if (isLeaf) {
if (stopNode.type === ElementType.Text && anchor.textEndIdx !== undefined) {
const truncated = {...stopNode, data: stopNode.data.slice(0, anchor.textEndIdx)} as unknown as AnyNode;
const truncated = stopNode.cloneNode(false);
// dom-serializer uses the parent to preserve raw text escaping.
truncated.parent = stopNode.parent;
truncated.data = stopNode.data.slice(0, anchor.textEndIdx);
return [...before, truncated];
}
return [...before, stopNode];
}
const elem = stopNode as DomElement;
const innerChildren = clip(elem.children as AnyNode[], depth + 1);
const partialElem = {...elem, children: innerChildren} as unknown as AnyNode;
if (stopNode.type !== ElementType.Tag) {
return [...before, stopNode];
}
const innerChildren = clip(stopNode.children, depth + 1);
const partialElem = stopNode.cloneNode(false);
// dom-serializer uses the parent to preserve HTML inside MathML and SVG.
partialElem.parent = stopNode.parent;
partialElem.children = innerChildren;
return [...before, partialElem];
};
return clip(doc.children as AnyNode[], 0);
return clip(doc.children, 0);
}

/**
Expand Down
13 changes: 0 additions & 13 deletions src/types/onyx/OriginalMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1622,18 +1622,6 @@ type OriginalMessageSettlementAccountLocked = {
policyID: string;
};

/**
* Original message for Expensify Card issue/replacement actions
*/
type IssueNewCardOriginalMessage = OriginalMessage<
| typeof CONST.REPORT.ACTIONS.TYPE.CARD_MISSING_ADDRESS
| typeof CONST.REPORT.ACTIONS.TYPE.CARD_ISSUED
| typeof CONST.REPORT.ACTIONS.TYPE.CARD_ISSUED_VIRTUAL
| typeof CONST.REPORT.ACTIONS.TYPE.CARD_ASSIGNED
| typeof CONST.REPORT.ACTIONS.TYPE.CARD_REPLACED_VIRTUAL
| typeof CONST.REPORT.ACTIONS.TYPE.CARD_REPLACED
>;

/**
* Model of a HOME_ADDRESS_REQUIRED Concierge report action.
*/
Expand Down Expand Up @@ -1838,7 +1826,6 @@ export type {
JoinWorkspaceResolution,
OriginalMessageModifiedExpense,
OriginalMessageExportIntegration,
IssueNewCardOriginalMessage,
OriginalMessageChangePolicy,
OriginalMessageMovedTransaction,
PolicyBudgetFrequency,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,28 @@ describe('tokenizeForReveal', () => {
expect(tokenizeForReveal(html).at(-1)).toBe(html);
});

it('preserves raw text escaping inside noscript at every reveal stage', () => {
// Given text whose ampersand must stay unescaped under its raw text parent.
const html = '<noscript>a&amp;b</noscript>';

// When the text is progressively clipped, each clone still needs that parent context.
const stages = tokenizeForReveal(html);

// Then no intermediate stage introduces entity escaping that changes the existing output.
expect(stages).toEqual(['', '<noscript>a</noscript>', '<noscript>a&</noscript>', '<noscript>a&b</noscript>']);
});

it('preserves HTML serialization inside a MathML integration point at every reveal stage', () => {
// Given an HTML child whose empty attribute serializes differently in MathML.
const html = '<math><mi><span title="">ab</span></mi></math>';

// When the child is progressively clipped, its parent must still switch serialization back to HTML.
const stages = tokenizeForReveal(html);

// Then both partial and complete stages retain the existing HTML attribute serialization.
expect(stages).toEqual(['', '<math><mi><span title>a</span></mi></math>', '<math><mi><span title>ab</span></mi></math>']);
});

it('produces enough anchors that a typical multi-paragraph response trickles smoothly', () => {
// Realistic shape — Xero-style 5-step instructions.
const html =
Expand Down
Loading