From c306f00ad8e4958433239c7dac80a01480212031 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 17 Sep 2026 20:17:07 +0530 Subject: [PATCH 01/80] feat: allow typing a date into the date field, one segment at a time Signed-off-by: krishna2323 --- src/components/DatePicker/DatePickerModal.tsx | 10 +- src/components/DatePicker/index.tsx | 64 +++- src/components/DatePicker/types.ts | 9 + src/components/Modal/BaseModal.tsx | 2 + .../Modal/ReanimatedModal/index.tsx | 3 +- src/components/Modal/ReanimatedModal/types.ts | 6 + src/hooks/useDateSegmentInput.ts | 195 ++++++++++++ src/libs/DateInputMaskUtils.ts | 279 ++++++++++++++++++ .../isTypedDateInputSupported/index.native.ts | 5 + src/libs/isTypedDateInputSupported/index.ts | 11 + src/libs/isTypedDateInputSupported/types.ts | 3 + tests/unit/DateInputMaskUtilsTest.ts | 206 +++++++++++++ 12 files changed, 775 insertions(+), 18 deletions(-) create mode 100644 src/hooks/useDateSegmentInput.ts create mode 100644 src/libs/DateInputMaskUtils.ts create mode 100644 src/libs/isTypedDateInputSupported/index.native.ts create mode 100644 src/libs/isTypedDateInputSupported/index.ts create mode 100644 src/libs/isTypedDateInputSupported/types.ts create mode 100644 tests/unit/DateInputMaskUtilsTest.ts diff --git a/src/components/DatePicker/DatePickerModal.tsx b/src/components/DatePicker/DatePickerModal.tsx index 5ea4f13a430e..d6f0d12a52d1 100644 --- a/src/components/DatePicker/DatePickerModal.tsx +++ b/src/components/DatePicker/DatePickerModal.tsx @@ -46,9 +46,15 @@ function DatePickerModal({ shouldPositionFromTop = false, forwardedFSClass, shouldEnableMonthYearBackdropInNarrowPane = false, + anchorRef: anchorRefProp, + hasBackdrop, + shouldDisableFocusTrap = false, }: DatePickerProps) { const [selectedDate, setSelectedDate] = useState(value ?? defaultValue ?? undefined); - const anchorRef = useRef(null); + const fallbackAnchorRef = useRef(null); + // PopoverProvider treats a click inside the anchor as "not outside", so the caller's own anchor has to be used + // or clicking the date input while the calendar is open would dismiss it. + const anchorRef = anchorRefProp ?? fallbackAnchorRef; const styles = useThemeStyles(); // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to distinguish RHL and narrow layout @@ -95,6 +101,8 @@ function DatePickerModal({ forwardedFSClass={forwardedFSClass} shouldDisplayBelowModals enableEdgeToEdgeBottomSafeAreaPadding + hasBackdrop={hasBackdrop} + shouldDisableFocusTrap={shouldDisableFocusTrap} > { + setSelectedDate(newDate); + onTouched?.(); + onInputChange?.(newDate); + }; + + const segmentInput = useDateSegmentInput({value: selectedDate, mask: dateMask, isEnabled: shouldAllowTyping, onCommit: handleTypedDate}); + const {inputCallbackRef: autoFocusCallbackRef, cancelAutoFocus} = useAutoFocusInput(); const autoFocusCallbackRefRef = useRef(autoFocusCallbackRef); autoFocusCallbackRefRef.current = autoFocusCallbackRef; @@ -114,10 +127,14 @@ function DatePicker({ const showDatePickerModal = useCallback(() => { cancelAutoFocus(); - // Blur the date input before showing the modal, so the focus won't be returned after the modal is closed - textInputRef.current?.blur(); - if (shouldDismissKeyboardBeforeShow) { + // While typing is allowed the calendar sits under an input the user is still writing in, so the caret has to + // stay put. Otherwise blur first so focus is not returned once the modal closes. + if (!shouldAllowTyping) { + textInputRef.current?.blur(); + } + + if (shouldDismissKeyboardBeforeShow && !shouldAllowTyping) { // Blur whichever input is focused (e.g. a preceding text field) so closing the picker does not briefly restore its keyboard. ComposerFocusManager.blurActiveInput(); // Dismiss in parallel with opening — do not await the hide animation or the open feels sluggish. @@ -141,29 +158,36 @@ function DatePicker({ }; openPicker(); - }, [shouldDeferShowUntilPositioned, shouldDismissKeyboardBeforeShow, calculatePopoverPosition, cancelAutoFocus, setPickerVisibility]); + }, [shouldDeferShowUntilPositioned, shouldDismissKeyboardBeforeShow, shouldAllowTyping, calculatePopoverPosition, cancelAutoFocus, setPickerVisibility]); const closeDatePicker = useCallback(() => { openIntentRef.current = false; setPickerVisibility(false); - if (!shouldDismissKeyboardBeforeShow) { + if (!shouldDismissKeyboardBeforeShow || shouldAllowTyping) { return; } textInputRef.current?.blur(); ComposerFocusManager.blurActiveInput(); Keyboard.dismiss(); - }, [shouldDismissKeyboardBeforeShow, setPickerVisibility]); + }, [shouldDismissKeyboardBeforeShow, shouldAllowTyping, setPickerVisibility]); const handlePress = useCallback>( (event) => { - if ('preventDefault' in event) { + // Preventing the press would also stop the caret from landing in the segment the user clicked. + if (!shouldAllowTyping && 'preventDefault' in event) { event.preventDefault(); } + + // Clicking from one segment to another must not remeasure and reopen a calendar that is already showing. + if (shouldAllowTyping && isModalVisible) { + return; + } + showDatePickerModal(); }, - [showDatePickerModal], + [shouldAllowTyping, isModalVisible, showDatePickerModal], ); const handleInputKeyPress = useCallback( @@ -239,21 +263,26 @@ function DatePicker({ accessibilityLabel={label} role={CONST.ROLE.COMBOBOX} accessibilityState={{expanded: isModalVisible}} - value={selectedDate} - placeholder={placeholder ?? translate('common.dateFormat')} + value={segmentInput.displayValue} + selection={segmentInput.selection} + placeholder={placeholder ?? dateMask} errorText={errorText} - inputStyle={styles.pointerEventsNone} + inputStyle={shouldAllowTyping ? undefined : styles.pointerEventsNone} disabled={disabled} - hideFocusedState={shouldDismissKeyboardBeforeShow} - onPress={shouldDismissKeyboardBeforeShow ? handlePress : () => showDatePickerModal()} - onSubmitEditing={() => showDatePickerModal()} - onKeyPress={handleInputKeyPress} + hideFocusedState={shouldDismissKeyboardBeforeShow && !shouldAllowTyping} + onPress={shouldDismissKeyboardBeforeShow || shouldAllowTyping ? handlePress : () => showDatePickerModal()} + onSubmitEditing={shouldAllowTyping ? undefined : () => showDatePickerModal()} + onFocus={shouldAllowTyping ? segmentInput.onFocus : undefined} + onBlur={shouldAllowTyping ? segmentInput.onBlur : undefined} + onChangeText={shouldAllowTyping ? segmentInput.onChangeText : undefined} + onSelectionChange={shouldAllowTyping ? segmentInput.onSelectionChange : undefined} + onKeyPress={shouldAllowTyping ? segmentInput.onKeyPress : handleInputKeyPress} textInputContainerStyles={isModalVisible ? styles.borderColorFocus : {}} shouldHideClearButton={shouldHideClearButton} onClearInput={handleClear} forwardedFSClass={forwardedFSClass} autoComplete={autoComplete} - disableKeyboard + disableKeyboard={!shouldAllowTyping} rightHandSideComponent={rightHandSideComponent} /> @@ -270,6 +299,9 @@ function DatePicker({ shouldPositionFromTop={!isInverted} forwardedFSClass={forwardedFSClass} shouldCloseWhenBrowserNavigationChanged + anchorRef={anchorRef} + hasBackdrop={shouldAllowTyping ? false : undefined} + shouldDisableFocusTrap={shouldAllowTyping} /> ); diff --git a/src/components/DatePicker/types.ts b/src/components/DatePicker/types.ts index 8f54da70932f..32c214cf8de8 100644 --- a/src/components/DatePicker/types.ts +++ b/src/components/DatePicker/types.ts @@ -5,6 +5,9 @@ import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import type {OnyxFormValuesMapping} from '@src/ONYXKEYS'; +import type {RefObject} from 'react'; +import type {View} from 'react-native'; + type DatePickerBaseProps = ForwardedFSClassProps & { /** * The datepicker supports any value that `new Date()` can parse. @@ -135,6 +138,12 @@ type DatePickerProps = { * Used by inline editing flows that require background dimming. */ shouldEnableMonthYearBackdropInNarrowPane?: boolean; + + /** + * The element the calendar is positioned against, normally the date input. Clicks inside it do not dismiss the + * calendar, so the user can keep editing the input while the calendar is open. Falls back to an internal ref. + */ + anchorRef?: RefObject; } & Omit; export type {DateInputWithPickerProps, DatePickerProps}; diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index 12c5ec70c794..5cb6c2f92f7e 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -77,6 +77,7 @@ function BaseModal({ enableEdgeToEdgeBottomSafeAreaPadding, shouldApplySidePanelOffset: shouldApplySidePanelOffsetProp, hasBackdrop, + shouldDisableFocusTrap = false, backdropOpacity, shouldDisableBottomSafeAreaPadding = false, shouldIgnoreBackHandlerDuringTransition = false, @@ -369,6 +370,7 @@ function BaseModal({ backdropOpacity={backdropOpacityAdjusted} backdropTransitionOutTiming={0} hasBackdrop={hasBackdrop ?? fullscreen} + shouldDisableFocusTrap={shouldDisableFocusTrap} coverScreen={fullscreen} style={modalStyle} deviceHeight={windowHeight} diff --git a/src/components/Modal/ReanimatedModal/index.tsx b/src/components/Modal/ReanimatedModal/index.tsx index e2764483a21b..8fe4ee85f9e0 100644 --- a/src/components/Modal/ReanimatedModal/index.tsx +++ b/src/components/Modal/ReanimatedModal/index.tsx @@ -36,6 +36,7 @@ function ReanimatedModal({ coverScreen = true, children, hasBackdrop = true, + shouldDisableFocusTrap = false, backdropColor = 'black', backdropOpacity = variables.overlayOpacity, customBackdrop = null, @@ -256,7 +257,7 @@ function ReanimatedModal({ ) : ( void; +}; + +type UseDateSegmentInputResult = { + /** The text to render in the input */ + displayValue: string; + + /** The range covering the segment being edited, which selects it as a whole */ + selection: DateSegmentRange | undefined; + + onKeyPress: (event: TextInputKeyPressEvent) => void; + onSelectionChange: (event: TextInputSelectionChangeEvent) => void; + onChangeText: (text: string) => void; + onFocus: () => void; + onBlur: () => void; +}; + +function isStepKey(key: string): key is keyof typeof STEP_KEYS { + return key in STEP_KEYS; +} + +function isMoveKey(key: string): key is keyof typeof MOVE_KEYS { + return key in MOVE_KEYS; +} + +/** Whether the key is a single character that is not a digit, such as a dash the user types to leave a segment */ +function isSeparatorKey(key: string): boolean { + return key.length === 1 && !isNumeric(key); +} + +export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: UseDateSegmentInputParams): UseDateSegmentInputResult { + // The segments only describe an edit in progress, so they are seeded on focus rather than synced with the value + const [segments, setSegments] = useState(EMPTY_SEGMENTS); + const [activeSegmentName, setActiveSegmentName] = useState(FIRST_SEGMENT_NAME); + const [isEditing, setIsEditing] = useState(false); + + const {value: editingValue, ranges} = getDateDisplay(segments, mask); + // A caret parked at the start of the segment reads as three fields sharing one box. Selecting the whole segment + // instead would highlight it as a block, which is not what the design asks for. + const activeSegmentStart = ranges[activeSegmentName].start; + + const commitIfComplete = (newSegments: DateSegments) => { + const isoDate = getISODateFromSegments(newSegments); + if (!isoDate) { + return; + } + + onCommit(isoDate); + }; + + const applySegments = (newSegments: DateSegments) => { + setSegments(newSegments); + commitIfComplete(newSegments); + }; + + const handleKeyPress = (event: TextInputKeyPressEvent) => { + const key = event.nativeEvent.key; + + if (isNumeric(key)) { + event.preventDefault(); + const result = typeDigitIntoSegment(segments, activeSegmentName, key); + applySegments(result.segments); + + if (result.isSegmentComplete) { + setActiveSegmentName(getAdjacentSegmentName(activeSegmentName, 1)); + } + return; + } + + if (isStepKey(key)) { + event.preventDefault(); + applySegments(stepSegment(segments, activeSegmentName, STEP_KEYS[key])); + return; + } + + if (isMoveKey(key)) { + event.preventDefault(); + setActiveSegmentName(getAdjacentSegmentName(activeSegmentName, MOVE_KEYS[key])); + return; + } + + if (key === BACKSPACE_KEY || key === DELETE_KEY) { + event.preventDefault(); + setSegments(clearSegment(segments, activeSegmentName)); + return; + } + + if (!isSeparatorKey(key)) { + return; + } + + // A separator means the user is finished with this segment even if they only typed one digit into it + event.preventDefault(); + setActiveSegmentName(getAdjacentSegmentName(activeSegmentName, 1)); + }; + + // Clicking into the text lands the caret anywhere, so snap the selection out to whichever segment was clicked + const handleSelectionChange = (event: TextInputSelectionChangeEvent) => { + const clickedSegmentName = getSegmentNameAtPosition(event.nativeEvent.selection.start, ranges); + if (clickedSegmentName === activeSegmentName) { + return; + } + + setActiveSegmentName(clickedSegmentName); + }; + + // Every keystroke is prevented, so this only runs for text the user pasted in + const handleChangeText = (text: string) => { + const pastedSegments = getSegmentsFromText(text); + if (!hasAnySegment(pastedSegments)) { + return; + } + + applySegments(pastedSegments); + }; + + const handleFocus = () => { + setSegments(getSegmentsFromISODate(value)); + setActiveSegmentName(FIRST_SEGMENT_NAME); + setIsEditing(true); + }; + + // An unfinished edit is dropped rather than cleared, so leaving the field restores the last committed date + const handleBlur = () => { + setIsEditing(false); + setSegments(EMPTY_SEGMENTS); + }; + + if (!isEnabled) { + return { + displayValue: value, + selection: undefined, + onKeyPress: () => {}, + onSelectionChange: () => {}, + onChangeText: () => {}, + onFocus: () => {}, + onBlur: () => {}, + }; + } + + return { + displayValue: isEditing ? editingValue : value, + selection: isEditing ? {start: activeSegmentStart, end: activeSegmentStart} : undefined, + onKeyPress: handleKeyPress, + onSelectionChange: handleSelectionChange, + onChangeText: handleChangeText, + onFocus: handleFocus, + onBlur: handleBlur, + }; +} + +export type {UseDateSegmentInputParams, UseDateSegmentInputResult}; diff --git a/src/libs/DateInputMaskUtils.ts b/src/libs/DateInputMaskUtils.ts new file mode 100644 index 000000000000..ad790e1fe2fc --- /dev/null +++ b/src/libs/DateInputMaskUtils.ts @@ -0,0 +1,279 @@ +/** + * Helpers for the guided date input, where the year, month and day are edited as separate segments. The segments hold + * the digits the user has typed rather than a parsed date, so a half finished segment is representable and the + * display text can be rebuilt from them at any point. + */ +import CONST from '@src/CONST'; + +import type {TupleToUnion} from 'type-fest'; + +import {getDaysInMonth, isValid, parse} from 'date-fns'; + +const DATE_SEGMENT_NAMES = ['year', 'month', 'day'] as const; + +const YEAR_LENGTH = 4; +const SEGMENT_LENGTH = 2; + +const FIRST_MONTH = 1; +const LAST_MONTH = 12; +const FIRST_DAY = 1; + +/** The longest month, used as the day limit until the typed month says otherwise */ +const MAX_DAYS_IN_MONTH = 31; + +/** A leading month digit above this cannot start a two digit month, so the segment is zero padded and completed early */ +const MAX_LEADING_MONTH_DIGIT = 1; + +/** A leading day digit above this cannot start a two digit day, so the segment is zero padded and completed early */ +const MAX_LEADING_DAY_DIGIT = 3; + +/** Mask characters standing in for a digit are letters, so anything else is a separator to copy through verbatim */ +const MASK_LETTER_REGEX = /\p{L}/u; + +const NON_DIGIT_REGEX = /\D/g; + +type DateSegmentName = TupleToUnion; + +/** The digits typed into each segment, empty when the segment has not been filled in yet */ +type DateSegments = Record; + +type DateSegmentRange = { + start: number; + end: number; +}; + +type DateMaskPart = { + name: DateSegmentName; + + /** The mask letters shown while the segment is empty, such as YYYY */ + placeholder: string; + + /** The characters the mask puts after this segment, such as a dash */ + separator: string; +}; + +type DateDisplay = { + /** The text to show in the input */ + value: string; + + /** Where each segment sits inside that text, so a segment can be selected as a whole */ + ranges: Record; +}; + +const EMPTY_SEGMENTS: DateSegments = {year: '', month: '', day: ''}; + +function isMaskLetter(character: string): boolean { + return MASK_LETTER_REGEX.test(character); +} + +/** + * Reads the localized mask into one part per segment. Every locale writes the segments in year, month, day order, so + * the runs of letters are matched to the segments by position. + */ +function getDateMaskParts(mask: string): DateMaskPart[] { + const parts: DateMaskPart[] = []; + let index = 0; + + while (index < mask.length && parts.length < DATE_SEGMENT_NAMES.length) { + let placeholder = ''; + while (index < mask.length && isMaskLetter(mask.charAt(index))) { + placeholder += mask.charAt(index); + index++; + } + + if (!placeholder) { + break; + } + + let separator = ''; + while (index < mask.length && !isMaskLetter(mask.charAt(index))) { + separator += mask.charAt(index); + index++; + } + + parts.push({name: DATE_SEGMENT_NAMES[parts.length], placeholder, separator}); + } + + return parts; +} + +function getSegmentLength(name: DateSegmentName): number { + return name === 'year' ? YEAR_LENGTH : SEGMENT_LENGTH; +} + +function getDaysInTypedMonth(segments: DateSegments): number { + if (segments.month.length !== SEGMENT_LENGTH || segments.year.length !== YEAR_LENGTH) { + return MAX_DAYS_IN_MONTH; + } + + return getDaysInMonth(new Date(Number(segments.year), Number(segments.month) - 1)); +} + +/** Trims a day the newly typed year or month cannot have, so February never keeps a 30th from the month before */ +function withDayInMonth(segments: DateSegments): DateSegments { + if (segments.day.length !== SEGMENT_LENGTH) { + return segments; + } + + const daysInMonth = getDaysInTypedMonth(segments); + return Number(segments.day) > daysInMonth ? {...segments, day: String(daysInMonth).padStart(SEGMENT_LENGTH, '0')} : segments; +} + +function getDateDisplay(segments: DateSegments, mask: string): DateDisplay { + let value = ''; + const ranges: Record = {year: {start: 0, end: 0}, month: {start: 0, end: 0}, day: {start: 0, end: 0}}; + + for (const part of getDateMaskParts(mask)) { + const text = segments[part.name] || part.placeholder; + + ranges[part.name] = {start: value.length, end: value.length + text.length}; + value += `${text}${part.separator}`; + } + + return {value, ranges}; +} + +/** Which segment a caret position falls in, so clicking into the text selects the segment that was clicked */ +function getSegmentNameAtPosition(position: number, ranges: Record): DateSegmentName { + const name = DATE_SEGMENT_NAMES.find((segmentName) => position <= ranges[segmentName].end); + + return name ?? DATE_SEGMENT_NAMES[DATE_SEGMENT_NAMES.length - 1]; +} + +/** The segment `offset` places away, clamped so moving past either end keeps the outermost segment selected */ +function getAdjacentSegmentName(name: DateSegmentName, offset: number): DateSegmentName { + const nextIndex = DATE_SEGMENT_NAMES.indexOf(name) + offset; + const clampedIndex = Math.min(Math.max(nextIndex, 0), DATE_SEGMENT_NAMES.length - 1); + + return DATE_SEGMENT_NAMES[clampedIndex]; +} + +/** + * Adds one typed digit to a segment. A digit that cannot extend what is already there starts the segment over, which + * is what makes typing over a filled in date feel like overwriting it. + */ +function typeDigitIntoSegment(segments: DateSegments, name: DateSegmentName, digit: string): {segments: DateSegments; isSegmentComplete: boolean} { + const current = segments[name].length >= getSegmentLength(name) ? '' : segments[name]; + + if (name === 'year') { + // No year we support starts with a zero, so swallow the keystroke rather than start a year that cannot resolve + if (!current && digit === '0') { + return {segments, isSegmentComplete: false}; + } + + const year = `${current}${digit}`; + return {segments: withDayInMonth({...segments, year}), isSegmentComplete: year.length === YEAR_LENGTH}; + } + + const isMonth = name === 'month'; + const maxLeadingDigit = isMonth ? MAX_LEADING_MONTH_DIGIT : MAX_LEADING_DAY_DIGIT; + const lowest = isMonth ? FIRST_MONTH : FIRST_DAY; + const highest = isMonth ? LAST_MONTH : getDaysInTypedMonth(segments); + + if (!current) { + if (Number(digit) > maxLeadingDigit) { + const padded = `0${digit}`; + return {segments: withDayInMonth({...segments, [name]: padded}), isSegmentComplete: true}; + } + + return {segments: {...segments, [name]: digit}, isSegmentComplete: false}; + } + + const candidate = Number(`${current}${digit}`); + if (candidate >= lowest && candidate <= highest) { + return {segments: withDayInMonth({...segments, [name]: `${current}${digit}`}), isSegmentComplete: true}; + } + + // The two digit number is out of range, so treat the keystroke as the start of a new segment instead + return typeDigitIntoSegment({...segments, [name]: ''}, name, digit); +} + +/** Moves a segment up or down by `offset`, wrapping months and days and starting from today when the segment is empty */ +function stepSegment(segments: DateSegments, name: DateSegmentName, offset: number): DateSegments { + const today = new Date(); + + if (name === 'year') { + const current = segments.year.length === YEAR_LENGTH ? Number(segments.year) : today.getFullYear(); + const year = Math.min(Math.max(current + offset, CONST.CALENDAR_PICKER.MIN_YEAR), CONST.CALENDAR_PICKER.MAX_YEAR); + + return withDayInMonth({...segments, year: String(year)}); + } + + const isMonth = name === 'month'; + const highest = isMonth ? LAST_MONTH : getDaysInTypedMonth(segments); + const fallback = isMonth ? today.getMonth() + FIRST_MONTH : today.getDate(); + const current = segments[name].length === SEGMENT_LENGTH ? Number(segments[name]) : fallback; + const stepped = ((current - 1 + offset + highest) % highest) + 1; + + return withDayInMonth({...segments, [name]: String(stepped).padStart(SEGMENT_LENGTH, '0')}); +} + +function clearSegment(segments: DateSegments, name: DateSegmentName): DateSegments { + return {...segments, [name]: ''}; +} + +function getSegmentsFromISODate(value: string | undefined): DateSegments { + if (!value || !isValid(parse(value, CONST.DATE.FNS_FORMAT_STRING, new Date()))) { + return EMPTY_SEGMENTS; + } + + const digits = value.replaceAll(NON_DIGIT_REGEX, ''); + + return { + year: digits.slice(0, YEAR_LENGTH), + month: digits.slice(YEAR_LENGTH, YEAR_LENGTH + SEGMENT_LENGTH), + day: digits.slice(YEAR_LENGTH + SEGMENT_LENGTH), + }; +} + +function getNextUnfilledSegmentName(segments: DateSegments): DateSegmentName | undefined { + return DATE_SEGMENT_NAMES.find((name) => segments[name].length < getSegmentLength(name)); +} + +/** Fills the segments from arbitrary text, so pasting a date works without going through it a keystroke at a time */ +function getSegmentsFromText(text: string): DateSegments { + const digits = text.replaceAll(NON_DIGIT_REGEX, ''); + let filled = EMPTY_SEGMENTS; + + for (const digit of digits) { + const name = getNextUnfilledSegmentName(filled); + if (!name) { + break; + } + + filled = typeDigitIntoSegment(filled, name, digit).segments; + } + + return filled; +} + +/** Returns the date in the format the rest of the app stores, or undefined while any segment is still unfinished */ +function getISODateFromSegments(segments: DateSegments): string | undefined { + if (segments.year.length !== YEAR_LENGTH || segments.month.length !== SEGMENT_LENGTH || segments.day.length !== SEGMENT_LENGTH) { + return undefined; + } + + const isoDate = `${segments.year}-${segments.month}-${segments.day}`; + + return isValid(parse(isoDate, CONST.DATE.FNS_FORMAT_STRING, new Date())) ? isoDate : undefined; +} + +function hasAnySegment(segments: DateSegments): boolean { + return DATE_SEGMENT_NAMES.some((name) => !!segments[name]); +} + +export { + DATE_SEGMENT_NAMES, + EMPTY_SEGMENTS, + clearSegment, + getAdjacentSegmentName, + getDateDisplay, + getISODateFromSegments, + getSegmentNameAtPosition, + getSegmentsFromISODate, + getSegmentsFromText, + hasAnySegment, + stepSegment, + typeDigitIntoSegment, +}; +export type {DateSegmentName, DateSegmentRange, DateSegments}; diff --git a/src/libs/isTypedDateInputSupported/index.native.ts b/src/libs/isTypedDateInputSupported/index.native.ts new file mode 100644 index 000000000000..a831cc67c730 --- /dev/null +++ b/src/libs/isTypedDateInputSupported/index.native.ts @@ -0,0 +1,5 @@ +import type IsTypedDateInputSupported from './types'; + +const isTypedDateInputSupported: IsTypedDateInputSupported = () => false; + +export default isTypedDateInputSupported; diff --git a/src/libs/isTypedDateInputSupported/index.ts b/src/libs/isTypedDateInputSupported/index.ts new file mode 100644 index 000000000000..61021e14acc2 --- /dev/null +++ b/src/libs/isTypedDateInputSupported/index.ts @@ -0,0 +1,11 @@ +/** + * Whether a date field accepts typed input alongside the calendar picker. Touch devices keep the picker on its own so + * the soft keyboard does not cover the calendar. + */ +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; + +import type IsTypedDateInputSupported from './types'; + +const isTypedDateInputSupported: IsTypedDateInputSupported = () => !canUseTouchScreen(); + +export default isTypedDateInputSupported; diff --git a/src/libs/isTypedDateInputSupported/types.ts b/src/libs/isTypedDateInputSupported/types.ts new file mode 100644 index 000000000000..02f08dc515a7 --- /dev/null +++ b/src/libs/isTypedDateInputSupported/types.ts @@ -0,0 +1,3 @@ +type IsTypedDateInputSupported = () => boolean; + +export default IsTypedDateInputSupported; diff --git a/tests/unit/DateInputMaskUtilsTest.ts b/tests/unit/DateInputMaskUtilsTest.ts new file mode 100644 index 000000000000..b930743e47b7 --- /dev/null +++ b/tests/unit/DateInputMaskUtilsTest.ts @@ -0,0 +1,206 @@ +import { + clearSegment, + getAdjacentSegmentName, + getDateDisplay, + getISODateFromSegments, + getSegmentNameAtPosition, + getSegmentsFromISODate, + getSegmentsFromText, + hasAnySegment, + stepSegment, + typeDigitIntoSegment, +} from '@libs/DateInputMaskUtils'; +import type {DateSegments} from '@libs/DateInputMaskUtils'; + +const MASK = 'YYYY-MM-DD'; +const EMPTY: DateSegments = {year: '', month: '', day: ''}; + +function segments(year: string, month: string, day: string): DateSegments { + return {year, month, day}; +} + +describe('DateInputMaskUtils', () => { + describe('typeDigitIntoSegment', () => { + it('rejects a leading zero in the year', () => { + expect(typeDigitIntoSegment(EMPTY, 'year', '0')).toEqual({segments: EMPTY, isSegmentComplete: false}); + }); + + it('completes the year on the fourth digit', () => { + expect(typeDigitIntoSegment(segments('202', '', ''), 'year', '6')).toEqual({segments: segments('2026', '', ''), isSegmentComplete: true}); + expect(typeDigitIntoSegment(segments('20', '', ''), 'year', '2')).toEqual({segments: segments('202', '', ''), isSegmentComplete: false}); + }); + + it('starts the year over when it is already full', () => { + expect(typeDigitIntoSegment(segments('2026', '', ''), 'year', '1')).toEqual({segments: segments('1', '', ''), isSegmentComplete: false}); + }); + + it('zero pads a month that cannot start a two digit month', () => { + expect(typeDigitIntoSegment(segments('2026', '', ''), 'month', '9')).toEqual({segments: segments('2026', '09', ''), isSegmentComplete: true}); + }); + + it('waits for a second digit when the month could still be a teen month', () => { + expect(typeDigitIntoSegment(segments('2026', '', ''), 'month', '1')).toEqual({segments: segments('2026', '1', ''), isSegmentComplete: false}); + expect(typeDigitIntoSegment(segments('2026', '1', ''), 'month', '2')).toEqual({segments: segments('2026', '12', ''), isSegmentComplete: true}); + }); + + it('restarts the month when the two digit number is out of range', () => { + expect(typeDigitIntoSegment(segments('2026', '1', ''), 'month', '3')).toEqual({segments: segments('2026', '03', ''), isSegmentComplete: true}); + expect(typeDigitIntoSegment(segments('2026', '0', ''), 'month', '0')).toEqual({segments: segments('2026', '0', ''), isSegmentComplete: false}); + }); + + it('limits the day to the typed month', () => { + expect(typeDigitIntoSegment(segments('2026', '01', '3'), 'day', '1')).toEqual({segments: segments('2026', '01', '31'), isSegmentComplete: true}); + + // February has no 31st, so the keystroke starts the day over instead + expect(typeDigitIntoSegment(segments('2026', '02', '3'), 'day', '1')).toEqual({segments: segments('2026', '02', '1'), isSegmentComplete: false}); + }); + + it('allows February 29 in a leap year only', () => { + expect(typeDigitIntoSegment(segments('2024', '02', '2'), 'day', '9')).toEqual({segments: segments('2024', '02', '29'), isSegmentComplete: true}); + expect(typeDigitIntoSegment(segments('2026', '02', '2'), 'day', '9')).toEqual({segments: segments('2026', '02', '09'), isSegmentComplete: true}); + }); + + it('allows any day up to 31 before the month is known', () => { + expect(typeDigitIntoSegment(segments('', '', '3'), 'day', '1')).toEqual({segments: segments('', '', '31'), isSegmentComplete: true}); + }); + + it('trims a day the newly typed month cannot have', () => { + expect(typeDigitIntoSegment(segments('2026', '1', '31'), 'month', '1').segments).toEqual(segments('2026', '11', '30')); + }); + + it('trims a day the newly typed year cannot have', () => { + expect(typeDigitIntoSegment(segments('202', '02', '29'), 'year', '6').segments).toEqual(segments('2026', '02', '28')); + }); + }); + + describe('stepSegment', () => { + it('wraps the month at both ends', () => { + expect(stepSegment(segments('2026', '12', ''), 'month', 1)).toEqual(segments('2026', '01', '')); + expect(stepSegment(segments('2026', '01', ''), 'month', -1)).toEqual(segments('2026', '12', '')); + }); + + it('wraps the day within the typed month', () => { + expect(stepSegment(segments('2026', '02', '28'), 'day', 1)).toEqual(segments('2026', '02', '01')); + }); + + it('steps the year without wrapping', () => { + expect(stepSegment(segments('2026', '', ''), 'year', 1)).toEqual(segments('2027', '', '')); + }); + + it('trims the day when stepping into a shorter month', () => { + expect(stepSegment(segments('2026', '01', '31'), 'month', 1)).toEqual(segments('2026', '02', '28')); + }); + }); + + describe('getDateDisplay', () => { + it('shows the mask while every segment is empty', () => { + expect(getDateDisplay(EMPTY, MASK).value).toBe(MASK); + }); + + it('keeps the mask for the segments still to be filled in', () => { + expect(getDateDisplay(segments('2026', '', ''), MASK).value).toBe('2026-MM-DD'); + expect(getDateDisplay(segments('2026', '09', ''), MASK).value).toBe('2026-09-DD'); + expect(getDateDisplay(segments('2026', '09', '18'), MASK).value).toBe('2026-09-18'); + }); + + it('reports where each segment sits in the text', () => { + expect(getDateDisplay(segments('2026', '09', '18'), MASK).ranges).toEqual({ + year: {start: 0, end: 4}, + month: {start: 5, end: 7}, + day: {start: 8, end: 10}, + }); + }); + + it('shifts the later ranges when a segment holds a single digit', () => { + const {value, ranges} = getDateDisplay(segments('2026', '1', ''), MASK); + + expect(value).toBe('2026-1-DD'); + expect(ranges.month).toEqual({start: 5, end: 6}); + expect(ranges.day).toEqual({start: 7, end: 9}); + }); + + it('uses the letters and separators of the localized mask', () => { + expect(getDateDisplay(segments('2026', '', ''), 'AAAA-MM-JJ').value).toBe('2026-MM-JJ'); + }); + }); + + describe('getSegmentNameAtPosition', () => { + const {ranges} = getDateDisplay(segments('2026', '09', '18'), MASK); + + it('maps a position to the segment that covers it', () => { + expect(getSegmentNameAtPosition(0, ranges)).toBe('year'); + expect(getSegmentNameAtPosition(4, ranges)).toBe('year'); + expect(getSegmentNameAtPosition(6, ranges)).toBe('month'); + expect(getSegmentNameAtPosition(9, ranges)).toBe('day'); + }); + + it('falls back to the last segment past the end of the text', () => { + expect(getSegmentNameAtPosition(99, ranges)).toBe('day'); + }); + }); + + describe('getAdjacentSegmentName', () => { + it('moves between segments', () => { + expect(getAdjacentSegmentName('year', 1)).toBe('month'); + expect(getAdjacentSegmentName('month', -1)).toBe('year'); + }); + + it('stays on the outermost segment rather than wrapping', () => { + expect(getAdjacentSegmentName('year', -1)).toBe('year'); + expect(getAdjacentSegmentName('day', 1)).toBe('day'); + }); + }); + + describe('getSegmentsFromText', () => { + it('fills the segments from a pasted date', () => { + expect(getSegmentsFromText('2026-09-18')).toEqual(segments('2026', '09', '18')); + expect(getSegmentsFromText('20260918')).toEqual(segments('2026', '09', '18')); + }); + + it('stops once every segment is full', () => { + expect(getSegmentsFromText('20260918123')).toEqual(segments('2026', '09', '18')); + }); + + it('fills what it can from a partial date', () => { + expect(getSegmentsFromText('2026-09')).toEqual(segments('2026', '09', '')); + expect(getSegmentsFromText('')).toEqual(EMPTY); + }); + }); + + describe('getISODateFromSegments', () => { + it('returns the stored format once every segment is filled in', () => { + expect(getISODateFromSegments(segments('2026', '09', '18'))).toBe('2026-09-18'); + }); + + it('returns undefined while a segment is unfinished', () => { + expect(getISODateFromSegments(segments('2026', '9', '18'))).toBeUndefined(); + expect(getISODateFromSegments(segments('202', '09', '18'))).toBeUndefined(); + expect(getISODateFromSegments(EMPTY)).toBeUndefined(); + }); + }); + + describe('getSegmentsFromISODate', () => { + it('reads back a stored date', () => { + expect(getSegmentsFromISODate('2026-09-18')).toEqual(segments('2026', '09', '18')); + }); + + it('returns empty segments for a value it cannot parse', () => { + expect(getSegmentsFromISODate('')).toEqual(EMPTY); + expect(getSegmentsFromISODate(undefined)).toEqual(EMPTY); + expect(getSegmentsFromISODate('not a date')).toEqual(EMPTY); + }); + }); + + describe('clearSegment', () => { + it('empties only the named segment', () => { + expect(clearSegment(segments('2026', '09', '18'), 'month')).toEqual(segments('2026', '', '18')); + }); + }); + + describe('hasAnySegment', () => { + it('reports whether anything has been filled in', () => { + expect(hasAnySegment(EMPTY)).toBe(false); + expect(hasAnySegment(segments('', '09', ''))).toBe(true); + }); + }); +}); From 79a94e838f83273dc8396bc1bec923ba7323047f Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 17 Sep 2026 20:29:06 +0530 Subject: [PATCH 02/80] fix: reach the overlay-free popover from a narrow pane, and open the calendar on focus Signed-off-by: krishna2323 --- src/components/DatePicker/DatePickerModal.tsx | 8 ++++---- src/components/DatePicker/index.tsx | 17 ++++++++++++++--- src/components/Modal/BaseModal.tsx | 2 -- src/components/Modal/ReanimatedModal/index.tsx | 3 +-- src/components/Modal/ReanimatedModal/types.ts | 6 ------ src/components/Popover/index.tsx | 7 ++++++- src/components/Popover/types.ts | 7 +++++++ 7 files changed, 32 insertions(+), 18 deletions(-) diff --git a/src/components/DatePicker/DatePickerModal.tsx b/src/components/DatePicker/DatePickerModal.tsx index d6f0d12a52d1..0b0f76f4de9b 100644 --- a/src/components/DatePicker/DatePickerModal.tsx +++ b/src/components/DatePicker/DatePickerModal.tsx @@ -47,8 +47,8 @@ function DatePickerModal({ forwardedFSClass, shouldEnableMonthYearBackdropInNarrowPane = false, anchorRef: anchorRefProp, - hasBackdrop, - shouldDisableFocusTrap = false, + withoutOverlay = false, + shouldAllowWithoutOverlayInNarrowPane = false, }: DatePickerProps) { const [selectedDate, setSelectedDate] = useState(value ?? defaultValue ?? undefined); const fallbackAnchorRef = useRef(null); @@ -101,8 +101,8 @@ function DatePickerModal({ forwardedFSClass={forwardedFSClass} shouldDisplayBelowModals enableEdgeToEdgeBottomSafeAreaPadding - hasBackdrop={hasBackdrop} - shouldDisableFocusTrap={shouldDisableFocusTrap} + withoutOverlay={withoutOverlay} + shouldAllowWithoutOverlayInNarrowPane={shouldAllowWithoutOverlayInNarrowPane} > { + segmentInput.onFocus(); + + if (isModalVisible) { + return; + } + + showDatePickerModal(); + }; + const handleInputKeyPress = useCallback( (event: TextInputKeyPressEvent) => { if (!isNumeric(event.nativeEvent.key)) { @@ -272,7 +283,7 @@ function DatePicker({ hideFocusedState={shouldDismissKeyboardBeforeShow && !shouldAllowTyping} onPress={shouldDismissKeyboardBeforeShow || shouldAllowTyping ? handlePress : () => showDatePickerModal()} onSubmitEditing={shouldAllowTyping ? undefined : () => showDatePickerModal()} - onFocus={shouldAllowTyping ? segmentInput.onFocus : undefined} + onFocus={shouldAllowTyping ? handleFocus : undefined} onBlur={shouldAllowTyping ? segmentInput.onBlur : undefined} onChangeText={shouldAllowTyping ? segmentInput.onChangeText : undefined} onSelectionChange={shouldAllowTyping ? segmentInput.onSelectionChange : undefined} @@ -300,8 +311,8 @@ function DatePicker({ forwardedFSClass={forwardedFSClass} shouldCloseWhenBrowserNavigationChanged anchorRef={anchorRef} - hasBackdrop={shouldAllowTyping ? false : undefined} - shouldDisableFocusTrap={shouldAllowTyping} + withoutOverlay={shouldAllowTyping} + shouldAllowWithoutOverlayInNarrowPane={shouldAllowTyping} /> ); diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index 5cb6c2f92f7e..12c5ec70c794 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -77,7 +77,6 @@ function BaseModal({ enableEdgeToEdgeBottomSafeAreaPadding, shouldApplySidePanelOffset: shouldApplySidePanelOffsetProp, hasBackdrop, - shouldDisableFocusTrap = false, backdropOpacity, shouldDisableBottomSafeAreaPadding = false, shouldIgnoreBackHandlerDuringTransition = false, @@ -370,7 +369,6 @@ function BaseModal({ backdropOpacity={backdropOpacityAdjusted} backdropTransitionOutTiming={0} hasBackdrop={hasBackdrop ?? fullscreen} - shouldDisableFocusTrap={shouldDisableFocusTrap} coverScreen={fullscreen} style={modalStyle} deviceHeight={windowHeight} diff --git a/src/components/Modal/ReanimatedModal/index.tsx b/src/components/Modal/ReanimatedModal/index.tsx index 8fe4ee85f9e0..e2764483a21b 100644 --- a/src/components/Modal/ReanimatedModal/index.tsx +++ b/src/components/Modal/ReanimatedModal/index.tsx @@ -36,7 +36,6 @@ function ReanimatedModal({ coverScreen = true, children, hasBackdrop = true, - shouldDisableFocusTrap = false, backdropColor = 'black', backdropOpacity = variables.overlayOpacity, customBackdrop = null, @@ -257,7 +256,7 @@ function ReanimatedModal({ ) : ( {}, animationIn = 'fadeIn', @@ -133,7 +134,11 @@ function Popover(props: PopoverProps) { ); } - if (withoutOverlay && !shouldUseNarrowLayout) { + // A narrow pane normally forces the full modal. An opting-in caller keeps the overlay-free popover there, but a + // small screen never does, since there is no room for a popover beside the control that opened it. + const canSkipOverlay = !shouldUseNarrowLayout || (shouldAllowWithoutOverlayInNarrowPane && !isSmallScreenWidth); + + if (withoutOverlay && canSkipOverlay) { return createPortal( ; From de3a085a6e63ec251076b7d464222bc426f17504 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 17 Sep 2026 20:43:54 +0530 Subject: [PATCH 03/80] fix: keep the date mask a fixed width and track the caret per digit Signed-off-by: krishna2323 --- src/hooks/useDateSegmentInput.ts | 75 ++++++++++++++++++++++------ src/libs/DateInputMaskUtils.ts | 5 +- tests/unit/DateInputMaskUtilsTest.ts | 15 ++++-- 3 files changed, 73 insertions(+), 22 deletions(-) diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index 788cf73e1e0f..8d77746a2819 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -21,7 +21,7 @@ import {isNumeric} from '@libs/ValidationUtils'; import type {TextInputKeyPressEvent, TextInputSelectionChangeEvent} from 'react-native'; -import {useState} from 'react'; +import {useRef, useState} from 'react'; const FIRST_SEGMENT_NAME = DATE_SEGMENT_NAMES[0]; @@ -75,12 +75,29 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: // The segments only describe an edit in progress, so they are seeded on focus rather than synced with the value const [segments, setSegments] = useState(EMPTY_SEGMENTS); const [activeSegmentName, setActiveSegmentName] = useState(FIRST_SEGMENT_NAME); + const [caretOffset, setCaretOffset] = useState(0); const [isEditing, setIsEditing] = useState(false); + // Re-rendering with a new value makes the browser report a caret of its own choosing. Honouring that would drag + // the active segment around, so the first report after a keystroke is discarded as an echo of our own update. + const hasPendingCaretEchoRef = useRef(false); const {value: editingValue, ranges} = getDateDisplay(segments, mask); - // A caret parked at the start of the segment reads as three fields sharing one box. Selecting the whole segment - // instead would highlight it as a block, which is not what the design asks for. - const activeSegmentStart = ranges[activeSegmentName].start; + const activeRange = ranges[activeSegmentName]; + // A caret parked on a digit place reads as three fields sharing one box. Selecting the whole segment instead would + // highlight it as a block, which is not what the design asks for. + const caretPosition = activeRange.start + caretOffset; + + /** + * A caret may rest on any digit place already typed, or just after the last of them, but never out on a mask + * letter. An empty segment therefore only ever has its start, which is what stops a click landing on a bare Y. + */ + const getFurthestOffset = (name: DateSegmentName, currentSegments: DateSegments) => currentSegments[name].length; + + const moveCaret = (name: DateSegmentName, offset: number, nextSegments: DateSegments = segments) => { + hasPendingCaretEchoRef.current = true; + setActiveSegmentName(name); + setCaretOffset(Math.min(Math.max(offset, 0), getFurthestOffset(name, nextSegments))); + }; const commitIfComplete = (newSegments: DateSegments) => { const isoDate = getISODateFromSegments(newSegments); @@ -104,27 +121,47 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: const result = typeDigitIntoSegment(segments, activeSegmentName, key); applySegments(result.segments); - if (result.isSegmentComplete) { - setActiveSegmentName(getAdjacentSegmentName(activeSegmentName, 1)); - } + // A completed segment hands over to its neighbour. The last one has nowhere to hand over to, so the caret + // rests after the digit just typed and the next digit overwrites the segment. + const nextSegmentName = result.isSegmentComplete ? getAdjacentSegmentName(activeSegmentName, 1) : activeSegmentName; + const nextOffset = nextSegmentName === activeSegmentName ? result.segments[activeSegmentName].length : 0; + + moveCaret(nextSegmentName, nextOffset, result.segments); return; } if (isStepKey(key)) { event.preventDefault(); - applySegments(stepSegment(segments, activeSegmentName, STEP_KEYS[key])); + const steppedSegments = stepSegment(segments, activeSegmentName, STEP_KEYS[key]); + applySegments(steppedSegments); + moveCaret(activeSegmentName, caretOffset, steppedSegments); return; } if (isMoveKey(key)) { event.preventDefault(); - setActiveSegmentName(getAdjacentSegmentName(activeSegmentName, MOVE_KEYS[key])); + const step = MOVE_KEYS[key]; + const nextOffset = caretOffset + step; + + // Stepping past either end of what has been typed carries on into the neighbouring segment. + if (nextOffset >= 0 && nextOffset <= getFurthestOffset(activeSegmentName, segments)) { + moveCaret(activeSegmentName, nextOffset); + return; + } + + const adjacentSegmentName = getAdjacentSegmentName(activeSegmentName, step); + if (adjacentSegmentName === activeSegmentName) { + return; + } + + moveCaret(adjacentSegmentName, step > 0 ? 0 : getFurthestOffset(adjacentSegmentName, segments)); return; } if (key === BACKSPACE_KEY || key === DELETE_KEY) { event.preventDefault(); setSegments(clearSegment(segments, activeSegmentName)); + moveCaret(activeSegmentName, 0); return; } @@ -134,17 +171,21 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: // A separator means the user is finished with this segment even if they only typed one digit into it event.preventDefault(); - setActiveSegmentName(getAdjacentSegmentName(activeSegmentName, 1)); + moveCaret(getAdjacentSegmentName(activeSegmentName, 1), 0); }; - // Clicking into the text lands the caret anywhere, so snap the selection out to whichever segment was clicked + // Clicking into the text lands the caret anywhere, so snap it onto the digit place that was clicked const handleSelectionChange = (event: TextInputSelectionChangeEvent) => { - const clickedSegmentName = getSegmentNameAtPosition(event.nativeEvent.selection.start, ranges); - if (clickedSegmentName === activeSegmentName) { + if (hasPendingCaretEchoRef.current) { + hasPendingCaretEchoRef.current = false; return; } - setActiveSegmentName(clickedSegmentName); + const position = event.nativeEvent.selection.start; + const clickedSegmentName = getSegmentNameAtPosition(position, ranges); + const clickedOffset = position - ranges[clickedSegmentName].start; + + moveCaret(clickedSegmentName, clickedOffset); }; // Every keystroke is prevented, so this only runs for text the user pasted in @@ -155,11 +196,12 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: } applySegments(pastedSegments); + moveCaret(FIRST_SEGMENT_NAME, 0); }; const handleFocus = () => { setSegments(getSegmentsFromISODate(value)); - setActiveSegmentName(FIRST_SEGMENT_NAME); + moveCaret(FIRST_SEGMENT_NAME, 0); setIsEditing(true); }; @@ -167,6 +209,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: const handleBlur = () => { setIsEditing(false); setSegments(EMPTY_SEGMENTS); + setCaretOffset(0); }; if (!isEnabled) { @@ -183,7 +226,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: return { displayValue: isEditing ? editingValue : value, - selection: isEditing ? {start: activeSegmentStart, end: activeSegmentStart} : undefined, + selection: isEditing ? {start: caretPosition, end: caretPosition} : undefined, onKeyPress: handleKeyPress, onSelectionChange: handleSelectionChange, onChangeText: handleChangeText, diff --git a/src/libs/DateInputMaskUtils.ts b/src/libs/DateInputMaskUtils.ts index ad790e1fe2fc..0e60781d9673 100644 --- a/src/libs/DateInputMaskUtils.ts +++ b/src/libs/DateInputMaskUtils.ts @@ -124,7 +124,10 @@ function getDateDisplay(segments: DateSegments, mask: string): DateDisplay { const ranges: Record = {year: {start: 0, end: 0}, month: {start: 0, end: 0}, day: {start: 0, end: 0}}; for (const part of getDateMaskParts(mask)) { - const text = segments[part.name] || part.placeholder; + // Typed digits replace the mask letters one at a time, so a half typed year reads as 2YYY rather than 2. This + // keeps every segment the width of its mask, which is what lets a caret position mean the same thing twice. + const digits = segments[part.name].slice(0, part.placeholder.length); + const text = `${digits}${part.placeholder.slice(digits.length)}`; ranges[part.name] = {start: value.length, end: value.length + text.length}; value += `${text}${part.separator}`; diff --git a/tests/unit/DateInputMaskUtilsTest.ts b/tests/unit/DateInputMaskUtilsTest.ts index b930743e47b7..6b113facbe87 100644 --- a/tests/unit/DateInputMaskUtilsTest.ts +++ b/tests/unit/DateInputMaskUtilsTest.ts @@ -103,6 +103,12 @@ describe('DateInputMaskUtils', () => { expect(getDateDisplay(segments('2026', '09', '18'), MASK).value).toBe('2026-09-18'); }); + it('keeps the mask letters of the digit places a half typed segment has not reached', () => { + expect(getDateDisplay(segments('2', '', ''), MASK).value).toBe('2YYY-MM-DD'); + expect(getDateDisplay(segments('20', '', ''), MASK).value).toBe('20YY-MM-DD'); + expect(getDateDisplay(segments('2026', '1', ''), MASK).value).toBe('2026-1M-DD'); + }); + it('reports where each segment sits in the text', () => { expect(getDateDisplay(segments('2026', '09', '18'), MASK).ranges).toEqual({ year: {start: 0, end: 4}, @@ -111,12 +117,11 @@ describe('DateInputMaskUtils', () => { }); }); - it('shifts the later ranges when a segment holds a single digit', () => { - const {value, ranges} = getDateDisplay(segments('2026', '1', ''), MASK); + it('holds the ranges still while a segment is half typed, so a caret position keeps its meaning', () => { + const {ranges} = getDateDisplay(segments('2026', '1', ''), MASK); - expect(value).toBe('2026-1-DD'); - expect(ranges.month).toEqual({start: 5, end: 6}); - expect(ranges.day).toEqual({start: 7, end: 9}); + expect(ranges.month).toEqual({start: 5, end: 7}); + expect(ranges.day).toEqual({start: 8, end: 10}); }); it('uses the letters and separators of the localized mask', () => { From 59e4711538cab4f6a11ff72a6a65e8f3ab191f49 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 17 Sep 2026 21:16:37 +0530 Subject: [PATCH 04/80] fix: match the prototype's segment rules for typed dates Signed-off-by: krishna2323 --- src/hooks/useDateSegmentInput.ts | 98 ++++++++-------- src/libs/DateInputMaskUtils.ts | 160 +++++++++++++++------------ tests/unit/DateInputMaskUtilsTest.ts | 85 ++++++-------- 3 files changed, 172 insertions(+), 171 deletions(-) diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index 8d77746a2819..958cbc8106a1 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -5,7 +5,6 @@ import { DATE_SEGMENT_NAMES, EMPTY_SEGMENTS, - clearSegment, getAdjacentSegmentName, getDateDisplay, getISODateFromSegments, @@ -13,8 +12,8 @@ import { getSegmentsFromISODate, getSegmentsFromText, hasAnySegment, - stepSegment, - typeDigitIntoSegment, + removeLastDigit, + typeDigitIntoSegments, } from '@libs/DateInputMaskUtils'; import type {DateSegmentName, DateSegmentRange, DateSegments} from '@libs/DateInputMaskUtils'; import {isNumeric} from '@libs/ValidationUtils'; @@ -24,12 +23,15 @@ import type {TextInputKeyPressEvent, TextInputSelectionChangeEvent} from 'react- import {useRef, useState} from 'react'; const FIRST_SEGMENT_NAME = DATE_SEGMENT_NAMES[0]; +const LAST_SEGMENT_NAME = DATE_SEGMENT_NAMES[DATE_SEGMENT_NAMES.length - 1]; const BACKSPACE_KEY = 'Backspace'; const DELETE_KEY = 'Delete'; -const STEP_KEYS = {ArrowUp: 1, ArrowDown: -1} as const; const MOVE_KEYS = {ArrowLeft: -1, ArrowRight: 1} as const; +/** The characters a locale uses between segments, any of which means the user is finished with the one they are on */ +const SEPARATOR_KEYS = new Set(['-', '/', '.', ' ']); + type UseDateSegmentInputParams = { /** The committed date in the format the app stores, shown whenever the field is not being edited */ value: string; @@ -58,19 +60,10 @@ type UseDateSegmentInputResult = { onBlur: () => void; }; -function isStepKey(key: string): key is keyof typeof STEP_KEYS { - return key in STEP_KEYS; -} - function isMoveKey(key: string): key is keyof typeof MOVE_KEYS { return key in MOVE_KEYS; } -/** Whether the key is a single character that is not a digit, such as a dash the user types to leave a segment */ -function isSeparatorKey(key: string): boolean { - return key.length === 1 && !isNumeric(key); -} - export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: UseDateSegmentInputParams): UseDateSegmentInputResult { // The segments only describe an edit in progress, so they are seeded on focus rather than synced with the value const [segments, setSegments] = useState(EMPTY_SEGMENTS); @@ -80,6 +73,8 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: // Re-rendering with a new value makes the browser report a caret of its own choosing. Honouring that would drag // the active segment around, so the first report after a keystroke is discarded as an echo of our own update. const hasPendingCaretEchoRef = useRef(false); + // Whether the next digit replaces the active segment instead of extending it, set on arriving at a segment + const shouldOverwriteRef = useRef(false); const {value: editingValue, ranges} = getDateDisplay(segments, mask); const activeRange = ranges[activeSegmentName]; @@ -99,6 +94,16 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: setCaretOffset(Math.min(Math.max(offset, 0), getFurthestOffset(name, nextSegments))); }; + /** + * Landing on a segment always rests the caret after whatever it already holds, so an empty one reads from its + * start and a filled one is ready to be typed over. `shouldOverwriteRef` is what makes that typing replace the + * segment rather than extend it. + */ + const enterSegment = (name: DateSegmentName, nextSegments: DateSegments = segments) => { + shouldOverwriteRef.current = true; + moveCaret(name, getFurthestOffset(name, nextSegments), nextSegments); + }; + const commitIfComplete = (newSegments: DateSegments) => { const isoDate = getISODateFromSegments(newSegments); if (!isoDate) { @@ -118,60 +123,52 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: if (isNumeric(key)) { event.preventDefault(); - const result = typeDigitIntoSegment(segments, activeSegmentName, key); + const result = typeDigitIntoSegments(segments, activeSegmentName, key, shouldOverwriteRef.current); + shouldOverwriteRef.current = false; applySegments(result.segments); - // A completed segment hands over to its neighbour. The last one has nowhere to hand over to, so the caret - // rests after the digit just typed and the next digit overwrites the segment. - const nextSegmentName = result.isSegmentComplete ? getAdjacentSegmentName(activeSegmentName, 1) : activeSegmentName; - const nextOffset = nextSegmentName === activeSegmentName ? result.segments[activeSegmentName].length : 0; + if (result.nextSegmentName) { + enterSegment(result.nextSegmentName, result.segments); + return; + } - moveCaret(nextSegmentName, nextOffset, result.segments); + moveCaret(activeSegmentName, result.segments[activeSegmentName].length, result.segments); return; } - if (isStepKey(key)) { + if (isMoveKey(key)) { event.preventDefault(); - const steppedSegments = stepSegment(segments, activeSegmentName, STEP_KEYS[key]); - applySegments(steppedSegments); - moveCaret(activeSegmentName, caretOffset, steppedSegments); + enterSegment(getAdjacentSegmentName(activeSegmentName, MOVE_KEYS[key])); return; } - if (isMoveKey(key)) { + if (key === BACKSPACE_KEY || key === DELETE_KEY) { event.preventDefault(); - const step = MOVE_KEYS[key]; - const nextOffset = caretOffset + step; - - // Stepping past either end of what has been typed carries on into the neighbouring segment. - if (nextOffset >= 0 && nextOffset <= getFurthestOffset(activeSegmentName, segments)) { - moveCaret(activeSegmentName, nextOffset); - return; - } + const trimmedSegments = removeLastDigit(segments, activeSegmentName); - const adjacentSegmentName = getAdjacentSegmentName(activeSegmentName, step); - if (adjacentSegmentName === activeSegmentName) { + // An empty segment has nothing to delete, so the keystroke falls back to leaving it + if (!trimmedSegments) { + enterSegment(getAdjacentSegmentName(activeSegmentName, -1)); return; } - moveCaret(adjacentSegmentName, step > 0 ? 0 : getFurthestOffset(adjacentSegmentName, segments)); - return; - } - - if (key === BACKSPACE_KEY || key === DELETE_KEY) { - event.preventDefault(); - setSegments(clearSegment(segments, activeSegmentName)); - moveCaret(activeSegmentName, 0); + setSegments(trimmedSegments); + shouldOverwriteRef.current = false; + moveCaret(activeSegmentName, trimmedSegments[activeSegmentName].length, trimmedSegments); return; } - if (!isSeparatorKey(key)) { + if (!SEPARATOR_KEYS.has(key)) { + // Nothing else may reach the input, or the browser would write characters the mask cannot represent + if (key.length === 1) { + event.preventDefault(); + } return; } // A separator means the user is finished with this segment even if they only typed one digit into it event.preventDefault(); - moveCaret(getAdjacentSegmentName(activeSegmentName, 1), 0); + enterSegment(getAdjacentSegmentName(activeSegmentName, 1)); }; // Clicking into the text lands the caret anywhere, so snap it onto the digit place that was clicked @@ -185,6 +182,9 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: const clickedSegmentName = getSegmentNameAtPosition(position, ranges); const clickedOffset = position - ranges[clickedSegmentName].start; + // Clicking is aiming at a digit place rather than arriving at a segment, so the next digit extends what is + // there instead of replacing it + shouldOverwriteRef.current = false; moveCaret(clickedSegmentName, clickedOffset); }; @@ -196,12 +196,15 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: } applySegments(pastedSegments); - moveCaret(FIRST_SEGMENT_NAME, 0); + enterSegment(LAST_SEGMENT_NAME, pastedSegments); }; const handleFocus = () => { - setSegments(getSegmentsFromISODate(value)); - moveCaret(FIRST_SEGMENT_NAME, 0); + const seededSegments = getSegmentsFromISODate(value); + + setSegments(seededSegments); + shouldOverwriteRef.current = false; + moveCaret(FIRST_SEGMENT_NAME, getFurthestOffset(FIRST_SEGMENT_NAME, seededSegments), seededSegments); setIsEditing(true); }; @@ -210,6 +213,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: setIsEditing(false); setSegments(EMPTY_SEGMENTS); setCaretOffset(0); + shouldOverwriteRef.current = false; }; if (!isEnabled) { diff --git a/src/libs/DateInputMaskUtils.ts b/src/libs/DateInputMaskUtils.ts index 0e60781d9673..6c765a7a92d2 100644 --- a/src/libs/DateInputMaskUtils.ts +++ b/src/libs/DateInputMaskUtils.ts @@ -7,7 +7,7 @@ import CONST from '@src/CONST'; import type {TupleToUnion} from 'type-fest'; -import {getDaysInMonth, isValid, parse} from 'date-fns'; +import {isValid, parse} from 'date-fns'; const DATE_SEGMENT_NAMES = ['year', 'month', 'day'] as const; @@ -15,17 +15,16 @@ const YEAR_LENGTH = 4; const SEGMENT_LENGTH = 2; const FIRST_MONTH = 1; -const LAST_MONTH = 12; -const FIRST_DAY = 1; -/** The longest month, used as the day limit until the typed month says otherwise */ -const MAX_DAYS_IN_MONTH = 31; - -/** A leading month digit above this cannot start a two digit month, so the segment is zero padded and completed early */ -const MAX_LEADING_MONTH_DIGIT = 1; - -/** A leading day digit above this cannot start a two digit day, so the segment is zero padded and completed early */ -const MAX_LEADING_DAY_DIGIT = 3; +/** + * The highest a segment may read, and the highest its leading digit may be while still allowing a second one. The day + * is capped at the longest month rather than the one that has been typed, so an impossible date such as the 31st of + * February can be entered and is then rejected by validation, rather than being silently corrected mid-keystroke. + */ +const SEGMENT_LIMITS = { + month: {max: 12, maxLeadingDigit: 1}, + day: {max: 31, maxLeadingDigit: 3}, +} as const; /** Mask characters standing in for a digit are letters, so anything else is a separator to copy through verbatim */ const MASK_LETTER_REGEX = /\p{L}/u; @@ -101,24 +100,6 @@ function getSegmentLength(name: DateSegmentName): number { return name === 'year' ? YEAR_LENGTH : SEGMENT_LENGTH; } -function getDaysInTypedMonth(segments: DateSegments): number { - if (segments.month.length !== SEGMENT_LENGTH || segments.year.length !== YEAR_LENGTH) { - return MAX_DAYS_IN_MONTH; - } - - return getDaysInMonth(new Date(Number(segments.year), Number(segments.month) - 1)); -} - -/** Trims a day the newly typed year or month cannot have, so February never keeps a 30th from the month before */ -function withDayInMonth(segments: DateSegments): DateSegments { - if (segments.day.length !== SEGMENT_LENGTH) { - return segments; - } - - const daysInMonth = getDaysInTypedMonth(segments); - return Number(segments.day) > daysInMonth ? {...segments, day: String(daysInMonth).padStart(SEGMENT_LENGTH, '0')} : segments; -} - function getDateDisplay(segments: DateSegments, mask: string): DateDisplay { let value = ''; const ranges: Record = {year: {start: 0, end: 0}, month: {start: 0, end: 0}, day: {start: 0, end: 0}}; @@ -143,6 +124,11 @@ function getSegmentNameAtPosition(position: number, ranges: Record= getSegmentLength(name) ? '' : segments[name]; +function typeDigitIntoOneSegment(name: DateSegmentName, typedSoFar: string, digit: string): SegmentDigitResult { + const current = typedSoFar.length >= getSegmentLength(name) ? '' : typedSoFar; if (name === 'year') { - // No year we support starts with a zero, so swallow the keystroke rather than start a year that cannot resolve - if (!current && digit === '0') { - return {segments, isSegmentComplete: false}; - } + const year = `${current}${digit}`.slice(0, YEAR_LENGTH); - const year = `${current}${digit}`; - return {segments: withDayInMonth({...segments, year}), isSegmentComplete: year.length === YEAR_LENGTH}; + return {value: year, shouldAdvance: year.length === YEAR_LENGTH}; } - const isMonth = name === 'month'; - const maxLeadingDigit = isMonth ? MAX_LEADING_MONTH_DIGIT : MAX_LEADING_DAY_DIGIT; - const lowest = isMonth ? FIRST_MONTH : FIRST_DAY; - const highest = isMonth ? LAST_MONTH : getDaysInTypedMonth(segments); + const limits = SEGMENT_LIMITS[name]; - if (!current) { - if (Number(digit) > maxLeadingDigit) { - const padded = `0${digit}`; - return {segments: withDayInMonth({...segments, [name]: padded}), isSegmentComplete: true}; + if (current.length === 1) { + const combined = `${current}${digit}`; + const combinedNumber = Number(combined); + + if (combinedNumber >= FIRST_MONTH && combinedNumber <= limits.max) { + return {value: combined, shouldAdvance: true}; } - return {segments: {...segments, [name]: digit}, isSegmentComplete: false}; + return {value: current.padStart(SEGMENT_LENGTH, '0'), shouldAdvance: true, carry: digit}; } - const candidate = Number(`${current}${digit}`); - if (candidate >= lowest && candidate <= highest) { - return {segments: withDayInMonth({...segments, [name]: `${current}${digit}`}), isSegmentComplete: true}; + // A leading digit this high cannot start a two digit number, so the segment is zero padded and finished early + if (digit !== '0' && Number(digit) > limits.maxLeadingDigit) { + return {value: digit.padStart(SEGMENT_LENGTH, '0'), shouldAdvance: true}; } - // The two digit number is out of range, so treat the keystroke as the start of a new segment instead - return typeDigitIntoSegment({...segments, [name]: ''}, name, digit); + return {value: digit, shouldAdvance: false}; } -/** Moves a segment up or down by `offset`, wrapping months and days and starting from today when the segment is empty */ -function stepSegment(segments: DateSegments, name: DateSegmentName, offset: number): DateSegments { - const today = new Date(); +/** + * Adds one typed digit, following a carried digit into the following segments for as long as they keep handing one on. + * `nextSegmentName` is where the caret belongs afterwards, and is undefined while the segment is unfinished. + */ +function typeDigitIntoSegments( + segments: DateSegments, + name: DateSegmentName, + digit: string, + shouldOverwrite = false, +): {segments: DateSegments; nextSegmentName: DateSegmentName | undefined} { + const filled = {...segments}; + let currentName = name; + let typedSoFar = shouldOverwrite ? '' : segments[name]; + let currentDigit = digit; + let nextSegmentName: DateSegmentName | undefined; + + for (;;) { + const result = typeDigitIntoOneSegment(currentName, typedSoFar, currentDigit); + filled[currentName] = result.value; + + const followingName = getFollowingSegmentName(currentName); + if (!result.shouldAdvance || !followingName) { + break; + } - if (name === 'year') { - const current = segments.year.length === YEAR_LENGTH ? Number(segments.year) : today.getFullYear(); - const year = Math.min(Math.max(current + offset, CONST.CALENDAR_PICKER.MIN_YEAR), CONST.CALENDAR_PICKER.MAX_YEAR); + nextSegmentName = followingName; + if (!result.carry) { + break; + } - return withDayInMonth({...segments, year: String(year)}); + currentName = followingName; + typedSoFar = ''; + currentDigit = result.carry; } - const isMonth = name === 'month'; - const highest = isMonth ? LAST_MONTH : getDaysInTypedMonth(segments); - const fallback = isMonth ? today.getMonth() + FIRST_MONTH : today.getDate(); - const current = segments[name].length === SEGMENT_LENGTH ? Number(segments[name]) : fallback; - const stepped = ((current - 1 + offset + highest) % highest) + 1; - - return withDayInMonth({...segments, [name]: String(stepped).padStart(SEGMENT_LENGTH, '0')}); + return {segments: filled, nextSegmentName}; } -function clearSegment(segments: DateSegments, name: DateSegmentName): DateSegments { - return {...segments, [name]: ''}; +/** Drops the last digit of a segment. Returns undefined when there was nothing left to drop */ +function removeLastDigit(segments: DateSegments, name: DateSegmentName): DateSegments | undefined { + if (!segments[name]) { + return undefined; + } + + return {...segments, [name]: segments[name].slice(0, -1)}; } function getSegmentsFromISODate(value: string | undefined): DateSegments { @@ -244,7 +259,7 @@ function getSegmentsFromText(text: string): DateSegments { break; } - filled = typeDigitIntoSegment(filled, name, digit).segments; + filled = typeDigitIntoSegments(filled, name, digit).segments; } return filled; @@ -268,7 +283,6 @@ function hasAnySegment(segments: DateSegments): boolean { export { DATE_SEGMENT_NAMES, EMPTY_SEGMENTS, - clearSegment, getAdjacentSegmentName, getDateDisplay, getISODateFromSegments, @@ -276,7 +290,7 @@ export { getSegmentsFromISODate, getSegmentsFromText, hasAnySegment, - stepSegment, - typeDigitIntoSegment, + removeLastDigit, + typeDigitIntoSegments, }; export type {DateSegmentName, DateSegmentRange, DateSegments}; diff --git a/tests/unit/DateInputMaskUtilsTest.ts b/tests/unit/DateInputMaskUtilsTest.ts index 6b113facbe87..71abd5d2aec8 100644 --- a/tests/unit/DateInputMaskUtilsTest.ts +++ b/tests/unit/DateInputMaskUtilsTest.ts @@ -1,5 +1,4 @@ import { - clearSegment, getAdjacentSegmentName, getDateDisplay, getISODateFromSegments, @@ -7,8 +6,8 @@ import { getSegmentsFromISODate, getSegmentsFromText, hasAnySegment, - stepSegment, - typeDigitIntoSegment, + removeLastDigit, + typeDigitIntoSegments, } from '@libs/DateInputMaskUtils'; import type {DateSegments} from '@libs/DateInputMaskUtils'; @@ -20,75 +19,65 @@ function segments(year: string, month: string, day: string): DateSegments { } describe('DateInputMaskUtils', () => { - describe('typeDigitIntoSegment', () => { - it('rejects a leading zero in the year', () => { - expect(typeDigitIntoSegment(EMPTY, 'year', '0')).toEqual({segments: EMPTY, isSegmentComplete: false}); + describe('typeDigitIntoSegments', () => { + it('completes the year on the fourth digit', () => { + expect(typeDigitIntoSegments(segments('202', '', ''), 'year', '6')).toEqual({segments: segments('2026', '', ''), nextSegmentName: 'month'}); + expect(typeDigitIntoSegments(segments('20', '', ''), 'year', '2')).toEqual({segments: segments('202', '', ''), nextSegmentName: undefined}); }); - it('completes the year on the fourth digit', () => { - expect(typeDigitIntoSegment(segments('202', '', ''), 'year', '6')).toEqual({segments: segments('2026', '', ''), isSegmentComplete: true}); - expect(typeDigitIntoSegment(segments('20', '', ''), 'year', '2')).toEqual({segments: segments('202', '', ''), isSegmentComplete: false}); + it('takes a leading zero in the year, which only validation can reject', () => { + expect(typeDigitIntoSegments(EMPTY, 'year', '0')).toEqual({segments: segments('0', '', ''), nextSegmentName: undefined}); }); it('starts the year over when it is already full', () => { - expect(typeDigitIntoSegment(segments('2026', '', ''), 'year', '1')).toEqual({segments: segments('1', '', ''), isSegmentComplete: false}); + expect(typeDigitIntoSegments(segments('2026', '', ''), 'year', '1')).toEqual({segments: segments('1', '', ''), nextSegmentName: undefined}); + }); + + it('replaces the segment rather than extending it when told to overwrite', () => { + expect(typeDigitIntoSegments(segments('202', '', ''), 'year', '9', true)).toEqual({segments: segments('9', '', ''), nextSegmentName: undefined}); }); it('zero pads a month that cannot start a two digit month', () => { - expect(typeDigitIntoSegment(segments('2026', '', ''), 'month', '9')).toEqual({segments: segments('2026', '09', ''), isSegmentComplete: true}); + expect(typeDigitIntoSegments(segments('2026', '', ''), 'month', '9')).toEqual({segments: segments('2026', '09', ''), nextSegmentName: 'day'}); }); it('waits for a second digit when the month could still be a teen month', () => { - expect(typeDigitIntoSegment(segments('2026', '', ''), 'month', '1')).toEqual({segments: segments('2026', '1', ''), isSegmentComplete: false}); - expect(typeDigitIntoSegment(segments('2026', '1', ''), 'month', '2')).toEqual({segments: segments('2026', '12', ''), isSegmentComplete: true}); + expect(typeDigitIntoSegments(segments('2026', '', ''), 'month', '1')).toEqual({segments: segments('2026', '1', ''), nextSegmentName: undefined}); + expect(typeDigitIntoSegments(segments('2026', '1', ''), 'month', '2')).toEqual({segments: segments('2026', '12', ''), nextSegmentName: 'day'}); }); - it('restarts the month when the two digit number is out of range', () => { - expect(typeDigitIntoSegment(segments('2026', '1', ''), 'month', '3')).toEqual({segments: segments('2026', '03', ''), isSegmentComplete: true}); - expect(typeDigitIntoSegment(segments('2026', '0', ''), 'month', '0')).toEqual({segments: segments('2026', '0', ''), isSegmentComplete: false}); + it('carries a digit the month cannot take into the day', () => { + expect(typeDigitIntoSegments(segments('2026', '1', ''), 'month', '3')).toEqual({segments: segments('2026', '01', '3'), nextSegmentName: 'day'}); }); - it('limits the day to the typed month', () => { - expect(typeDigitIntoSegment(segments('2026', '01', '3'), 'day', '1')).toEqual({segments: segments('2026', '01', '31'), isSegmentComplete: true}); - - // February has no 31st, so the keystroke starts the day over instead - expect(typeDigitIntoSegment(segments('2026', '02', '3'), 'day', '1')).toEqual({segments: segments('2026', '02', '1'), isSegmentComplete: false}); + it('waits on a zero rather than padding it, since 0 is not a month', () => { + expect(typeDigitIntoSegments(segments('2026', '0', ''), 'month', '0')).toEqual({segments: segments('2026', '0', ''), nextSegmentName: undefined}); }); - it('allows February 29 in a leap year only', () => { - expect(typeDigitIntoSegment(segments('2024', '02', '2'), 'day', '9')).toEqual({segments: segments('2024', '02', '29'), isSegmentComplete: true}); - expect(typeDigitIntoSegment(segments('2026', '02', '2'), 'day', '9')).toEqual({segments: segments('2026', '02', '09'), isSegmentComplete: true}); - }); + it('caps the day at the longest month rather than the one that was typed', () => { + expect(typeDigitIntoSegments(segments('2026', '01', '3'), 'day', '1')).toEqual({segments: segments('2026', '01', '31'), nextSegmentName: undefined}); - it('allows any day up to 31 before the month is known', () => { - expect(typeDigitIntoSegment(segments('', '', '3'), 'day', '1')).toEqual({segments: segments('', '', '31'), isSegmentComplete: true}); + // February has no 31st, but the field takes it and validation is what rejects the date + expect(typeDigitIntoSegments(segments('2026', '02', '3'), 'day', '1')).toEqual({segments: segments('2026', '02', '31'), nextSegmentName: undefined}); }); - it('trims a day the newly typed month cannot have', () => { - expect(typeDigitIntoSegment(segments('2026', '1', '31'), 'month', '1').segments).toEqual(segments('2026', '11', '30')); + it('keeps a day the newly typed month cannot have, leaving it to validation', () => { + expect(typeDigitIntoSegments(segments('2026', '1', '31'), 'month', '1').segments).toEqual(segments('2026', '11', '31')); }); - it('trims a day the newly typed year cannot have', () => { - expect(typeDigitIntoSegment(segments('202', '02', '29'), 'year', '6').segments).toEqual(segments('2026', '02', '28')); + it('has nothing to carry into past the day, so a rejected pair restarts it', () => { + expect(typeDigitIntoSegments(segments('2026', '09', '3'), 'day', '9')).toEqual({segments: segments('2026', '09', '03'), nextSegmentName: undefined}); }); }); - describe('stepSegment', () => { - it('wraps the month at both ends', () => { - expect(stepSegment(segments('2026', '12', ''), 'month', 1)).toEqual(segments('2026', '01', '')); - expect(stepSegment(segments('2026', '01', ''), 'month', -1)).toEqual(segments('2026', '12', '')); + describe('removeLastDigit', () => { + it('drops one digit at a time', () => { + expect(removeLastDigit(segments('2026', '', ''), 'year')).toEqual(segments('202', '', '')); + expect(removeLastDigit(segments('2', '', ''), 'year')).toEqual(EMPTY); }); - it('wraps the day within the typed month', () => { - expect(stepSegment(segments('2026', '02', '28'), 'day', 1)).toEqual(segments('2026', '02', '01')); - }); - - it('steps the year without wrapping', () => { - expect(stepSegment(segments('2026', '', ''), 'year', 1)).toEqual(segments('2027', '', '')); - }); - - it('trims the day when stepping into a shorter month', () => { - expect(stepSegment(segments('2026', '01', '31'), 'month', 1)).toEqual(segments('2026', '02', '28')); + it('reports that an empty segment had nothing to drop', () => { + expect(removeLastDigit(EMPTY, 'year')).toBeUndefined(); }); }); @@ -196,12 +185,6 @@ describe('DateInputMaskUtils', () => { }); }); - describe('clearSegment', () => { - it('empties only the named segment', () => { - expect(clearSegment(segments('2026', '09', '18'), 'month')).toEqual(segments('2026', '', '18')); - }); - }); - describe('hasAnySegment', () => { it('reports whether anything has been filled in', () => { expect(hasAnySegment(EMPTY)).toBe(false); From 0d46d4fcfda32c9acface73cf29ea98c47d63086 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 17 Sep 2026 22:01:08 +0530 Subject: [PATCH 05/80] fix: do not drag the calendar to the date limit while a year is typed Signed-off-by: krishna2323 --- .../DatePicker/CalendarPicker/index.tsx | 16 ++++++++ src/components/DatePicker/DatePickerModal.tsx | 2 + src/components/DatePicker/index.tsx | 3 +- src/components/DatePicker/types.ts | 3 ++ src/hooks/useDateSegmentInput.ts | 30 ++++++++++++++- src/libs/DateInputMaskUtils.ts | 25 +++++++++++- tests/unit/DateInputMaskUtilsTest.ts | 38 +++++++++++++++++++ 7 files changed, 113 insertions(+), 4 deletions(-) diff --git a/src/components/DatePicker/CalendarPicker/index.tsx b/src/components/DatePicker/CalendarPicker/index.tsx index bd70d3ca7094..73aa26e33b5a 100644 --- a/src/components/DatePicker/CalendarPicker/index.tsx +++ b/src/components/DatePicker/CalendarPicker/index.tsx @@ -52,6 +52,12 @@ type CalendarPickerProps = { /** Whether Month/Year right-docked picker modals should keep backdrop in narrow pane context */ shouldEnableMonthYearBackdropInNarrowPane?: boolean; + + /** + * Moves the calendar to this month without selecting a day, so it can follow a date being typed into the input. + * The calendar still owns its own view, so its arrows and month picker keep working between updates. + */ + viewDate?: Date; }; function getInitialCurrentDateView(value: Date | string, minDate: Date, maxDate: Date) { @@ -85,6 +91,7 @@ function CalendarPicker({ headerContainerStyle, containerStyle, shouldEnableMonthYearBackdropInNarrowPane = false, + viewDate, }: CalendarPickerProps) { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); @@ -94,10 +101,19 @@ function CalendarPicker({ const pressableRef = useRef(null); const monthPressableRef = useRef(null); const [currentDateView, setCurrentDateView] = useState(() => getInitialCurrentDateView(value, minDate, maxDate)); + const [appliedViewDate, setAppliedViewDate] = useState(viewDate); const [isYearPickerVisible, setIsYearPickerVisible] = useState(false); const [isMonthPickerVisible, setIsMonthPickerVisible] = useState(false); const isFirstRender = useRef(true); + // Catching up with the caller here rather than in an effect keeps the view and the month matrix in step within a + // single render, so the calendar never paints the old month first. The date arrives already inside the allowed + // range, and is deliberately not clamped: clamping would show the limit's month instead of the typed one. + if (viewDate && viewDate.getTime() !== appliedViewDate?.getTime()) { + setAppliedViewDate(viewDate); + setCurrentDateView(viewDate); + } + const currentMonthView = currentDateView.getMonth(); const currentYearView = currentDateView.getFullYear(); const calendarDaysMatrix = generateMonthMatrix(currentYearView, currentMonthView); diff --git a/src/components/DatePicker/DatePickerModal.tsx b/src/components/DatePicker/DatePickerModal.tsx index 0b0f76f4de9b..8df9742fc161 100644 --- a/src/components/DatePicker/DatePickerModal.tsx +++ b/src/components/DatePicker/DatePickerModal.tsx @@ -49,6 +49,7 @@ function DatePickerModal({ anchorRef: anchorRefProp, withoutOverlay = false, shouldAllowWithoutOverlayInNarrowPane = false, + viewDate, }: DatePickerProps) { const [selectedDate, setSelectedDate] = useState(value ?? defaultValue ?? undefined); const fallbackAnchorRef = useRef(null); @@ -111,6 +112,7 @@ function DatePickerModal({ onSelected={handleDateSelection} containerStyle={bottomSafeAreaPaddingStyle} shouldEnableMonthYearBackdropInNarrowPane={shouldEnableMonthYearBackdropInNarrowPane} + viewDate={viewDate} /> ); diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index e4c91cbaf511..a0c02384a5fe 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -80,7 +80,7 @@ function DatePicker({ onInputChange?.(newDate); }; - const segmentInput = useDateSegmentInput({value: selectedDate, mask: dateMask, isEnabled: shouldAllowTyping, onCommit: handleTypedDate}); + const segmentInput = useDateSegmentInput({value: selectedDate, mask: dateMask, isEnabled: shouldAllowTyping, minDate, maxDate, onCommit: handleTypedDate}); const {inputCallbackRef: autoFocusCallbackRef, cancelAutoFocus} = useAutoFocusInput(); const autoFocusCallbackRefRef = useRef(autoFocusCallbackRef); @@ -313,6 +313,7 @@ function DatePicker({ anchorRef={anchorRef} withoutOverlay={shouldAllowTyping} shouldAllowWithoutOverlayInNarrowPane={shouldAllowTyping} + viewDate={segmentInput.viewDate} /> ); diff --git a/src/components/DatePicker/types.ts b/src/components/DatePicker/types.ts index 32c214cf8de8..1fb1dec3ac40 100644 --- a/src/components/DatePicker/types.ts +++ b/src/components/DatePicker/types.ts @@ -144,6 +144,9 @@ type DatePickerProps = { * calendar, so the user can keep editing the input while the calendar is open. Falls back to an internal ref. */ anchorRef?: RefObject; + + /** Moves the calendar to this month without selecting a day, so it follows the date being typed into the input */ + viewDate?: Date; } & Omit; export type {DateInputWithPickerProps, DatePickerProps}; diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index 958cbc8106a1..ad5195b1416b 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -11,6 +11,7 @@ import { getSegmentNameAtPosition, getSegmentsFromISODate, getSegmentsFromText, + getViewDateFromSegments, hasAnySegment, removeLastDigit, typeDigitIntoSegments, @@ -42,6 +43,12 @@ type UseDateSegmentInputParams = { /** Whether this platform lets the user type a date at all */ isEnabled: boolean; + /** The oldest date the calendar can show, so it is not moved somewhere with nothing to select */ + minDate: Date; + + /** The newest date the calendar can show */ + maxDate: Date; + /** Called with a stored format date once every segment holds a valid value */ onCommit: (isoDate: string) => void; }; @@ -53,6 +60,9 @@ type UseDateSegmentInputResult = { /** The range covering the segment being edited, which selects it as a whole */ selection: DateSegmentRange | undefined; + /** The month the calendar should show, so it follows the date being typed. Undefined leaves the calendar alone */ + viewDate: Date | undefined; + onKeyPress: (event: TextInputKeyPressEvent) => void; onSelectionChange: (event: TextInputSelectionChangeEvent) => void; onChangeText: (text: string) => void; @@ -64,12 +74,14 @@ function isMoveKey(key: string): key is keyof typeof MOVE_KEYS { return key in MOVE_KEYS; } -export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: UseDateSegmentInputParams): UseDateSegmentInputResult { +export default function useDateSegmentInput({value, mask, isEnabled, minDate, maxDate, onCommit}: UseDateSegmentInputParams): UseDateSegmentInputResult { // The segments only describe an edit in progress, so they are seeded on focus rather than synced with the value const [segments, setSegments] = useState(EMPTY_SEGMENTS); const [activeSegmentName, setActiveSegmentName] = useState(FIRST_SEGMENT_NAME); const [caretOffset, setCaretOffset] = useState(0); const [isEditing, setIsEditing] = useState(false); + // The month the calendar should show, which follows the typed date once the year is complete + const [viewDate, setViewDate] = useState(undefined); // Re-rendering with a new value makes the browser report a caret of its own choosing. Honouring that would drag // the active segment around, so the first report after a keystroke is discarded as an echo of our own update. const hasPendingCaretEchoRef = useRef(false); @@ -113,8 +125,18 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: onCommit(isoDate); }; + /** + * The one place segments are written, so the calendar cannot fall out of step with them. The month already on + * screen is the fallback while the typed month is unfinished, which keeps the calendar where the user left it. + */ const applySegments = (newSegments: DateSegments) => { setSegments(newSegments); + + const nextViewDate = getViewDateFromSegments(newSegments, (viewDate ?? new Date()).getMonth(), minDate, maxDate); + if (nextViewDate) { + setViewDate(nextViewDate); + } + commitIfComplete(newSegments); }; @@ -152,7 +174,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: return; } - setSegments(trimmedSegments); + applySegments(trimmedSegments); shouldOverwriteRef.current = false; moveCaret(activeSegmentName, trimmedSegments[activeSegmentName].length, trimmedSegments); return; @@ -203,6 +225,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: const seededSegments = getSegmentsFromISODate(value); setSegments(seededSegments); + setViewDate(getViewDateFromSegments(seededSegments, new Date().getMonth(), minDate, maxDate)); shouldOverwriteRef.current = false; moveCaret(FIRST_SEGMENT_NAME, getFurthestOffset(FIRST_SEGMENT_NAME, seededSegments), seededSegments); setIsEditing(true); @@ -213,6 +236,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: setIsEditing(false); setSegments(EMPTY_SEGMENTS); setCaretOffset(0); + setViewDate(undefined); shouldOverwriteRef.current = false; }; @@ -220,6 +244,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: return { displayValue: value, selection: undefined, + viewDate: undefined, onKeyPress: () => {}, onSelectionChange: () => {}, onChangeText: () => {}, @@ -231,6 +256,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: return { displayValue: isEditing ? editingValue : value, selection: isEditing ? {start: caretPosition, end: caretPosition} : undefined, + viewDate: isEditing ? viewDate : undefined, onKeyPress: handleKeyPress, onSelectionChange: handleSelectionChange, onChangeText: handleChangeText, diff --git a/src/libs/DateInputMaskUtils.ts b/src/libs/DateInputMaskUtils.ts index 6c765a7a92d2..34cc7d89dec9 100644 --- a/src/libs/DateInputMaskUtils.ts +++ b/src/libs/DateInputMaskUtils.ts @@ -7,7 +7,7 @@ import CONST from '@src/CONST'; import type {TupleToUnion} from 'type-fest'; -import {isValid, parse} from 'date-fns'; +import {endOfMonth, isValid, parse} from 'date-fns'; const DATE_SEGMENT_NAMES = ['year', 'month', 'day'] as const; @@ -221,6 +221,28 @@ function typeDigitIntoSegments( return {segments: filled, nextSegmentName}; } +/** + * Which month the calendar should show for the date being typed, or undefined when there is nowhere useful to go and + * the calendar should stay where it is. A month only counts once both its digits read as a real month, so the calendar + * does not lurch to January while the user is still on the first digit. + * + * A month outside the allowed range is refused rather than clamped. Every year is out of range while it is being + * typed, since 1985 passes through 1, 19 and 198, and clamping those would drag the calendar to the limit and back on + * every keystroke. + */ +function getViewDateFromSegments(segments: DateSegments, fallbackMonthIndex: number, minDate: Date, maxDate: Date): Date | undefined { + if (segments.year.length !== YEAR_LENGTH) { + return undefined; + } + + const monthNumber = Number(segments.month); + const hasMonth = segments.month.length === SEGMENT_LENGTH && monthNumber >= FIRST_MONTH && monthNumber <= SEGMENT_LIMITS.month.max; + const viewDate = new Date(Number(segments.year), hasMonth ? monthNumber - 1 : fallbackMonthIndex, 1); + + // A month holding no selectable day at all is not worth moving to + return endOfMonth(viewDate) < minDate || viewDate > maxDate ? undefined : viewDate; +} + /** Drops the last digit of a segment. Returns undefined when there was nothing left to drop */ function removeLastDigit(segments: DateSegments, name: DateSegmentName): DateSegments | undefined { if (!segments[name]) { @@ -289,6 +311,7 @@ export { getSegmentNameAtPosition, getSegmentsFromISODate, getSegmentsFromText, + getViewDateFromSegments, hasAnySegment, removeLastDigit, typeDigitIntoSegments, diff --git a/tests/unit/DateInputMaskUtilsTest.ts b/tests/unit/DateInputMaskUtilsTest.ts index 71abd5d2aec8..2090447abba2 100644 --- a/tests/unit/DateInputMaskUtilsTest.ts +++ b/tests/unit/DateInputMaskUtilsTest.ts @@ -5,6 +5,7 @@ import { getSegmentNameAtPosition, getSegmentsFromISODate, getSegmentsFromText, + getViewDateFromSegments, hasAnySegment, removeLastDigit, typeDigitIntoSegments, @@ -70,6 +71,43 @@ describe('DateInputMaskUtils', () => { }); }); + describe('getViewDateFromSegments', () => { + /** September, standing in for the month the calendar happens to be showing */ + const FALLBACK_MONTH_INDEX = 8; + const MIN_DATE = new Date(1876, 8, 17); + const MAX_DATE = new Date(2126, 8, 17); + const viewDateFor = (dateSegments: DateSegments) => getViewDateFromSegments(dateSegments, FALLBACK_MONTH_INDEX, MIN_DATE, MAX_DATE); + + it('leaves the calendar alone while the year is unfinished', () => { + expect(viewDateFor(segments('202', '', ''))).toBeUndefined(); + expect(viewDateFor(EMPTY)).toBeUndefined(); + }); + + it('moves the year while keeping the month on screen', () => { + expect(viewDateFor(segments('2030', '', ''))).toEqual(new Date(2030, 8, 1)); + expect(viewDateFor(segments('2030', '1', ''))).toEqual(new Date(2030, 8, 1)); + }); + + it('moves the month once both of its digits read as a real month', () => { + expect(viewDateFor(segments('2030', '02', ''))).toEqual(new Date(2030, 1, 1)); + expect(viewDateFor(segments('2030', '00', ''))).toEqual(new Date(2030, 8, 1)); + }); + + it('ignores the day, which picks a date rather than a month to show', () => { + expect(viewDateFor(segments('2030', '02', '28'))).toEqual(new Date(2030, 1, 1)); + }); + + it('stays put for a year outside the range rather than jumping to the limit', () => { + expect(viewDateFor(segments('1111', '01', '03'))).toBeUndefined(); + expect(viewDateFor(segments('9999', '01', ''))).toBeUndefined(); + }); + + it('moves to the month holding the limit itself, which still has days to select', () => { + expect(viewDateFor(segments('1876', '09', ''))).toEqual(new Date(1876, 8, 1)); + expect(viewDateFor(segments('1876', '08', ''))).toBeUndefined(); + }); + }); + describe('removeLastDigit', () => { it('drops one digit at a time', () => { expect(removeLastDigit(segments('2026', '', ''), 'year')).toEqual(segments('202', '', '')); From b13855fd075fca9f3d3946fffedeaae3bd37f25e Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 17 Sep 2026 22:05:32 +0530 Subject: [PATCH 06/80] fix: fill the month and day from the right so a single digit shows as 02 Signed-off-by: krishna2323 --- src/hooks/useDateSegmentInput.ts | 9 ++++---- src/libs/DateInputMaskUtils.ts | 31 +++++++++++++++++++++++++--- tests/unit/DateInputMaskUtilsTest.ts | 27 ++++++++++++++++++++++-- 3 files changed, 58 insertions(+), 9 deletions(-) diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index ad5195b1416b..824b8515b2b6 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -6,6 +6,7 @@ import { DATE_SEGMENT_NAMES, EMPTY_SEGMENTS, getAdjacentSegmentName, + getCaretOffsetLimit, getDateDisplay, getISODateFromSegments, getSegmentNameAtPosition, @@ -98,7 +99,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma * A caret may rest on any digit place already typed, or just after the last of them, but never out on a mask * letter. An empty segment therefore only ever has its start, which is what stops a click landing on a bare Y. */ - const getFurthestOffset = (name: DateSegmentName, currentSegments: DateSegments) => currentSegments[name].length; + const getFurthestOffset = (name: DateSegmentName, currentSegments: DateSegments) => getCaretOffsetLimit(currentSegments, name); const moveCaret = (name: DateSegmentName, offset: number, nextSegments: DateSegments = segments) => { hasPendingCaretEchoRef.current = true; @@ -154,7 +155,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma return; } - moveCaret(activeSegmentName, result.segments[activeSegmentName].length, result.segments); + moveCaret(activeSegmentName, getFurthestOffset(activeSegmentName, result.segments), result.segments); return; } @@ -176,7 +177,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma applySegments(trimmedSegments); shouldOverwriteRef.current = false; - moveCaret(activeSegmentName, trimmedSegments[activeSegmentName].length, trimmedSegments); + moveCaret(activeSegmentName, getFurthestOffset(activeSegmentName, trimmedSegments), trimmedSegments); return; } @@ -227,7 +228,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma setSegments(seededSegments); setViewDate(getViewDateFromSegments(seededSegments, new Date().getMonth(), minDate, maxDate)); shouldOverwriteRef.current = false; - moveCaret(FIRST_SEGMENT_NAME, getFurthestOffset(FIRST_SEGMENT_NAME, seededSegments), seededSegments); + moveCaret(FIRST_SEGMENT_NAME, getCaretOffsetLimit(seededSegments, FIRST_SEGMENT_NAME), seededSegments); setIsEditing(true); }; diff --git a/src/libs/DateInputMaskUtils.ts b/src/libs/DateInputMaskUtils.ts index 34cc7d89dec9..810b3c0836c1 100644 --- a/src/libs/DateInputMaskUtils.ts +++ b/src/libs/DateInputMaskUtils.ts @@ -100,15 +100,39 @@ function getSegmentLength(name: DateSegmentName): number { return name === 'year' ? YEAR_LENGTH : SEGMENT_LENGTH; } +/** + * Whether a segment reads as a zero padded number, which the two digit ones do and the year does not. Their digits + * therefore sit at the end of the segment, so a single typed digit shows as 02 rather than 2 followed by a mask letter. + */ +function isZeroPaddedSegment(name: DateSegmentName): name is keyof typeof SEGMENT_LIMITS { + return name in SEGMENT_LIMITS; +} + +/** + * How far into a segment the caret may sit. A zero padded segment displays its digits at the end, so one typed digit + * puts the caret at the end of the segment rather than one place into it. + */ +function getCaretOffsetLimit(segments: DateSegments, name: DateSegmentName): number { + const typedLength = segments[name].length; + + if (!typedLength) { + return 0; + } + + return isZeroPaddedSegment(name) ? getSegmentLength(name) : Math.min(typedLength, getSegmentLength(name)); +} + function getDateDisplay(segments: DateSegments, mask: string): DateDisplay { let value = ''; const ranges: Record = {year: {start: 0, end: 0}, month: {start: 0, end: 0}, day: {start: 0, end: 0}}; for (const part of getDateMaskParts(mask)) { - // Typed digits replace the mask letters one at a time, so a half typed year reads as 2YYY rather than 2. This - // keeps every segment the width of its mask, which is what lets a caret position mean the same thing twice. + // Typed digits take the place of the mask letters, so a half typed year reads as 2YYY rather than 2. A zero + // padded segment fills from the right instead, so a day part way through reads as 02 and becomes 23 on the + // next digit. Either way the segment keeps the width of its mask, which is what lets a caret position mean + // the same thing twice. const digits = segments[part.name].slice(0, part.placeholder.length); - const text = `${digits}${part.placeholder.slice(digits.length)}`; + const text = digits && isZeroPaddedSegment(part.name) ? digits.padStart(part.placeholder.length, '0') : `${digits}${part.placeholder.slice(digits.length)}`; ranges[part.name] = {start: value.length, end: value.length + text.length}; value += `${text}${part.separator}`; @@ -306,6 +330,7 @@ export { DATE_SEGMENT_NAMES, EMPTY_SEGMENTS, getAdjacentSegmentName, + getCaretOffsetLimit, getDateDisplay, getISODateFromSegments, getSegmentNameAtPosition, diff --git a/tests/unit/DateInputMaskUtilsTest.ts b/tests/unit/DateInputMaskUtilsTest.ts index 2090447abba2..67554c9de5b4 100644 --- a/tests/unit/DateInputMaskUtilsTest.ts +++ b/tests/unit/DateInputMaskUtilsTest.ts @@ -1,5 +1,6 @@ import { getAdjacentSegmentName, + getCaretOffsetLimit, getDateDisplay, getISODateFromSegments, getSegmentNameAtPosition, @@ -130,10 +131,14 @@ describe('DateInputMaskUtils', () => { expect(getDateDisplay(segments('2026', '09', '18'), MASK).value).toBe('2026-09-18'); }); - it('keeps the mask letters of the digit places a half typed segment has not reached', () => { + it('keeps the mask letters of the digit places a half typed year has not reached', () => { expect(getDateDisplay(segments('2', '', ''), MASK).value).toBe('2YYY-MM-DD'); expect(getDateDisplay(segments('20', '', ''), MASK).value).toBe('20YY-MM-DD'); - expect(getDateDisplay(segments('2026', '1', ''), MASK).value).toBe('2026-1M-DD'); + }); + + it('zero pads a half typed month or day, which fill from the right', () => { + expect(getDateDisplay(segments('2026', '1', ''), MASK).value).toBe('2026-01-DD'); + expect(getDateDisplay(segments('2026', '09', '2'), MASK).value).toBe('2026-09-02'); }); it('reports where each segment sits in the text', () => { @@ -156,6 +161,24 @@ describe('DateInputMaskUtils', () => { }); }); + describe('getCaretOffsetLimit', () => { + it('keeps the caret at the start of an empty segment', () => { + expect(getCaretOffsetLimit(EMPTY, 'year')).toBe(0); + expect(getCaretOffsetLimit(EMPTY, 'day')).toBe(0); + }); + + it('follows the typed digits through the year, which fills from the left', () => { + expect(getCaretOffsetLimit(segments('2', '', ''), 'year')).toBe(1); + expect(getCaretOffsetLimit(segments('202', '', ''), 'year')).toBe(3); + expect(getCaretOffsetLimit(segments('2026', '', ''), 'year')).toBe(4); + }); + + it('rests at the end of a zero padded segment, since 02 shows the 2 last', () => { + expect(getCaretOffsetLimit(segments('2026', '1', ''), 'month')).toBe(2); + expect(getCaretOffsetLimit(segments('2026', '12', ''), 'month')).toBe(2); + }); + }); + describe('getSegmentNameAtPosition', () => { const {ranges} = getDateDisplay(segments('2026', '09', '18'), MASK); From b9896d653f0dbe41acee419abd577682d269d736 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 17 Sep 2026 22:11:02 +0530 Subject: [PATCH 07/80] fix: send a click on the field's empty space to the first unfilled segment Signed-off-by: krishna2323 --- src/hooks/useDateSegmentInput.ts | 13 +++++++++++-- src/libs/DateInputMaskUtils.ts | 6 ++++-- tests/unit/DateInputMaskUtilsTest.ts | 21 +++++++++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index 824b8515b2b6..026691ec9ce9 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -8,6 +8,7 @@ import { getAdjacentSegmentName, getCaretOffsetLimit, getDateDisplay, + getFirstUnfilledSegmentName, getISODateFromSegments, getSegmentNameAtPosition, getSegmentsFromISODate, @@ -202,7 +203,10 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma } const position = event.nativeEvent.selection.start; - const clickedSegmentName = getSegmentNameAtPosition(position, ranges); + + // Landing past the end of the text means the empty space in the field was clicked rather than a segment, so + // the first segment still to be filled in takes it, which is the year on an untouched field. + const clickedSegmentName = position >= editingValue.length ? (getFirstUnfilledSegmentName(segments) ?? LAST_SEGMENT_NAME) : getSegmentNameAtPosition(position, ranges); const clickedOffset = position - ranges[clickedSegmentName].start; // Clicking is aiming at a digit place rather than arriving at a segment, so the next digit extends what is @@ -224,11 +228,16 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma const handleFocus = () => { const seededSegments = getSegmentsFromISODate(value); + const firstSegmentName = getFirstUnfilledSegmentName(seededSegments) ?? FIRST_SEGMENT_NAME; setSegments(seededSegments); setViewDate(getViewDateFromSegments(seededSegments, new Date().getMonth(), minDate, maxDate)); shouldOverwriteRef.current = false; - moveCaret(FIRST_SEGMENT_NAME, getCaretOffsetLimit(seededSegments, FIRST_SEGMENT_NAME), seededSegments); + setActiveSegmentName(firstSegmentName); + setCaretOffset(getCaretOffsetLimit(seededSegments, firstSegmentName)); + + // Deliberately not arming the caret echo guard. The click that brought focus here reports its own position + // next, and that report is what moves the caret off the end of the text and onto a segment. setIsEditing(true); }; diff --git a/src/libs/DateInputMaskUtils.ts b/src/libs/DateInputMaskUtils.ts index 810b3c0836c1..78e68395617e 100644 --- a/src/libs/DateInputMaskUtils.ts +++ b/src/libs/DateInputMaskUtils.ts @@ -290,7 +290,8 @@ function getSegmentsFromISODate(value: string | undefined): DateSegments { }; } -function getNextUnfilledSegmentName(segments: DateSegments): DateSegmentName | undefined { +/** The first segment still short of its digits, which is where a click on the field rather than on a segment lands */ +function getFirstUnfilledSegmentName(segments: DateSegments): DateSegmentName | undefined { return DATE_SEGMENT_NAMES.find((name) => segments[name].length < getSegmentLength(name)); } @@ -300,7 +301,7 @@ function getSegmentsFromText(text: string): DateSegments { let filled = EMPTY_SEGMENTS; for (const digit of digits) { - const name = getNextUnfilledSegmentName(filled); + const name = getFirstUnfilledSegmentName(filled); if (!name) { break; } @@ -332,6 +333,7 @@ export { getAdjacentSegmentName, getCaretOffsetLimit, getDateDisplay, + getFirstUnfilledSegmentName, getISODateFromSegments, getSegmentNameAtPosition, getSegmentsFromISODate, diff --git a/tests/unit/DateInputMaskUtilsTest.ts b/tests/unit/DateInputMaskUtilsTest.ts index 67554c9de5b4..8c22ddc94840 100644 --- a/tests/unit/DateInputMaskUtilsTest.ts +++ b/tests/unit/DateInputMaskUtilsTest.ts @@ -2,6 +2,7 @@ import { getAdjacentSegmentName, getCaretOffsetLimit, getDateDisplay, + getFirstUnfilledSegmentName, getISODateFromSegments, getSegmentNameAtPosition, getSegmentsFromISODate, @@ -206,6 +207,26 @@ describe('DateInputMaskUtils', () => { }); }); + describe('getFirstUnfilledSegmentName', () => { + it('points at the year on an untouched date', () => { + expect(getFirstUnfilledSegmentName(EMPTY)).toBe('year'); + }); + + it('skips the segments already holding all of their digits', () => { + expect(getFirstUnfilledSegmentName(segments('2026', '', ''))).toBe('month'); + expect(getFirstUnfilledSegmentName(segments('2026', '09', ''))).toBe('day'); + }); + + it('counts a half typed segment as unfilled', () => { + expect(getFirstUnfilledSegmentName(segments('202', '09', '18'))).toBe('year'); + expect(getFirstUnfilledSegmentName(segments('2026', '1', ''))).toBe('month'); + }); + + it('reports nothing once the whole date is filled in', () => { + expect(getFirstUnfilledSegmentName(segments('2026', '09', '18'))).toBeUndefined(); + }); + }); + describe('getSegmentsFromText', () => { it('fills the segments from a pasted date', () => { expect(getSegmentsFromText('2026-09-18')).toEqual(segments('2026', '09', '18')); From 905605a30f51e77fd7c92eb6b7097bca63860896 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 17 Sep 2026 22:23:20 +0530 Subject: [PATCH 08/80] fix: hide the calendar icon once a digit is typed Signed-off-by: krishna2323 --- src/components/DatePicker/index.tsx | 2 +- src/hooks/useDateSegmentInput.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index a0c02384a5fe..c98c5842f4f3 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -268,7 +268,7 @@ function DatePicker({ ref={combinedTextInputRef} inputID={inputID} forceActiveLabel - icon={selectedDate || shouldHideCalendarIcon ? null : icons.Calendar} + icon={selectedDate || segmentInput.hasTypedDigits || shouldHideCalendarIcon ? null : icons.Calendar} iconContainerStyle={styles.pr0} label={label} accessibilityLabel={label} diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index 026691ec9ce9..e55b6f190993 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -65,6 +65,9 @@ type UseDateSegmentInputResult = { /** The month the calendar should show, so it follows the date being typed. Undefined leaves the calendar alone */ viewDate: Date | undefined; + /** Whether any digit has been typed, so the field is showing more than an untouched mask */ + hasTypedDigits: boolean; + onKeyPress: (event: TextInputKeyPressEvent) => void; onSelectionChange: (event: TextInputSelectionChangeEvent) => void; onChangeText: (text: string) => void; @@ -255,6 +258,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma displayValue: value, selection: undefined, viewDate: undefined, + hasTypedDigits: false, onKeyPress: () => {}, onSelectionChange: () => {}, onChangeText: () => {}, @@ -267,6 +271,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma displayValue: isEditing ? editingValue : value, selection: isEditing ? {start: caretPosition, end: caretPosition} : undefined, viewDate: isEditing ? viewDate : undefined, + hasTypedDigits: isEditing && hasAnySegment(segments), onKeyPress: handleKeyPress, onSelectionChange: handleSelectionChange, onChangeText: handleChangeText, From 7d4f9c69c69e28f14cc214b67f808a9ea6cec0a5 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 17 Sep 2026 22:34:28 +0530 Subject: [PATCH 09/80] fix: keep the typing calendar attached to its field while the page scrolls Signed-off-by: krishna2323 --- src/components/DatePicker/DatePickerModal.tsx | 2 ++ src/components/DatePicker/index.tsx | 21 +++++++++++++++++++ src/components/Popover/types.ts | 7 +++++++ src/components/PopoverProvider/index.tsx | 5 +++++ src/components/PopoverProvider/types.ts | 7 +++++++ .../PopoverWithoutOverlay/index.tsx | 2 ++ src/components/PopoverWithoutOverlay/types.ts | 6 ++++++ 7 files changed, 50 insertions(+) diff --git a/src/components/DatePicker/DatePickerModal.tsx b/src/components/DatePicker/DatePickerModal.tsx index 8df9742fc161..f372251242a6 100644 --- a/src/components/DatePicker/DatePickerModal.tsx +++ b/src/components/DatePicker/DatePickerModal.tsx @@ -49,6 +49,7 @@ function DatePickerModal({ anchorRef: anchorRefProp, withoutOverlay = false, shouldAllowWithoutOverlayInNarrowPane = false, + shouldCloseOnWheel = true, viewDate, }: DatePickerProps) { const [selectedDate, setSelectedDate] = useState(value ?? defaultValue ?? undefined); @@ -104,6 +105,7 @@ function DatePickerModal({ enableEdgeToEdgeBottomSafeAreaPadding withoutOverlay={withoutOverlay} shouldAllowWithoutOverlayInNarrowPane={shouldAllowWithoutOverlayInNarrowPane} + shouldCloseOnWheel={shouldCloseOnWheel} > debouncedCalculatePopoverPosition.cancel(); }, [calculatePopoverPosition, windowWidth]); + useEffect(() => { + // Only the typing calendar stays open while the page scrolls. Every other one is dismissed by PopoverProvider, + // so it never needs to follow the field. + if (!shouldAllowTyping || !isModalVisible) { + return; + } + + // The calendar is positioned from coordinates measured when it opened, so scrolling the form would leave it + // behind. Following the field keeps it attached rather than interrupting an edit in progress. + // Wrapped so the scroll event is not passed through as the measurement callback + const handleScroll = throttle(() => calculatePopoverPosition(), CONST.TIMING.MIN_SMOOTH_SCROLL_EVENT_THROTTLE); + document.addEventListener('scroll', handleScroll, true); + + return () => { + document.removeEventListener('scroll', handleScroll, true); + handleScroll.cancel(); + }; + }, [shouldAllowTyping, isModalVisible, calculatePopoverPosition]); + // Combined ref: updates textInputRef (needed for blur() in showDatePickerModal) and connects // autoFocusCallbackRef only when autoFocus=true so useAutoFocusInput's useFocusEffect cleanup // can cancel any pending focus task when the screen starts closing. @@ -313,6 +333,7 @@ function DatePicker({ anchorRef={anchorRef} withoutOverlay={shouldAllowTyping} shouldAllowWithoutOverlayInNarrowPane={shouldAllowTyping} + shouldCloseOnWheel={!shouldAllowTyping} viewDate={segmentInput.viewDate} /> diff --git a/src/components/Popover/types.ts b/src/components/Popover/types.ts index cee068ef7b1d..44fab5a4499c 100644 --- a/src/components/Popover/types.ts +++ b/src/components/Popover/types.ts @@ -26,6 +26,13 @@ type PopoverProps = BaseModalProps & */ shouldAllowWithoutOverlayInNarrowPane?: boolean; + /** + * Whether scrolling the page dismisses the popover. Only reaches an overlay-free popover, since the others + * cover the page and cannot be scrolled past in the first place. + * @default true + */ + shouldCloseOnWheel?: boolean; + popoverDimensions?: Dimensions; withoutOverlayRef?: RefObject; diff --git a/src/components/PopoverProvider/index.tsx b/src/components/PopoverProvider/index.tsx index d62d3defb351..fa56c2f1ec8a 100644 --- a/src/components/PopoverProvider/index.tsx +++ b/src/components/PopoverProvider/index.tsx @@ -125,6 +125,11 @@ function PopoverContextProvider(props: PopoverContextProps) { return; } + // A popover that keeps itself attached to its anchor has no reason to be dismissed by scrolling + if (activePopoverRef.current?.shouldCloseOnWheel === false) { + return; + } + closePopover(); }; document.addEventListener('wheel', listener, true); diff --git a/src/components/PopoverProvider/types.ts b/src/components/PopoverProvider/types.ts index 6a49012bbc0c..b477dbe4c94d 100644 --- a/src/components/PopoverProvider/types.ts +++ b/src/components/PopoverProvider/types.ts @@ -13,6 +13,13 @@ type AnchorRef = { close: (anchorRef?: RefObject) => void; anchorRef: RefObject; extraAnchorRefs?: Array>; + + /** + * Whether scrolling the page dismisses this popover. It is the right default for one that cannot follow its + * anchor, but false suits a popover that tracks the anchor itself, or one whose anchor is still being edited. + * @default true + */ + shouldCloseOnWheel?: boolean; }; export type {PopoverContextProps, AnchorRef}; diff --git a/src/components/PopoverWithoutOverlay/index.tsx b/src/components/PopoverWithoutOverlay/index.tsx index ea03d6b019de..ff867264b06b 100644 --- a/src/components/PopoverWithoutOverlay/index.tsx +++ b/src/components/PopoverWithoutOverlay/index.tsx @@ -32,6 +32,7 @@ function PopoverWithoutOverlay({ children, shouldDisplayBelowModals = false, enableEdgeToEdgeBottomSafeAreaPadding, + shouldCloseOnWheel = true, }: PopoverWithoutOverlayProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -62,6 +63,7 @@ function PopoverWithoutOverlay({ ref: withoutOverlayRef, close: onClose ?? NOOP, anchorRef, + shouldCloseOnWheel, }); removeOnClose = setCloseModal(onClose ?? NOOP); } else { diff --git a/src/components/PopoverWithoutOverlay/types.ts b/src/components/PopoverWithoutOverlay/types.ts index a0ded56a404b..5a74a20f2b23 100644 --- a/src/components/PopoverWithoutOverlay/types.ts +++ b/src/components/PopoverWithoutOverlay/types.ts @@ -25,6 +25,12 @@ type PopoverWithoutOverlayProps = ChildrenProps & /** Whether we should display the popover below other modals (e.g. SidePanel, RHP) */ shouldDisplayBelowModals?: boolean; + + /** + * Whether scrolling the page dismisses the popover, which it should unless the popover tracks its anchor. + * @default true + */ + shouldCloseOnWheel?: boolean; }; export default PopoverWithoutOverlayProps; From fc6df11ab212ff527de91383828b7ad22d797c22 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 17 Sep 2026 22:45:03 +0530 Subject: [PATCH 10/80] fix: keep the calendar open while the year or month picker is used Signed-off-by: krishna2323 --- .../CalendarPicker/MonthPickerModal.tsx | 18 +++++++++++++++++- .../CalendarPicker/YearPickerModal.tsx | 18 +++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx b/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx index bdb1545a68f7..ac1e89d0db42 100644 --- a/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx +++ b/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx @@ -1,5 +1,6 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Modal from '@components/Modal'; +import {usePopoverActions} from '@components/PopoverProvider'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; @@ -11,7 +12,9 @@ import DateUtils from '@libs/DateUtils'; import CONST from '@src/CONST'; -import React, {useEffect, useMemo, useState} from 'react'; +import type {View} from 'react-native'; + +import React, {useEffect, useMemo, useRef, useState} from 'react'; import {Keyboard} from 'react-native'; type MonthPickerModalProps = { @@ -32,9 +35,21 @@ type MonthPickerModalProps = { function MonthPickerModal({isVisible, currentMonth = new Date().getMonth(), onMonthChange, onClose, shouldEnableBackdropInNarrowPane = false}: MonthPickerModalProps) { const styles = useThemeStyles(); const {translate, dateFnsLocale} = useLocalize(); + const {setActivePopoverExtraAnchorRef} = usePopoverActions(); + const contentRef = useRef(null); const [searchText, setSearchText] = useState(''); const monthNames = DateUtils.getMonthNames(dateFnsLocale); + useEffect(() => { + if (!isVisible) { + return; + } + + // This modal is rendered above the calendar rather than inside it, so a press on a month counts as a press + // outside the calendar popover and would dismiss it. Registering the content keeps the calendar open. + setActivePopoverExtraAnchorRef(contentRef); + }, [isVisible, setActivePopoverExtraAnchorRef]); + const allMonths = useMemo(() => DateUtils.getFilteredMonthItems(monthNames, currentMonth), [monthNames, currentMonth]); const {data, headerMessage} = useMemo(() => { @@ -75,6 +90,7 @@ function MonthPickerModal({isVisible, currentMonth = new Date().getMonth(), onMo enableEdgeToEdgeBottomSafeAreaPadding > (null); const [searchText, setSearchText] = useState(''); + + useEffect(() => { + if (!isVisible) { + return; + } + + // This modal is rendered above the calendar rather than inside it, so a press on a year counts as a press + // outside the calendar popover and would dismiss it. Registering the content keeps the calendar open. + setActivePopoverExtraAnchorRef(contentRef); + }, [isVisible, setActivePopoverExtraAnchorRef]); const {data, headerMessage} = useMemo(() => { const yearsList = searchText === '' ? years : years.filter((year) => year.text?.includes(searchText)); return { @@ -71,6 +86,7 @@ function YearPickerModal({isVisible, years, currentYear = new Date().getFullYear enableEdgeToEdgeBottomSafeAreaPadding > Date: Thu, 17 Sep 2026 23:02:34 +0530 Subject: [PATCH 11/80] refactor: remove the workarounds that accumulated around the typed date field Signed-off-by: krishna2323 --- src/components/DatePicker/index.tsx | 53 +++++++------------ src/components/Popover/index.tsx | 7 +-- src/components/Popover/types.ts | 6 +-- src/hooks/useDateSegmentInput.ts | 14 ++--- .../useRemeasureOnScroll/index.native.ts | 9 ++++ src/hooks/useRemeasureOnScroll/index.ts | 29 ++++++++++ src/hooks/useRemeasureOnScroll/types.ts | 12 +++++ 7 files changed, 79 insertions(+), 51 deletions(-) create mode 100644 src/hooks/useRemeasureOnScroll/index.native.ts create mode 100644 src/hooks/useRemeasureOnScroll/index.ts create mode 100644 src/hooks/useRemeasureOnScroll/types.ts diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index 262cfaeba3e7..71c9c9870921 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -6,6 +6,7 @@ import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useDateSegmentInput from '@hooks/useDateSegmentInput'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useRemeasureOnScroll from '@hooks/useRemeasureOnScroll'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -21,7 +22,6 @@ import type {TextInputKeyPressEvent} from 'react-native'; import {format, setYear} from 'date-fns'; import debounce from 'lodash/debounce'; -import throttle from 'lodash/throttle'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {Keyboard, View} from 'react-native'; @@ -81,6 +81,8 @@ function DatePicker({ onInputChange?.(newDate); }; + // The hook is the single gate on typing. When the platform does not allow it, the handlers it returns are no-ops + // and the value passes straight through, so the call sites below do not have to check again. const segmentInput = useDateSegmentInput({value: selectedDate, mask: dateMask, isEnabled: shouldAllowTyping, minDate, maxDate, onCommit: handleTypedDate}); const {inputCallbackRef: autoFocusCallbackRef, cancelAutoFocus} = useAutoFocusInput(); @@ -127,6 +129,12 @@ function DatePicker({ ); const showDatePickerModal = useCallback(() => { + // Re-opening would remeasure and re-announce a calendar that is already showing. Both a press and a focus can + // ask for it, and while typing both arrive for a single click. + if (isModalVisible) { + return; + } + cancelAutoFocus(); // While typing is allowed the calendar sits under an input the user is still writing in, so the caret has to @@ -159,7 +167,7 @@ function DatePicker({ }; openPicker(); - }, [shouldDeferShowUntilPositioned, shouldDismissKeyboardBeforeShow, shouldAllowTyping, calculatePopoverPosition, cancelAutoFocus, setPickerVisibility]); + }, [isModalVisible, shouldDeferShowUntilPositioned, shouldDismissKeyboardBeforeShow, shouldAllowTyping, calculatePopoverPosition, cancelAutoFocus, setPickerVisibility]); const closeDatePicker = useCallback(() => { openIntentRef.current = false; @@ -181,24 +189,14 @@ function DatePicker({ event.preventDefault(); } - // Clicking from one segment to another must not remeasure and reopen a calendar that is already showing. - if (shouldAllowTyping && isModalVisible) { - return; - } - showDatePickerModal(); }, - [shouldAllowTyping, isModalVisible, showDatePickerModal], + [shouldAllowTyping, showDatePickerModal], ); // Reaching the field by keyboard never fires a press, so focus is what opens the calendar once typing is allowed. const handleFocus = () => { segmentInput.onFocus(); - - if (isModalVisible) { - return; - } - showDatePickerModal(); }; @@ -221,6 +219,10 @@ function DatePicker({ requestAnimationFrame(() => onInputChange?.(newDate)); }; + // Only the typing calendar stays open while the page scrolls. Every other one is dismissed instead, so it never + // has to follow the field, and following it keeps an edit in progress from being interrupted. + useRemeasureOnScroll({isActive: shouldAllowTyping && isModalVisible, remeasure: calculatePopoverPosition}); + const handleClear = () => { onTouched?.(); onInputChange?.(''); @@ -236,25 +238,6 @@ function DatePicker({ return () => debouncedCalculatePopoverPosition.cancel(); }, [calculatePopoverPosition, windowWidth]); - useEffect(() => { - // Only the typing calendar stays open while the page scrolls. Every other one is dismissed by PopoverProvider, - // so it never needs to follow the field. - if (!shouldAllowTyping || !isModalVisible) { - return; - } - - // The calendar is positioned from coordinates measured when it opened, so scrolling the form would leave it - // behind. Following the field keeps it attached rather than interrupting an edit in progress. - // Wrapped so the scroll event is not passed through as the measurement callback - const handleScroll = throttle(() => calculatePopoverPosition(), CONST.TIMING.MIN_SMOOTH_SCROLL_EVENT_THROTTLE); - document.addEventListener('scroll', handleScroll, true); - - return () => { - document.removeEventListener('scroll', handleScroll, true); - handleScroll.cancel(); - }; - }, [shouldAllowTyping, isModalVisible, calculatePopoverPosition]); - // Combined ref: updates textInputRef (needed for blur() in showDatePickerModal) and connects // autoFocusCallbackRef only when autoFocus=true so useAutoFocusInput's useFocusEffect cleanup // can cancel any pending focus task when the screen starts closing. @@ -304,9 +287,9 @@ function DatePicker({ onPress={shouldDismissKeyboardBeforeShow || shouldAllowTyping ? handlePress : () => showDatePickerModal()} onSubmitEditing={shouldAllowTyping ? undefined : () => showDatePickerModal()} onFocus={shouldAllowTyping ? handleFocus : undefined} - onBlur={shouldAllowTyping ? segmentInput.onBlur : undefined} - onChangeText={shouldAllowTyping ? segmentInput.onChangeText : undefined} - onSelectionChange={shouldAllowTyping ? segmentInput.onSelectionChange : undefined} + onBlur={segmentInput.onBlur} + onChangeText={segmentInput.onChangeText} + onSelectionChange={segmentInput.onSelectionChange} onKeyPress={shouldAllowTyping ? segmentInput.onKeyPress : handleInputKeyPress} textInputContainerStyles={isModalVisible ? styles.borderColorFocus : {}} shouldHideClearButton={shouldHideClearButton} diff --git a/src/components/Popover/index.tsx b/src/components/Popover/index.tsx index 3add94a1ab28..819cfd61b21b 100644 --- a/src/components/Popover/index.tsx +++ b/src/components/Popover/index.tsx @@ -134,9 +134,10 @@ function Popover(props: PopoverProps) { ); } - // A narrow pane normally forces the full modal. An opting-in caller keeps the overlay-free popover there, but a - // small screen never does, since there is no room for a popover beside the control that opened it. - const canSkipOverlay = !shouldUseNarrowLayout || (shouldAllowWithoutOverlayInNarrowPane && !isSmallScreenWidth); + // An overlay-free popover normally needs room beside its anchor, which a narrow pane is assumed not to have. An + // opting-in caller is judged on screen size alone, so it keeps the popover in the RHP on a wide screen and still + // gets the full modal on a small one. + const canSkipOverlay = shouldAllowWithoutOverlayInNarrowPane ? !isSmallScreenWidth : !shouldUseNarrowLayout; if (withoutOverlay && canSkipOverlay) { return createPortal( diff --git a/src/components/Popover/types.ts b/src/components/Popover/types.ts index 44fab5a4499c..54fdbc10b51e 100644 --- a/src/components/Popover/types.ts +++ b/src/components/Popover/types.ts @@ -20,9 +20,9 @@ type PopoverProps = BaseModalProps & withoutOverlay?: boolean; /** - * Honours `withoutOverlay` inside a narrow pane such as the RHP, where it is otherwise ignored in favour of a - * full modal. Only for a popover that must leave the control that opened it usable, such as the calendar the - * user keeps typing a date into. Small screens still get the full modal either way. + * Judges `withoutOverlay` on screen size rather than on whether this is a narrow pane, so the popover survives + * inside the RHP on a wide screen. Only for one that must leave the control that opened it usable, such as the + * calendar the user keeps typing a date into. A small screen still gets the full modal. */ shouldAllowWithoutOverlayInNarrowPane?: boolean; diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index e55b6f190993..2dfa70d5f9ce 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -99,16 +99,10 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma // highlight it as a block, which is not what the design asks for. const caretPosition = activeRange.start + caretOffset; - /** - * A caret may rest on any digit place already typed, or just after the last of them, but never out on a mask - * letter. An empty segment therefore only ever has its start, which is what stops a click landing on a bare Y. - */ - const getFurthestOffset = (name: DateSegmentName, currentSegments: DateSegments) => getCaretOffsetLimit(currentSegments, name); - const moveCaret = (name: DateSegmentName, offset: number, nextSegments: DateSegments = segments) => { hasPendingCaretEchoRef.current = true; setActiveSegmentName(name); - setCaretOffset(Math.min(Math.max(offset, 0), getFurthestOffset(name, nextSegments))); + setCaretOffset(Math.min(Math.max(offset, 0), getCaretOffsetLimit(nextSegments, name))); }; /** @@ -118,7 +112,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma */ const enterSegment = (name: DateSegmentName, nextSegments: DateSegments = segments) => { shouldOverwriteRef.current = true; - moveCaret(name, getFurthestOffset(name, nextSegments), nextSegments); + moveCaret(name, getCaretOffsetLimit(nextSegments, name), nextSegments); }; const commitIfComplete = (newSegments: DateSegments) => { @@ -159,7 +153,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma return; } - moveCaret(activeSegmentName, getFurthestOffset(activeSegmentName, result.segments), result.segments); + moveCaret(activeSegmentName, getCaretOffsetLimit(result.segments, activeSegmentName), result.segments); return; } @@ -181,7 +175,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma applySegments(trimmedSegments); shouldOverwriteRef.current = false; - moveCaret(activeSegmentName, getFurthestOffset(activeSegmentName, trimmedSegments), trimmedSegments); + moveCaret(activeSegmentName, getCaretOffsetLimit(trimmedSegments, activeSegmentName), trimmedSegments); return; } diff --git a/src/hooks/useRemeasureOnScroll/index.native.ts b/src/hooks/useRemeasureOnScroll/index.native.ts new file mode 100644 index 000000000000..8f615444b3cb --- /dev/null +++ b/src/hooks/useRemeasureOnScroll/index.native.ts @@ -0,0 +1,9 @@ +/** + * Native has no document to listen to, and nothing anchored to a scrolling page to keep in place, so there is nothing + * to remeasure. + */ +import type UseRemeasureOnScroll from './types'; + +const useRemeasureOnScroll: UseRemeasureOnScroll = () => {}; + +export default useRemeasureOnScroll; diff --git a/src/hooks/useRemeasureOnScroll/index.ts b/src/hooks/useRemeasureOnScroll/index.ts new file mode 100644 index 000000000000..348bc122faf2 --- /dev/null +++ b/src/hooks/useRemeasureOnScroll/index.ts @@ -0,0 +1,29 @@ +/** + * Keeps something positioned from a one-off measurement attached to what it was measured against, by remeasuring as + * the page scrolls. A capture phase listener is used because scroll events do not bubble, so this covers the inner + * scroll containers a screen is built from as well as the document itself. + */ +import CONST from '@src/CONST'; + +import throttle from 'lodash/throttle'; +import {useEffect} from 'react'; + +import type UseRemeasureOnScroll from './types'; + +const useRemeasureOnScroll: UseRemeasureOnScroll = ({isActive, remeasure}) => { + useEffect(() => { + if (!isActive) { + return; + } + + const handleScroll = throttle(remeasure, CONST.TIMING.MIN_SMOOTH_SCROLL_EVENT_THROTTLE); + document.addEventListener('scroll', handleScroll, true); + + return () => { + document.removeEventListener('scroll', handleScroll, true); + handleScroll.cancel(); + }; + }, [isActive, remeasure]); +}; + +export default useRemeasureOnScroll; diff --git a/src/hooks/useRemeasureOnScroll/types.ts b/src/hooks/useRemeasureOnScroll/types.ts new file mode 100644 index 000000000000..cee7bc213303 --- /dev/null +++ b/src/hooks/useRemeasureOnScroll/types.ts @@ -0,0 +1,12 @@ +type UseRemeasureOnScrollParams = { + /** Whether anything is currently anchored and so worth keeping in place */ + isActive: boolean; + + /** Takes a fresh measurement of the anchor */ + remeasure: () => void; +}; + +type UseRemeasureOnScroll = (params: UseRemeasureOnScrollParams) => void; + +export default UseRemeasureOnScroll; +export type {UseRemeasureOnScrollParams}; From cf9bbd1dbc1b6fa298e6fa78da40265600941bce Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 17 Sep 2026 23:24:43 +0530 Subject: [PATCH 12/80] feat: update the date field when a month or year is picked Signed-off-by: krishna2323 --- .../DatePicker/CalendarPicker/index.tsx | 48 +++++++--- src/components/DatePicker/DatePickerModal.tsx | 17 +++- src/components/DatePicker/index.tsx | 7 +- src/components/DatePicker/types.ts | 9 ++ src/hooks/useDateSegmentInput.ts | 39 ++++++-- tests/unit/CalendarPickerTest.tsx | 92 +++++++++++++++++++ 6 files changed, 186 insertions(+), 26 deletions(-) diff --git a/src/components/DatePicker/CalendarPicker/index.tsx b/src/components/DatePicker/CalendarPicker/index.tsx index 73aa26e33b5a..ce6f945b6d05 100644 --- a/src/components/DatePicker/CalendarPicker/index.tsx +++ b/src/components/DatePicker/CalendarPicker/index.tsx @@ -12,7 +12,7 @@ import CONST from '@src/CONST'; import type {StyleProp, ViewStyle} from 'react-native'; -import {addMonths, addYears, format, isSameDay, parseISO, setDate, setMonth, setYear, startOfDay, subMonths, subYears} from 'date-fns'; +import {addMonths, addYears, format, getDaysInMonth, isSameDay, parseISO, setDate, setMonth, setYear, startOfDay, subMonths, subYears} from 'date-fns'; import {Str} from 'expensify-common'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; @@ -44,6 +44,12 @@ type CalendarPickerProps = { onSelected?: (selectedDate: string) => void; + /** + * Called when a month or year is picked from its own picker, with the date that pick lands on. The selection is + * not finished, so the calendar stays open for the day press. Leave it out to have those picks only move the view. + */ + onMonthOrYearSelected?: (selectedDate: string) => void; + /** Optional style override for the header container */ headerContainerStyle?: StyleProp; @@ -58,6 +64,9 @@ type CalendarPickerProps = { * The calendar still owns its own view, so its arrows and month picker keep working between updates. */ viewDate?: Date; + + /** Changes every time `viewDate` is asserted, including when it repeats the month the calendar already shows */ + viewDateVersion?: number; }; function getInitialCurrentDateView(value: Date | string, minDate: Date, maxDate: Date) { @@ -81,17 +90,25 @@ function getInitialCurrentDateView(value: Date | string, minDate: Date, maxDate: return initialCurrentDateView; } +// Keeps the day inside the target month, since setYear alone turns February 29 into March 1 on a non leap year +function setYearKeepingDay(date: Date, year: number) { + const firstOfTargetMonth = setYear(setDate(date, 1), year); + return setDate(firstOfTargetMonth, Math.min(date.getDate(), getDaysInMonth(firstOfTargetMonth))); +} + function CalendarPicker({ value = new Date(), minDate = setYear(new Date(), CONST.CALENDAR_PICKER.MIN_YEAR), maxDate = setYear(new Date(), CONST.CALENDAR_PICKER.MAX_YEAR), onSelected, + onMonthOrYearSelected, DayComponent = Day, selectableDates, headerContainerStyle, containerStyle, shouldEnableMonthYearBackdropInNarrowPane = false, viewDate, + viewDateVersion = 0, }: CalendarPickerProps) { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); @@ -101,7 +118,7 @@ function CalendarPicker({ const pressableRef = useRef(null); const monthPressableRef = useRef(null); const [currentDateView, setCurrentDateView] = useState(() => getInitialCurrentDateView(value, minDate, maxDate)); - const [appliedViewDate, setAppliedViewDate] = useState(viewDate); + const [appliedViewDateVersion, setAppliedViewDateVersion] = useState(viewDateVersion); const [isYearPickerVisible, setIsYearPickerVisible] = useState(false); const [isMonthPickerVisible, setIsMonthPickerVisible] = useState(false); const isFirstRender = useRef(true); @@ -109,8 +126,8 @@ function CalendarPicker({ // Catching up with the caller here rather than in an effect keeps the view and the month matrix in step within a // single render, so the calendar never paints the old month first. The date arrives already inside the allowed // range, and is deliberately not clamped: clamping would show the limit's month instead of the typed one. - if (viewDate && viewDate.getTime() !== appliedViewDate?.getTime()) { - setAppliedViewDate(viewDate); + if (viewDate && viewDateVersion !== appliedViewDateVersion) { + setAppliedViewDateVersion(viewDateVersion); setCurrentDateView(viewDate); } @@ -133,21 +150,22 @@ function CalendarPicker({ ); const onYearSelected = (year: number) => { - setCurrentDateView((prev) => { - const newCurrentDateView = setYear(new Date(prev), year); - setYears((prevYears) => - prevYears.map((item) => ({ - ...item, - isSelected: item.value === newCurrentDateView.getFullYear(), - })), - ); - return newCurrentDateView; - }); + const newCurrentDateView = setYearKeepingDay(new Date(currentDateView), year); + setCurrentDateView(newCurrentDateView); + setYears((prevYears) => + prevYears.map((item) => ({ + ...item, + isSelected: item.value === newCurrentDateView.getFullYear(), + })), + ); + onMonthOrYearSelected?.(format(newCurrentDateView, CONST.DATE.FNS_FORMAT_STRING)); requestAnimationFrame(() => setIsYearPickerVisible(false)); }; const onMonthSelected = (month: number) => { - setCurrentDateView((prev) => setMonth(new Date(prev), month)); + const newCurrentDateView = setMonth(new Date(currentDateView), month); + setCurrentDateView(newCurrentDateView); + onMonthOrYearSelected?.(format(newCurrentDateView, CONST.DATE.FNS_FORMAT_STRING)); requestAnimationFrame(() => setIsMonthPickerVisible(false)); }; diff --git a/src/components/DatePicker/DatePickerModal.tsx b/src/components/DatePicker/DatePickerModal.tsx index f372251242a6..160a31826898 100644 --- a/src/components/DatePicker/DatePickerModal.tsx +++ b/src/components/DatePicker/DatePickerModal.tsx @@ -42,6 +42,7 @@ function DatePickerModal({ anchorPosition, anchorAlignment = DEFAULT_ANCHOR_ORIGIN, onSelected, + onMonthOrYearSelected, shouldCloseWhenBrowserNavigationChanged = false, shouldPositionFromTop = false, forwardedFSClass, @@ -51,6 +52,7 @@ function DatePickerModal({ shouldAllowWithoutOverlayInNarrowPane = false, shouldCloseOnWheel = true, viewDate, + viewDateVersion, }: DatePickerProps) { const [selectedDate, setSelectedDate] = useState(value ?? defaultValue ?? undefined); const fallbackAnchorRef = useRef(null); @@ -72,13 +74,22 @@ function DatePickerModal({ } }, [formID, inputID, selectedDate, shouldSaveDraft, value]); - const handleDateSelection = (newValue: string) => { - onSelected?.(newValue); + const applySelection = (newValue: string) => { onTouched?.(); onInputChange?.(newValue); setSelectedDate(newValue); }; + const handleDateSelection = (newValue: string) => { + onSelected?.(newValue); + applySelection(newValue); + }; + + const handleMonthOrYearSelection = (newValue: string) => { + onMonthOrYearSelected?.(newValue); + applySelection(newValue); + }; + // Pass the CalendarPicker's existing bottom padding (pb4) as the base style so the safe-area padding is // added on top of it instead of overriding it (containerStyle is applied after pb4 in CalendarPicker). // The modal doesn't render an offline indicator inside it, so disable the offline-indicator padding — @@ -112,9 +123,11 @@ function DatePickerModal({ maxDate={maxDate} value={selectedDate} onSelected={handleDateSelection} + onMonthOrYearSelected={onMonthOrYearSelected ? handleMonthOrYearSelection : undefined} containerStyle={bottomSafeAreaPaddingStyle} shouldEnableMonthYearBackdropInNarrowPane={shouldEnableMonthYearBackdropInNarrowPane} viewDate={viewDate} + viewDateVersion={viewDateVersion} /> ); diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index 71c9c9870921..692c5be609df 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -75,7 +75,8 @@ function DatePicker({ const shouldAllowTyping = isTypedDateInputSupported(); const dateMask = translate('common.dateFormat'); - const handleTypedDate = (newDate: string) => { + // Updates the field without ending the selection, so the calendar stays open for whatever the user does next + const commitDate = (newDate: string) => { setSelectedDate(newDate); onTouched?.(); onInputChange?.(newDate); @@ -83,7 +84,7 @@ function DatePicker({ // The hook is the single gate on typing. When the platform does not allow it, the handlers it returns are no-ops // and the value passes straight through, so the call sites below do not have to check again. - const segmentInput = useDateSegmentInput({value: selectedDate, mask: dateMask, isEnabled: shouldAllowTyping, minDate, maxDate, onCommit: handleTypedDate}); + const segmentInput = useDateSegmentInput({value: selectedDate, mask: dateMask, isEnabled: shouldAllowTyping, minDate, maxDate, onCommit: commitDate}); const {inputCallbackRef: autoFocusCallbackRef, cancelAutoFocus} = useAutoFocusInput(); const autoFocusCallbackRefRef = useRef(autoFocusCallbackRef); @@ -318,6 +319,8 @@ function DatePicker({ shouldAllowWithoutOverlayInNarrowPane={shouldAllowTyping} shouldCloseOnWheel={!shouldAllowTyping} viewDate={segmentInput.viewDate} + viewDateVersion={segmentInput.viewDateVersion} + onMonthOrYearSelected={commitDate} /> ); diff --git a/src/components/DatePicker/types.ts b/src/components/DatePicker/types.ts index 1fb1dec3ac40..22cad09fe57b 100644 --- a/src/components/DatePicker/types.ts +++ b/src/components/DatePicker/types.ts @@ -147,6 +147,15 @@ type DatePickerProps = { /** Moves the calendar to this month without selecting a day, so it follows the date being typed into the input */ viewDate?: Date; + + /** Changes every time `viewDate` is asserted, including when it repeats the month the calendar already shows */ + viewDateVersion?: number; + + /** + * Called when a month or year is picked from its own picker, with the date that pick lands on. The calendar stays + * open, so the user can still press a day. Leave it out to have those picks only move the view. + */ + onMonthOrYearSelected?: (selectedDate: string) => void; } & Omit; export type {DateInputWithPickerProps, DatePickerProps}; diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index 2dfa70d5f9ce..35748e7fc722 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -65,6 +65,9 @@ type UseDateSegmentInputResult = { /** The month the calendar should show, so it follows the date being typed. Undefined leaves the calendar alone */ viewDate: Date | undefined; + /** Counts how many times the input has asked the calendar to follow it, so asking twice for the same month counts twice */ + viewDateVersion: number; + /** Whether any digit has been typed, so the field is showing more than an untouched mask */ hasTypedDigits: boolean; @@ -87,11 +90,23 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma const [isEditing, setIsEditing] = useState(false); // The month the calendar should show, which follows the typed date once the year is complete const [viewDate, setViewDate] = useState(undefined); + const [viewDateVersion, setViewDateVersion] = useState(0); // Re-rendering with a new value makes the browser report a caret of its own choosing. Honouring that would drag // the active segment around, so the first report after a keystroke is discarded as an echo of our own update. const hasPendingCaretEchoRef = useRef(false); // Whether the next digit replaces the active segment instead of extending it, set on arriving at a segment const shouldOverwriteRef = useRef(false); + const [appliedValue, setAppliedValue] = useState(value); + + // A date set from outside, by the calendar or by a restored draft, has to reach the segments as well. Without this + // an edit in progress would keep showing the date it started from, since the segments are what the field renders. + if (value !== appliedValue) { + setAppliedValue(value); + + if (isEditing) { + setSegments(getSegmentsFromISODate(value)); + } + } const {value: editingValue, ranges} = getDateDisplay(segments, mask); const activeRange = ranges[activeSegmentName]; @@ -124,18 +139,26 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma onCommit(isoDate); }; + /** + * Asks the calendar to follow the input. The count is what carries the request, since the user can have moved the + * calendar elsewhere with its arrows or its month picker and then typed the month it was already showing. + */ + const assertViewDate = (nextViewDate: Date | undefined) => { + if (!nextViewDate) { + return; + } + + setViewDate(nextViewDate); + setViewDateVersion((version) => version + 1); + }; + /** * The one place segments are written, so the calendar cannot fall out of step with them. The month already on * screen is the fallback while the typed month is unfinished, which keeps the calendar where the user left it. */ const applySegments = (newSegments: DateSegments) => { setSegments(newSegments); - - const nextViewDate = getViewDateFromSegments(newSegments, (viewDate ?? new Date()).getMonth(), minDate, maxDate); - if (nextViewDate) { - setViewDate(nextViewDate); - } - + assertViewDate(getViewDateFromSegments(newSegments, (viewDate ?? new Date()).getMonth(), minDate, maxDate)); commitIfComplete(newSegments); }; @@ -228,7 +251,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma const firstSegmentName = getFirstUnfilledSegmentName(seededSegments) ?? FIRST_SEGMENT_NAME; setSegments(seededSegments); - setViewDate(getViewDateFromSegments(seededSegments, new Date().getMonth(), minDate, maxDate)); + assertViewDate(getViewDateFromSegments(seededSegments, new Date().getMonth(), minDate, maxDate)); shouldOverwriteRef.current = false; setActiveSegmentName(firstSegmentName); setCaretOffset(getCaretOffsetLimit(seededSegments, firstSegmentName)); @@ -252,6 +275,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma displayValue: value, selection: undefined, viewDate: undefined, + viewDateVersion: 0, hasTypedDigits: false, onKeyPress: () => {}, onSelectionChange: () => {}, @@ -265,6 +289,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma displayValue: isEditing ? editingValue : value, selection: isEditing ? {start: caretPosition, end: caretPosition} : undefined, viewDate: isEditing ? viewDate : undefined, + viewDateVersion, hasTypedDigits: isEditing && hasAnySegment(segments), onKeyPress: handleKeyPress, onSelectionChange: handleSelectionChange, diff --git a/tests/unit/CalendarPickerTest.tsx b/tests/unit/CalendarPickerTest.tsx index da71b09cf8d4..8d3e04388d71 100644 --- a/tests/unit/CalendarPickerTest.tsx +++ b/tests/unit/CalendarPickerTest.tsx @@ -624,6 +624,98 @@ describe('CalendarPicker', () => { expect(within(screen.getByTestId('currentYearText')).getByText('2027')).toBeTruthy(); }); + test('selecting a year reports the date it lands on without ending the selection', () => { + const onSelectedMock = jest.fn(); + const onMonthOrYearSelectedMock = jest.fn(); + render( + , + ); + + fireEvent.press(screen.getByTestId('currentYearButton')); + fireEvent.press(within(screen.getByTestId('YearPickerModal')).getByTestId('year-option-2027')); + + expect(onMonthOrYearSelectedMock).toHaveBeenCalledWith('2027-06-15'); + expect(onSelectedMock).not.toHaveBeenCalled(); + }); + + test('selecting a month reports the date it lands on without ending the selection', () => { + const onSelectedMock = jest.fn(); + const onMonthOrYearSelectedMock = jest.fn(); + render( + , + ); + + fireEvent.press(screen.getByTestId('currentMonthButton')); + fireEvent.press(within(screen.getByTestId('MonthPickerModal')).getByTestId('month-option-8')); + + expect(onMonthOrYearSelectedMock).toHaveBeenCalledWith('2025-09-15'); + expect(onSelectedMock).not.toHaveBeenCalled(); + }); + + test('selecting a month keeps the day inside it', () => { + const onMonthOrYearSelectedMock = jest.fn(); + render( + , + ); + + fireEvent.press(screen.getByTestId('currentMonthButton')); + fireEvent.press(within(screen.getByTestId('MonthPickerModal')).getByTestId('month-option-1')); + + expect(onMonthOrYearSelectedMock).toHaveBeenCalledWith('2025-02-28'); + }); + + test('selecting a non leap year from February 29 keeps the day inside February', () => { + const onMonthOrYearSelectedMock = jest.fn(); + render( + , + ); + + fireEvent.press(screen.getByTestId('currentYearButton')); + fireEvent.press(within(screen.getByTestId('YearPickerModal')).getByTestId('year-option-2025')); + + expect(onMonthOrYearSelectedMock).toHaveBeenCalledWith('2025-02-28'); + }); + + test('a month or year selection only moves the view when the caller does not ask to be told', () => { + const onSelectedMock = jest.fn(); + render( + , + ); + + fireEvent.press(screen.getByTestId('currentYearButton')); + fireEvent.press(within(screen.getByTestId('YearPickerModal')).getByTestId('year-option-2027')); + + expect(within(screen.getByTestId('currentYearText')).getByText('2027')).toBeTruthy(); + expect(onSelectedMock).not.toHaveBeenCalled(); + }); + test('closing the year picker via onClose hides the modal', () => { render(); From 6848a7448947291dc360c98f04e0438f12a1f9da Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 17 Sep 2026 23:35:52 +0530 Subject: [PATCH 13/80] fix: stop a stray caret report moving the date field's active segment Signed-off-by: krishna2323 --- src/hooks/useDateSegmentInput.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index 35748e7fc722..eea4a2d705d3 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -224,9 +224,15 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma const position = event.nativeEvent.selection.start; + // The caret is already here, so this is the browser reporting our own position back rather than the user + // aiming somewhere. Acting on it is what let a stray report drag the active segment around. + if (position === caretPosition) { + return; + } + // Landing past the end of the text means the empty space in the field was clicked rather than a segment, so - // the first segment still to be filled in takes it, which is the year on an untouched field. - const clickedSegmentName = position >= editingValue.length ? (getFirstUnfilledSegmentName(segments) ?? LAST_SEGMENT_NAME) : getSegmentNameAtPosition(position, ranges); + // the first segment still to be filled in takes it, and a date with no gaps in it starts again from the year. + const clickedSegmentName = position >= editingValue.length ? (getFirstUnfilledSegmentName(segments) ?? FIRST_SEGMENT_NAME) : getSegmentNameAtPosition(position, ranges); const clickedOffset = position - ranges[clickedSegmentName].start; // Clicking is aiming at a digit place rather than arriving at a segment, so the next digit extends what is From c5345240a456eca1970d40b9e7e60efc1df99179 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 18 Sep 2026 23:06:54 +0530 Subject: [PATCH 14/80] refactor: rebuild the date field as one input per segment Signed-off-by: krishna2323 --- src/components/DatePicker/index.tsx | 30 ++- src/components/DateSegmentsInput.tsx | 117 +++++++++++ .../BaseTextInput/implementations.ts | 2 + .../TextInput/BaseTextInput/types.ts | 22 +- src/hooks/useDateSegmentInput.ts | 198 ++++++++---------- src/languages/de.ts | 5 + src/languages/el.ts | 5 + src/languages/en.ts | 5 + src/languages/es.ts | 5 + src/languages/fr.ts | 5 + src/languages/it.ts | 5 + src/languages/ja.ts | 5 + src/languages/nl.ts | 5 + src/languages/pl.ts | 5 + src/languages/pt-BR.ts | 5 + src/languages/zh-hans.ts | 5 + src/libs/DateInputMaskUtils.ts | 61 +----- tests/unit/DateInputMaskUtilsTest.ts | 90 +++----- 18 files changed, 343 insertions(+), 232 deletions(-) create mode 100644 src/components/DateSegmentsInput.tsx diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index 692c5be609df..355df62ef44f 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -84,7 +84,7 @@ function DatePicker({ // The hook is the single gate on typing. When the platform does not allow it, the handlers it returns are no-ops // and the value passes straight through, so the call sites below do not have to check again. - const segmentInput = useDateSegmentInput({value: selectedDate, mask: dateMask, isEnabled: shouldAllowTyping, minDate, maxDate, onCommit: commitDate}); + const segmentInput = useDateSegmentInput({value: selectedDate, isEnabled: shouldAllowTyping, minDate, maxDate, onCommit: commitDate}); const {inputCallbackRef: autoFocusCallbackRef, cancelAutoFocus} = useAutoFocusInput(); const autoFocusCallbackRefRef = useRef(autoFocusCallbackRef); @@ -185,19 +185,24 @@ function DatePicker({ const handlePress = useCallback>( (event) => { - // Preventing the press would also stop the caret from landing in the segment the user clicked. + // Preventing the press would also stop focus from landing in the segment the user clicked. if (!shouldAllowTyping && 'preventDefault' in event) { event.preventDefault(); } + // A press that landed on the field but not on a segment leaves nothing focused, so send it somewhere useful + if (shouldAllowTyping && !segmentInput.isEditing) { + segmentInput.requestInitialFocus(); + } + showDatePickerModal(); }, - [shouldAllowTyping, showDatePickerModal], + [shouldAllowTyping, showDatePickerModal, segmentInput], ); // Reaching the field by keyboard never fires a press, so focus is what opens the calendar once typing is allowed. + // The segments report their own focus to the hook, so there is nothing to seed here. const handleFocus = () => { - segmentInput.onFocus(); showDatePickerModal(); }; @@ -278,8 +283,18 @@ function DatePicker({ accessibilityLabel={label} role={CONST.ROLE.COMBOBOX} accessibilityState={{expanded: isModalVisible}} + type={shouldAllowTyping ? 'dateSegments' : 'default'} + dateSegmentsConfig={ + shouldAllowTyping + ? { + mask: dateMask, + getSegmentProps: segmentInput.getSegmentProps, + focusRequest: segmentInput.focusRequest, + onFieldBlur: segmentInput.onFieldBlur, + } + : undefined + } value={segmentInput.displayValue} - selection={segmentInput.selection} placeholder={placeholder ?? dateMask} errorText={errorText} inputStyle={shouldAllowTyping ? undefined : styles.pointerEventsNone} @@ -288,10 +303,7 @@ function DatePicker({ onPress={shouldDismissKeyboardBeforeShow || shouldAllowTyping ? handlePress : () => showDatePickerModal()} onSubmitEditing={shouldAllowTyping ? undefined : () => showDatePickerModal()} onFocus={shouldAllowTyping ? handleFocus : undefined} - onBlur={segmentInput.onBlur} - onChangeText={segmentInput.onChangeText} - onSelectionChange={segmentInput.onSelectionChange} - onKeyPress={shouldAllowTyping ? segmentInput.onKeyPress : handleInputKeyPress} + onKeyPress={shouldAllowTyping ? undefined : handleInputKeyPress} textInputContainerStyles={isModalVisible ? styles.borderColorFocus : {}} shouldHideClearButton={shouldHideClearButton} onClearInput={handleClear} diff --git a/src/components/DateSegmentsInput.tsx b/src/components/DateSegmentsInput.tsx new file mode 100644 index 000000000000..6c87251070eb --- /dev/null +++ b/src/components/DateSegmentsInput.tsx @@ -0,0 +1,117 @@ +/** + * The inner part of the date field, rendering one input per date segment instead of one input for the whole date. It is + * registered as a `BaseTextInput` input type, so it sits inside the same label, border and error chrome as any other + * text field. + * + * One input per segment is what makes the caret a non issue: moving between segments is an ordinary focus change, so + * nothing here has to reason about where a caret sits inside a longer string. + */ +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {getDateMaskParts} from '@libs/DateInputMaskUtils'; +import type {DateSegmentName} from '@libs/DateInputMaskUtils'; + +import CONST from '@src/CONST'; + +import React, {useEffect, useRef} from 'react'; +import {View} from 'react-native'; + +import type {AnimatedTextInputRef} from './RNTextInput'; +import type {BaseTextInputProps} from './TextInput/BaseTextInput/types'; + +import RNTextInput from './RNTextInput'; +import Text from './Text'; + +/** The mask letters are wider than the digits that replace them, so a segment is sized to hold its placeholder */ +const SEGMENT_PADDING = CONST.CHARACTER_WIDTH / 2; + +/** + * The style the field hands down stretches its input to fill the row, which would spread three of them evenly across + * it. Each segment is sized to its own digits instead, so the date reads as one run of text. + */ +const SIZED_TO_CONTENT = {flexGrow: 0, flexShrink: 0, flexBasis: 'auto'} as const; + +function DateSegmentsInput({dateSegmentsConfig, style, placeholderTextColor, disabled, onPressOut, forwardedFSClass, ref}: BaseTextInputProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const segmentRefs = useRef>>({}); + // Focus moves between segments as one blur followed by one focus, so leaving the field can only be told apart from + // moving within it once the next focus has had its chance to arrive. + const blurTimeoutRef = useRef(undefined); + const appliedFocusVersionRef = useRef(0); + + const focusRequest = dateSegmentsConfig?.focusRequest; + + useEffect(() => { + if (!focusRequest || focusRequest.version === appliedFocusVersionRef.current) { + return; + } + + appliedFocusVersionRef.current = focusRequest.version; + segmentRefs.current[focusRequest.name]?.focus(); + }, [focusRequest]); + + useEffect(() => () => clearTimeout(blurTimeoutRef.current), []); + + if (!dateSegmentsConfig) { + return null; + } + + const {mask, getSegmentProps, onFieldBlur} = dateSegmentsConfig; + + const handleSegmentBlur = () => { + clearTimeout(blurTimeoutRef.current); + blurTimeoutRef.current = setTimeout(onFieldBlur, 0); + }; + + return ( + + {getDateMaskParts(mask).map((part) => { + const segmentProps = getSegmentProps(part.name); + + return ( + + { + segmentRefs.current[part.name] = element; + + // The field's own ref has to lead somewhere, and the year is where typing starts + if (part.name !== 'year') { + return; + } + if (typeof ref === 'function') { + ref(element); + } else if (ref && 'current' in ref) { + // eslint-disable-next-line no-param-reassign + ref.current = element; + } + }} + style={[style, styles.p0, SIZED_TO_CONTENT, {width: part.placeholder.length * CONST.CHARACTER_WIDTH + SEGMENT_PADDING}]} + value={segmentProps.value} + placeholder={part.placeholder} + placeholderTextColor={placeholderTextColor} + onKeyPress={segmentProps.onKeyPress} + onChangeText={segmentProps.onChangeText} + onFocus={() => { + clearTimeout(blurTimeoutRef.current); + segmentProps.onFocus(); + }} + onBlur={handleSegmentBlur} + onPressOut={onPressOut} + accessibilityLabel={translate(`common.dateSegments.${part.name}`)} + inputMode="numeric" + disabled={disabled} + forwardedFSClass={forwardedFSClass} + /> + {!!part.separator && {part.separator}} + + ); + })} + + ); +} + +DateSegmentsInput.displayName = 'DateSegmentsInput'; + +export default DateSegmentsInput; diff --git a/src/components/TextInput/BaseTextInput/implementations.ts b/src/components/TextInput/BaseTextInput/implementations.ts index ccb8096335b9..d90dc7229dae 100644 --- a/src/components/TextInput/BaseTextInput/implementations.ts +++ b/src/components/TextInput/BaseTextInput/implementations.ts @@ -1,3 +1,4 @@ +import DateSegmentsInput from '@components/DateSegmentsInput'; import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; import RNMaskedTextInput from '@components/RNMaskedTextInput'; import RNTextInput from '@components/RNTextInput'; @@ -10,6 +11,7 @@ const InputComponentMap = new Map([ ['default', RNTextInput as InputComponentType], ['mask', RNMaskedTextInput as InputComponentType], ['markdown', RNMarkdownTextInput as InputComponentType], + ['dateSegments', DateSegmentsInput as InputComponentType], ]); export default InputComponentMap; diff --git a/src/components/TextInput/BaseTextInput/types.ts b/src/components/TextInput/BaseTextInput/types.ts index 07cf3fbce236..5a618c50b0e2 100644 --- a/src/components/TextInput/BaseTextInput/types.ts +++ b/src/components/TextInput/BaseTextInput/types.ts @@ -1,5 +1,7 @@ import type {AnimatedTextInputRef} from '@components/RNTextInput'; +import type {UseDateSegmentInputResult} from '@hooks/useDateSegmentInput'; + import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -11,7 +13,18 @@ import type {ForwardedRef} from 'react'; import type {GestureResponderEvent, StyleProp, TextInputProps, TextStyle, ViewStyle} from 'react-native'; import type {MaskedTextInputOwnProps} from 'react-native-advanced-input-mask/lib/typescript/src/types'; -type InputType = 'markdown' | 'mask' | 'default'; +type InputType = 'markdown' | 'mask' | 'default' | 'dateSegments'; + +/** Everything the segmented date input needs, which reaches it through `BaseTextInput` rather than directly */ +type DateSegmentsConfig = { + /** The localized mask, such as YYYY-MM-DD, which decides the segment order, their widths and the separators */ + mask: string; + + getSegmentProps: UseDateSegmentInputResult['getSegmentProps']; + focusRequest: UseDateSegmentInputResult['focusRequest']; + onFieldBlur: UseDateSegmentInputResult['onFieldBlur']; +}; + type CustomBaseTextInputProps = ForwardedFSClassProps & WithSentryLabel & { label?: string; @@ -151,9 +164,12 @@ type CustomBaseTextInputProps = ForwardedFSClassProps & clearButtonIconSize?: number; contentWidth?: number; - /** The type (internal implementation) of input. Can be one of: `default`, `mask`, `markdown` */ + /** The type (internal implementation) of input. Can be one of: `default`, `mask`, `markdown`, `dateSegments` */ type?: InputType; + /** Required by the `dateSegments` type, and ignored by every other one */ + dateSegmentsConfig?: DateSegmentsConfig; + mask?: MaskedTextInputOwnProps['mask']; customNotations?: MaskedTextInputOwnProps['customNotations']; @@ -196,4 +212,4 @@ type BaseTextInputRef = HTMLFormElement | AnimatedTextInputRef; type BaseTextInputProps = CustomBaseTextInputProps & TextInputProps; -export type {BaseTextInputProps, BaseTextInputRef, InputType}; +export type {BaseTextInputProps, BaseTextInputRef, DateSegmentsConfig, InputType}; diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index eea4a2d705d3..1cdeff76fec0 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -1,16 +1,17 @@ /** - * Drives the guided date input, where the year, month and day are selected and edited one segment at a time. Every - * keystroke is handled here and the raw keystroke is prevented, so the field can only ever hold a date shaped value. + * Drives the guided date input, where the year, month and day are edited in one input each. Every keystroke is handled + * here and the raw keystroke is prevented, so a segment can only ever hold digits it is allowed to hold. + * + * Focus belongs to the browser. Each segment reports its own focus, and this hook only ever asks for a move when a + * keystroke calls for one, which is why nothing here has to arbitrate against a caret it does not control. */ import { DATE_SEGMENT_NAMES, EMPTY_SEGMENTS, getAdjacentSegmentName, - getCaretOffsetLimit, - getDateDisplay, getFirstUnfilledSegmentName, getISODateFromSegments, - getSegmentNameAtPosition, + getSegmentDisplay, getSegmentsFromISODate, getSegmentsFromText, getViewDateFromSegments, @@ -18,12 +19,12 @@ import { removeLastDigit, typeDigitIntoSegments, } from '@libs/DateInputMaskUtils'; -import type {DateSegmentName, DateSegmentRange, DateSegments} from '@libs/DateInputMaskUtils'; +import type {DateSegmentName, DateSegments} from '@libs/DateInputMaskUtils'; import {isNumeric} from '@libs/ValidationUtils'; -import type {TextInputKeyPressEvent, TextInputSelectionChangeEvent} from 'react-native'; +import type {TextInputKeyPressEvent} from 'react-native'; -import {useRef, useState} from 'react'; +import {useState} from 'react'; const FIRST_SEGMENT_NAME = DATE_SEGMENT_NAMES[0]; const LAST_SEGMENT_NAME = DATE_SEGMENT_NAMES[DATE_SEGMENT_NAMES.length - 1]; @@ -39,9 +40,6 @@ type UseDateSegmentInputParams = { /** The committed date in the format the app stores, shown whenever the field is not being edited */ value: string; - /** The localized mask, such as YYYY-MM-DD, which decides the segment order and the text shown for empty segments */ - mask: string; - /** Whether this platform lets the user type a date at all */ isEnabled: boolean; @@ -55,12 +53,29 @@ type UseDateSegmentInputParams = { onCommit: (isoDate: string) => void; }; +/** A request for a segment to take focus. The count is what carries it, so asking twice for the same segment works */ +type SegmentFocusRequest = { + name: DateSegmentName; + version: number; +}; + +/** Everything one segment's input needs. The segment itself is stateless and reports back through these */ +type DateSegmentProps = { + value: string; + onKeyPress: (event: TextInputKeyPressEvent) => void; + onChangeText: (text: string) => void; + onFocus: () => void; +}; + type UseDateSegmentInputResult = { - /** The text to render in the input */ + /** The committed date, shown while the field is not being edited */ displayValue: string; - /** The range covering the segment being edited, which selects it as a whole */ - selection: DateSegmentRange | undefined; + /** Whether the user is inside the field, so the segments rather than the committed date are what to render */ + isEditing: boolean; + + /** The segment the caller should move focus to, or undefined when no move has been asked for */ + focusRequest: SegmentFocusRequest | undefined; /** The month the calendar should show, so it follows the date being typed. Undefined leaves the calendar alone */ viewDate: Date | undefined; @@ -71,31 +86,29 @@ type UseDateSegmentInputResult = { /** Whether any digit has been typed, so the field is showing more than an untouched mask */ hasTypedDigits: boolean; - onKeyPress: (event: TextInputKeyPressEvent) => void; - onSelectionChange: (event: TextInputSelectionChangeEvent) => void; - onChangeText: (text: string) => void; - onFocus: () => void; - onBlur: () => void; + getSegmentProps: (name: DateSegmentName) => DateSegmentProps; + + /** Sends focus to the first segment still to be filled in, for a press that landed on the field but not on a segment */ + requestInitialFocus: () => void; + + /** Called once focus has left the field altogether rather than moved between segments */ + onFieldBlur: () => void; }; function isMoveKey(key: string): key is keyof typeof MOVE_KEYS { return key in MOVE_KEYS; } -export default function useDateSegmentInput({value, mask, isEnabled, minDate, maxDate, onCommit}: UseDateSegmentInputParams): UseDateSegmentInputResult { +export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: UseDateSegmentInputParams): UseDateSegmentInputResult { // The segments only describe an edit in progress, so they are seeded on focus rather than synced with the value const [segments, setSegments] = useState(EMPTY_SEGMENTS); - const [activeSegmentName, setActiveSegmentName] = useState(FIRST_SEGMENT_NAME); - const [caretOffset, setCaretOffset] = useState(0); const [isEditing, setIsEditing] = useState(false); + const [focusRequest, setFocusRequest] = useState(undefined); // The month the calendar should show, which follows the typed date once the year is complete const [viewDate, setViewDate] = useState(undefined); const [viewDateVersion, setViewDateVersion] = useState(0); - // Re-rendering with a new value makes the browser report a caret of its own choosing. Honouring that would drag - // the active segment around, so the first report after a keystroke is discarded as an echo of our own update. - const hasPendingCaretEchoRef = useRef(false); - // Whether the next digit replaces the active segment instead of extending it, set on arriving at a segment - const shouldOverwriteRef = useRef(false); + // Whether the next digit replaces the segment instead of extending it, set on arriving at a segment + const [shouldOverwrite, setShouldOverwrite] = useState(false); const [appliedValue, setAppliedValue] = useState(value); // A date set from outside, by the calendar or by a restored draft, has to reach the segments as well. Without this @@ -108,26 +121,14 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma } } - const {value: editingValue, ranges} = getDateDisplay(segments, mask); - const activeRange = ranges[activeSegmentName]; - // A caret parked on a digit place reads as three fields sharing one box. Selecting the whole segment instead would - // highlight it as a block, which is not what the design asks for. - const caretPosition = activeRange.start + caretOffset; - - const moveCaret = (name: DateSegmentName, offset: number, nextSegments: DateSegments = segments) => { - hasPendingCaretEchoRef.current = true; - setActiveSegmentName(name); - setCaretOffset(Math.min(Math.max(offset, 0), getCaretOffsetLimit(nextSegments, name))); + const requestFocus = (name: DateSegmentName) => { + setFocusRequest((previous) => ({name, version: (previous?.version ?? 0) + 1})); }; - /** - * Landing on a segment always rests the caret after whatever it already holds, so an empty one reads from its - * start and a filled one is ready to be typed over. `shouldOverwriteRef` is what makes that typing replace the - * segment rather than extend it. - */ - const enterSegment = (name: DateSegmentName, nextSegments: DateSegments = segments) => { - shouldOverwriteRef.current = true; - moveCaret(name, getCaretOffsetLimit(nextSegments, name), nextSegments); + /** Landing on a segment arms the overwrite, so the next digit replaces what is there rather than extending it */ + const enterSegment = (name: DateSegmentName) => { + setShouldOverwrite(true); + requestFocus(name); }; const commitIfComplete = (newSegments: DateSegments) => { @@ -162,43 +163,39 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma commitIfComplete(newSegments); }; - const handleKeyPress = (event: TextInputKeyPressEvent) => { + const handleKeyPress = (name: DateSegmentName, event: TextInputKeyPressEvent) => { const key = event.nativeEvent.key; if (isNumeric(key)) { event.preventDefault(); - const result = typeDigitIntoSegments(segments, activeSegmentName, key, shouldOverwriteRef.current); - shouldOverwriteRef.current = false; + const result = typeDigitIntoSegments(segments, name, key, shouldOverwrite); + setShouldOverwrite(false); applySegments(result.segments); if (result.nextSegmentName) { - enterSegment(result.nextSegmentName, result.segments); - return; + enterSegment(result.nextSegmentName); } - - moveCaret(activeSegmentName, getCaretOffsetLimit(result.segments, activeSegmentName), result.segments); return; } if (isMoveKey(key)) { event.preventDefault(); - enterSegment(getAdjacentSegmentName(activeSegmentName, MOVE_KEYS[key])); + enterSegment(getAdjacentSegmentName(name, MOVE_KEYS[key])); return; } if (key === BACKSPACE_KEY || key === DELETE_KEY) { event.preventDefault(); - const trimmedSegments = removeLastDigit(segments, activeSegmentName); + const trimmedSegments = removeLastDigit(segments, name); // An empty segment has nothing to delete, so the keystroke falls back to leaving it if (!trimmedSegments) { - enterSegment(getAdjacentSegmentName(activeSegmentName, -1)); + enterSegment(getAdjacentSegmentName(name, -1)); return; } applySegments(trimmedSegments); - shouldOverwriteRef.current = false; - moveCaret(activeSegmentName, getCaretOffsetLimit(trimmedSegments, activeSegmentName), trimmedSegments); + setShouldOverwrite(false); return; } @@ -212,33 +209,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma // A separator means the user is finished with this segment even if they only typed one digit into it event.preventDefault(); - enterSegment(getAdjacentSegmentName(activeSegmentName, 1)); - }; - - // Clicking into the text lands the caret anywhere, so snap it onto the digit place that was clicked - const handleSelectionChange = (event: TextInputSelectionChangeEvent) => { - if (hasPendingCaretEchoRef.current) { - hasPendingCaretEchoRef.current = false; - return; - } - - const position = event.nativeEvent.selection.start; - - // The caret is already here, so this is the browser reporting our own position back rather than the user - // aiming somewhere. Acting on it is what let a stray report drag the active segment around. - if (position === caretPosition) { - return; - } - - // Landing past the end of the text means the empty space in the field was clicked rather than a segment, so - // the first segment still to be filled in takes it, and a date with no gaps in it starts again from the year. - const clickedSegmentName = position >= editingValue.length ? (getFirstUnfilledSegmentName(segments) ?? FIRST_SEGMENT_NAME) : getSegmentNameAtPosition(position, ranges); - const clickedOffset = position - ranges[clickedSegmentName].start; - - // Clicking is aiming at a digit place rather than arriving at a segment, so the next digit extends what is - // there instead of replacing it - shouldOverwriteRef.current = false; - moveCaret(clickedSegmentName, clickedOffset); + enterSegment(getAdjacentSegmentName(name, 1)); }; // Every keystroke is prevented, so this only runs for text the user pasted in @@ -249,60 +220,65 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma } applySegments(pastedSegments); - enterSegment(LAST_SEGMENT_NAME, pastedSegments); + enterSegment(LAST_SEGMENT_NAME); }; - const handleFocus = () => { - const seededSegments = getSegmentsFromISODate(value); - const firstSegmentName = getFirstUnfilledSegmentName(seededSegments) ?? FIRST_SEGMENT_NAME; + /** + * A segment reporting that it now holds focus, whether the user clicked it or a keystroke sent them there. Arriving + * at a segment always arms the overwrite, so the first digit replaces what is already in it. + */ + const handleSegmentFocus = () => { + setShouldOverwrite(true); + if (isEditing) { + return; + } + + const seededSegments = getSegmentsFromISODate(value); setSegments(seededSegments); assertViewDate(getViewDateFromSegments(seededSegments, new Date().getMonth(), minDate, maxDate)); - shouldOverwriteRef.current = false; - setActiveSegmentName(firstSegmentName); - setCaretOffset(getCaretOffsetLimit(seededSegments, firstSegmentName)); - - // Deliberately not arming the caret echo guard. The click that brought focus here reports its own position - // next, and that report is what moves the caret off the end of the text and onto a segment. setIsEditing(true); }; // An unfinished edit is dropped rather than cleared, so leaving the field restores the last committed date - const handleBlur = () => { + const handleFieldBlur = () => { setIsEditing(false); setSegments(EMPTY_SEGMENTS); - setCaretOffset(0); setViewDate(undefined); - shouldOverwriteRef.current = false; + setFocusRequest(undefined); + setShouldOverwrite(false); }; if (!isEnabled) { return { displayValue: value, - selection: undefined, + isEditing: false, + focusRequest: undefined, viewDate: undefined, viewDateVersion: 0, hasTypedDigits: false, - onKeyPress: () => {}, - onSelectionChange: () => {}, - onChangeText: () => {}, - onFocus: () => {}, - onBlur: () => {}, + getSegmentProps: () => ({value: '', onKeyPress: () => {}, onChangeText: () => {}, onFocus: () => {}}), + requestInitialFocus: () => {}, + onFieldBlur: () => {}, }; } return { - displayValue: isEditing ? editingValue : value, - selection: isEditing ? {start: caretPosition, end: caretPosition} : undefined, + displayValue: value, + isEditing, + focusRequest, viewDate: isEditing ? viewDate : undefined, viewDateVersion, hasTypedDigits: isEditing && hasAnySegment(segments), - onKeyPress: handleKeyPress, - onSelectionChange: handleSelectionChange, - onChangeText: handleChangeText, - onFocus: handleFocus, - onBlur: handleBlur, + getSegmentProps: (name: DateSegmentName) => ({ + value: getSegmentDisplay(segments, name), + onKeyPress: (event: TextInputKeyPressEvent) => handleKeyPress(name, event), + onChangeText: handleChangeText, + onFocus: handleSegmentFocus, + }), + requestInitialFocus: () => enterSegment(getFirstUnfilledSegmentName(getSegmentsFromISODate(value)) ?? FIRST_SEGMENT_NAME), + onFieldBlur: handleFieldBlur, }; } -export type {UseDateSegmentInputParams, UseDateSegmentInputResult}; +export type {DateSegmentProps, SegmentFocusRequest, UseDateSegmentInputParams, UseDateSegmentInputResult}; diff --git a/src/languages/de.ts b/src/languages/de.ts index c3f2566239ed..c82e34ac4129 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -35,6 +35,11 @@ type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { common: { + dateSegments: { + year: 'Jahr', + month: 'Monat', + day: 'Tag', + }, durationDays: ({count}: {count: number}) => ({ one: `1 Tag`, other: `${count} Tage`, diff --git a/src/languages/el.ts b/src/languages/el.ts index 8f7c78a93910..29a12eb1db6e 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -32,6 +32,11 @@ type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { common: { + dateSegments: { + year: 'Έτος', + month: 'Μήνας', + day: 'Ημέρα', + }, durationDays: ({count}: {count: number}) => ({ one: `1 ημέρα`, other: `${count} ημέρες`, diff --git a/src/languages/en.ts b/src/languages/en.ts index 0ab827f6b67a..967b4d4fd954 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -534,6 +534,11 @@ const translations = { reimbursableTotal: 'Reimbursable total', nonReimbursableTotal: 'Non-reimbursable total', opensInNewTab: 'Opens in a new tab', + dateSegments: { + year: 'Year', + month: 'Month', + day: 'Day', + }, locked: 'Locked', month: 'Month', week: 'Week', diff --git a/src/languages/es.ts b/src/languages/es.ts index 4eaad6f99764..89e6c823e1b2 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -480,6 +480,11 @@ const translations: TranslationDeepObject = { amountDebited: 'Importe debitado', amountReimbursed: 'Importe reembolsado', opensInNewTab: 'Se abre en una nueva pestaña', + dateSegments: { + year: 'Año', + month: 'Mes', + day: 'Día', + }, locked: 'Bloqueado', month: 'Monat', week: 'Semana', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index cb157ffffb4c..2e1c6656792e 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -35,6 +35,11 @@ type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { common: { + dateSegments: { + year: 'Année', + month: 'Mois', + day: 'Jour', + }, durationDays: ({count}: {count: number}) => ({ one: `${count} jour`, other: `${count} jours`, diff --git a/src/languages/it.ts b/src/languages/it.ts index 8276553f8ee2..6bc6033ef964 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -35,6 +35,11 @@ type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { common: { + dateSegments: { + year: 'Anno', + month: 'Mese', + day: 'Giorno', + }, durationDays: ({count}: {count: number}) => ({ one: `1 giorno`, other: `${count} giorni`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index a00d7c5b0eb8..12eef2035951 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -35,6 +35,11 @@ type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { common: { + dateSegments: { + year: '年', + month: '月', + day: '日', + }, durationDays: ({count}: {count: number}) => ({ one: `1 日`, other: `${count} 日`, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index c30e3f205c6d..25d839f1b6e8 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -35,6 +35,11 @@ type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { common: { + dateSegments: { + year: 'Jaar', + month: 'Maand', + day: 'Dag', + }, durationDays: ({count}: {count: number}) => ({ one: `1 dag`, other: `${count} dagen`, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 885864be4592..337ffe6c5c09 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -35,6 +35,11 @@ type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { common: { + dateSegments: { + year: 'Rok', + month: 'Miesiąc', + day: 'Dzień', + }, durationDays: ({count}: {count: number}) => ({ one: `1 dzień`, other: `${count} dni`, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 3a44db440220..f97d4b93c41f 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -35,6 +35,11 @@ type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { common: { + dateSegments: { + year: 'Ano', + month: 'Mês', + day: 'Dia', + }, durationDays: ({count}: {count: number}) => ({ one: `${count} dia`, other: `${count} dias`, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 0519fa4dfff9..0f5809bcf94b 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -35,6 +35,11 @@ type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { common: { + dateSegments: { + year: '年', + month: '月', + day: '日', + }, durationDays: ({count}: {count: number}) => ({ one: `1 天`, other: `${count} 天`, diff --git a/src/libs/DateInputMaskUtils.ts b/src/libs/DateInputMaskUtils.ts index 78e68395617e..ff04ce94ec0d 100644 --- a/src/libs/DateInputMaskUtils.ts +++ b/src/libs/DateInputMaskUtils.ts @@ -36,11 +36,6 @@ type DateSegmentName = TupleToUnion; /** The digits typed into each segment, empty when the segment has not been filled in yet */ type DateSegments = Record; -type DateSegmentRange = { - start: number; - end: number; -}; - type DateMaskPart = { name: DateSegmentName; @@ -51,14 +46,6 @@ type DateMaskPart = { separator: string; }; -type DateDisplay = { - /** The text to show in the input */ - value: string; - - /** Where each segment sits inside that text, so a segment can be selected as a whole */ - ranges: Record; -}; - const EMPTY_SEGMENTS: DateSegments = {year: '', month: '', day: ''}; function isMaskLetter(character: string): boolean { @@ -109,43 +96,17 @@ function isZeroPaddedSegment(name: DateSegmentName): name is keyof typeof SEGMEN } /** - * How far into a segment the caret may sit. A zero padded segment displays its digits at the end, so one typed digit - * puts the caret at the end of the segment rather than one place into it. + * The text one segment shows. A zero padded segment fills from the right, so a day part way through reads as 02 and + * becomes 23 on the next digit. An empty segment renders nothing and lets its own placeholder show through. */ -function getCaretOffsetLimit(segments: DateSegments, name: DateSegmentName): number { - const typedLength = segments[name].length; +function getSegmentDisplay(segments: DateSegments, name: DateSegmentName): string { + const digits = segments[name].slice(0, getSegmentLength(name)); - if (!typedLength) { - return 0; + if (!digits) { + return ''; } - return isZeroPaddedSegment(name) ? getSegmentLength(name) : Math.min(typedLength, getSegmentLength(name)); -} - -function getDateDisplay(segments: DateSegments, mask: string): DateDisplay { - let value = ''; - const ranges: Record = {year: {start: 0, end: 0}, month: {start: 0, end: 0}, day: {start: 0, end: 0}}; - - for (const part of getDateMaskParts(mask)) { - // Typed digits take the place of the mask letters, so a half typed year reads as 2YYY rather than 2. A zero - // padded segment fills from the right instead, so a day part way through reads as 02 and becomes 23 on the - // next digit. Either way the segment keeps the width of its mask, which is what lets a caret position mean - // the same thing twice. - const digits = segments[part.name].slice(0, part.placeholder.length); - const text = digits && isZeroPaddedSegment(part.name) ? digits.padStart(part.placeholder.length, '0') : `${digits}${part.placeholder.slice(digits.length)}`; - - ranges[part.name] = {start: value.length, end: value.length + text.length}; - value += `${text}${part.separator}`; - } - - return {value, ranges}; -} - -/** Which segment a caret position falls in, so clicking into the text selects the segment that was clicked */ -function getSegmentNameAtPosition(position: number, ranges: Record): DateSegmentName { - const name = DATE_SEGMENT_NAMES.find((segmentName) => position <= ranges[segmentName].end); - - return name ?? DATE_SEGMENT_NAMES[DATE_SEGMENT_NAMES.length - 1]; + return isZeroPaddedSegment(name) ? digits.padStart(getSegmentLength(name), '0') : digits; } /** The segment that follows this one, or undefined for the last one, which has nowhere to hand a finished value on to */ @@ -331,11 +292,11 @@ export { DATE_SEGMENT_NAMES, EMPTY_SEGMENTS, getAdjacentSegmentName, - getCaretOffsetLimit, - getDateDisplay, + getDateMaskParts, getFirstUnfilledSegmentName, getISODateFromSegments, - getSegmentNameAtPosition, + getSegmentDisplay, + getSegmentLength, getSegmentsFromISODate, getSegmentsFromText, getViewDateFromSegments, @@ -343,4 +304,4 @@ export { removeLastDigit, typeDigitIntoSegments, }; -export type {DateSegmentName, DateSegmentRange, DateSegments}; +export type {DateMaskPart, DateSegmentName, DateSegments}; diff --git a/tests/unit/DateInputMaskUtilsTest.ts b/tests/unit/DateInputMaskUtilsTest.ts index 8c22ddc94840..0014601659c9 100644 --- a/tests/unit/DateInputMaskUtilsTest.ts +++ b/tests/unit/DateInputMaskUtilsTest.ts @@ -1,10 +1,10 @@ import { getAdjacentSegmentName, - getCaretOffsetLimit, - getDateDisplay, + getDateMaskParts, getFirstUnfilledSegmentName, getISODateFromSegments, - getSegmentNameAtPosition, + getSegmentDisplay, + getSegmentLength, getSegmentsFromISODate, getSegmentsFromText, getViewDateFromSegments, @@ -121,77 +121,49 @@ describe('DateInputMaskUtils', () => { }); }); - describe('getDateDisplay', () => { - it('shows the mask while every segment is empty', () => { - expect(getDateDisplay(EMPTY, MASK).value).toBe(MASK); + describe('getSegmentDisplay', () => { + it('shows nothing for an empty segment, leaving its own placeholder to show through', () => { + expect(getSegmentDisplay(EMPTY, 'year')).toBe(''); + expect(getSegmentDisplay(EMPTY, 'month')).toBe(''); }); - it('keeps the mask for the segments still to be filled in', () => { - expect(getDateDisplay(segments('2026', '', ''), MASK).value).toBe('2026-MM-DD'); - expect(getDateDisplay(segments('2026', '09', ''), MASK).value).toBe('2026-09-DD'); - expect(getDateDisplay(segments('2026', '09', '18'), MASK).value).toBe('2026-09-18'); - }); - - it('keeps the mask letters of the digit places a half typed year has not reached', () => { - expect(getDateDisplay(segments('2', '', ''), MASK).value).toBe('2YYY-MM-DD'); - expect(getDateDisplay(segments('20', '', ''), MASK).value).toBe('20YY-MM-DD'); + it('shows the year as far as it has been typed', () => { + expect(getSegmentDisplay(segments('2', '', ''), 'year')).toBe('2'); + expect(getSegmentDisplay(segments('20', '', ''), 'year')).toBe('20'); + expect(getSegmentDisplay(segments('2026', '', ''), 'year')).toBe('2026'); }); it('zero pads a half typed month or day, which fill from the right', () => { - expect(getDateDisplay(segments('2026', '1', ''), MASK).value).toBe('2026-01-DD'); - expect(getDateDisplay(segments('2026', '09', '2'), MASK).value).toBe('2026-09-02'); - }); - - it('reports where each segment sits in the text', () => { - expect(getDateDisplay(segments('2026', '09', '18'), MASK).ranges).toEqual({ - year: {start: 0, end: 4}, - month: {start: 5, end: 7}, - day: {start: 8, end: 10}, - }); - }); - - it('holds the ranges still while a segment is half typed, so a caret position keeps its meaning', () => { - const {ranges} = getDateDisplay(segments('2026', '1', ''), MASK); - - expect(ranges.month).toEqual({start: 5, end: 7}); - expect(ranges.day).toEqual({start: 8, end: 10}); + expect(getSegmentDisplay(segments('2026', '1', ''), 'month')).toBe('01'); + expect(getSegmentDisplay(segments('2026', '09', '2'), 'day')).toBe('02'); }); - it('uses the letters and separators of the localized mask', () => { - expect(getDateDisplay(segments('2026', '', ''), 'AAAA-MM-JJ').value).toBe('2026-MM-JJ'); + it('shows a finished month or day as typed', () => { + expect(getSegmentDisplay(segments('2026', '09', '18'), 'month')).toBe('09'); + expect(getSegmentDisplay(segments('2026', '09', '18'), 'day')).toBe('18'); }); }); - describe('getCaretOffsetLimit', () => { - it('keeps the caret at the start of an empty segment', () => { - expect(getCaretOffsetLimit(EMPTY, 'year')).toBe(0); - expect(getCaretOffsetLimit(EMPTY, 'day')).toBe(0); - }); - - it('follows the typed digits through the year, which fills from the left', () => { - expect(getCaretOffsetLimit(segments('2', '', ''), 'year')).toBe(1); - expect(getCaretOffsetLimit(segments('202', '', ''), 'year')).toBe(3); - expect(getCaretOffsetLimit(segments('2026', '', ''), 'year')).toBe(4); + describe('getDateMaskParts', () => { + it('reads the segment order, placeholders and separators out of the mask', () => { + expect(getDateMaskParts(MASK)).toEqual([ + {name: 'year', placeholder: 'YYYY', separator: '-'}, + {name: 'month', placeholder: 'MM', separator: '-'}, + {name: 'day', placeholder: 'DD', separator: ''}, + ]); }); - it('rests at the end of a zero padded segment, since 02 shows the 2 last', () => { - expect(getCaretOffsetLimit(segments('2026', '1', ''), 'month')).toBe(2); - expect(getCaretOffsetLimit(segments('2026', '12', ''), 'month')).toBe(2); + it('uses the letters and separators of the localized mask', () => { + expect(getDateMaskParts('AAAA/MM/JJ').map((part) => part.placeholder)).toEqual(['AAAA', 'MM', 'JJ']); + expect(getDateMaskParts('AAAA/MM/JJ').map((part) => part.separator)).toEqual(['/', '/', '']); }); }); - describe('getSegmentNameAtPosition', () => { - const {ranges} = getDateDisplay(segments('2026', '09', '18'), MASK); - - it('maps a position to the segment that covers it', () => { - expect(getSegmentNameAtPosition(0, ranges)).toBe('year'); - expect(getSegmentNameAtPosition(4, ranges)).toBe('year'); - expect(getSegmentNameAtPosition(6, ranges)).toBe('month'); - expect(getSegmentNameAtPosition(9, ranges)).toBe('day'); - }); - - it('falls back to the last segment past the end of the text', () => { - expect(getSegmentNameAtPosition(99, ranges)).toBe('day'); + describe('getSegmentLength', () => { + it('gives the year twice the digits of the others', () => { + expect(getSegmentLength('year')).toBe(4); + expect(getSegmentLength('month')).toBe(2); + expect(getSegmentLength('day')).toBe(2); }); }); From 8f9b1521131400882891e5b0aa7764f313fac779 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 18 Sep 2026 23:13:49 +0530 Subject: [PATCH 15/80] fix: measure each date segment instead of estimating its width Signed-off-by: krishna2323 --- src/components/DateSegmentsInput.tsx | 36 ++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/src/components/DateSegmentsInput.tsx b/src/components/DateSegmentsInput.tsx index 6c87251070eb..65130f590c39 100644 --- a/src/components/DateSegmentsInput.tsx +++ b/src/components/DateSegmentsInput.tsx @@ -14,7 +14,7 @@ import type {DateSegmentName} from '@libs/DateInputMaskUtils'; import CONST from '@src/CONST'; -import React, {useEffect, useRef} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import type {AnimatedTextInputRef} from './RNTextInput'; @@ -23,14 +23,21 @@ import type {BaseTextInputProps} from './TextInput/BaseTextInput/types'; import RNTextInput from './RNTextInput'; import Text from './Text'; -/** The mask letters are wider than the digits that replace them, so a segment is sized to hold its placeholder */ -const SEGMENT_PADDING = CONST.CHARACTER_WIDTH / 2; +/** Leaves the caret somewhere to sit, since a segment sized to its text exactly would clip it at the end */ +const CARET_ALLOWANCE = 2; + +/** Used until the real text has been measured, so the first paint is close rather than collapsed */ +const ESTIMATED_CHARACTER_WIDTH = CONST.CHARACTER_WIDTH; /** - * The style the field hands down stretches its input to fill the row, which would spread three of them evenly across - * it. Each segment is sized to its own digits instead, so the date reads as one run of text. + * The style the field hands down stretches its input across the whole row, which would spread the segments evenly and + * make each separator as wide as the field. Everything here is sized to its own text instead, so the date reads as one + * run of text. */ -const SIZED_TO_CONTENT = {flexGrow: 0, flexShrink: 0, flexBasis: 'auto'} as const; +const SIZED_TO_CONTENT = {flexGrow: 0, flexShrink: 0, flexBasis: 'auto', width: 'auto'} as const; + +/** Holds a copy of a segment's text purely to be measured, so it must not take part in the layout it is measuring */ +const MEASURED_OFF_LAYOUT = {position: 'absolute', opacity: 0} as const; function DateSegmentsInput({dateSegmentsConfig, style, placeholderTextColor, disabled, onPressOut, forwardedFSClass, ref}: BaseTextInputProps) { const styles = useThemeStyles(); @@ -40,6 +47,9 @@ function DateSegmentsInput({dateSegmentsConfig, style, placeholderTextColor, dis // moving within it once the next focus has had its chance to arrive. const blurTimeoutRef = useRef(undefined); const appliedFocusVersionRef = useRef(0); + // The mask letters are wider than the digits that replace them, so a fixed width would either clip the placeholder + // or leave a gap once the segment is filled in. Each one is measured from the text it is currently showing. + const [measuredWidths, setMeasuredWidths] = useState>>({}); const focusRequest = dateSegmentsConfig?.focusRequest; @@ -69,9 +79,21 @@ function DateSegmentsInput({dateSegmentsConfig, style, placeholderTextColor, dis {getDateMaskParts(mask).map((part) => { const segmentProps = getSegmentProps(part.name); + const measuredText = segmentProps.value || part.placeholder; + const width = (measuredWidths[part.name] ?? measuredText.length * ESTIMATED_CHARACTER_WIDTH) + CARET_ALLOWANCE; return ( + { + const layoutWidth = event.nativeEvent.layout.width; + + setMeasuredWidths((previous) => (previous[part.name] === layoutWidth ? previous : {...previous, [part.name]: layoutWidth})); + }} + > + {measuredText} + { segmentRefs.current[part.name] = element; @@ -87,7 +109,7 @@ function DateSegmentsInput({dateSegmentsConfig, style, placeholderTextColor, dis ref.current = element; } }} - style={[style, styles.p0, SIZED_TO_CONTENT, {width: part.placeholder.length * CONST.CHARACTER_WIDTH + SEGMENT_PADDING}]} + style={[style, styles.p0, SIZED_TO_CONTENT, {width}]} value={segmentProps.value} placeholder={part.placeholder} placeholderTextColor={placeholderTextColor} From 1f44f08ade38a55635fb2323ef1f401417650c77 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 18 Sep 2026 23:19:43 +0530 Subject: [PATCH 16/80] fix: show the committed date when the date field is not being edited Signed-off-by: krishna2323 --- src/components/DateSegmentsInput.tsx | 18 +++++++++++++----- src/hooks/useDateSegmentInput.ts | 5 ++++- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/components/DateSegmentsInput.tsx b/src/components/DateSegmentsInput.tsx index 65130f590c39..f615e5076814 100644 --- a/src/components/DateSegmentsInput.tsx +++ b/src/components/DateSegmentsInput.tsx @@ -24,7 +24,13 @@ import RNTextInput from './RNTextInput'; import Text from './Text'; /** Leaves the caret somewhere to sit, since a segment sized to its text exactly would clip it at the end */ -const CARET_ALLOWANCE = 2; +const CARET_ALLOWANCE = 1; + +/** + * Only the horizontal padding is dropped. The vertical padding that the field hands down is what sits the text below + * the floating label, so the segments keep it and stay in line with any other text field. + */ +const NO_HORIZONTAL_PADDING = {paddingHorizontal: 0} as const; /** Used until the real text has been measured, so the first paint is close rather than collapsed */ const ESTIMATED_CHARACTER_WIDTH = CONST.CHARACTER_WIDTH; @@ -75,8 +81,10 @@ function DateSegmentsInput({dateSegmentsConfig, style, placeholderTextColor, dis blurTimeoutRef.current = setTimeout(onFieldBlur, 0); }; + // The segments stretch down the row as a single input would, so the padding they inherit lands their text in the + // same place as any other text field's return ( - + {getDateMaskParts(mask).map((part) => { const segmentProps = getSegmentProps(part.name); const measuredText = segmentProps.value || part.placeholder; @@ -85,7 +93,7 @@ function DateSegmentsInput({dateSegmentsConfig, style, placeholderTextColor, dis return ( { const layoutWidth = event.nativeEvent.layout.width; @@ -109,7 +117,7 @@ function DateSegmentsInput({dateSegmentsConfig, style, placeholderTextColor, dis ref.current = element; } }} - style={[style, styles.p0, SIZED_TO_CONTENT, {width}]} + style={[style, NO_HORIZONTAL_PADDING, SIZED_TO_CONTENT, {width}]} value={segmentProps.value} placeholder={part.placeholder} placeholderTextColor={placeholderTextColor} @@ -126,7 +134,7 @@ function DateSegmentsInput({dateSegmentsConfig, style, placeholderTextColor, dis disabled={disabled} forwardedFSClass={forwardedFSClass} /> - {!!part.separator && {part.separator}} + {!!part.separator && {part.separator}} ); })} diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index 1cdeff76fec0..f6801ab0012d 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -263,6 +263,9 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, }; } + // The segments describe an edit in progress, so outside of one the committed date is what the field has to show + const displayedSegments = isEditing ? segments : getSegmentsFromISODate(value); + return { displayValue: value, isEditing, @@ -271,7 +274,7 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, viewDateVersion, hasTypedDigits: isEditing && hasAnySegment(segments), getSegmentProps: (name: DateSegmentName) => ({ - value: getSegmentDisplay(segments, name), + value: getSegmentDisplay(displayedSegments, name), onKeyPress: (event: TextInputKeyPressEvent) => handleKeyPress(name, event), onChangeText: handleChangeText, onFocus: handleSegmentFocus, From d3068c33f94adeb9fbc254e0f95b02179a76b0d2 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 18 Sep 2026 23:23:18 +0530 Subject: [PATCH 17/80] fix: let a press land on the date segment it was aimed at Signed-off-by: krishna2323 --- src/components/DatePicker/index.tsx | 8 ++------ src/components/DateSegmentsInput.tsx | 12 +++++++++++- src/components/TextInput/BaseTextInput/types.ts | 1 + 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index 355df62ef44f..7a1779318646 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -190,14 +190,9 @@ function DatePicker({ event.preventDefault(); } - // A press that landed on the field but not on a segment leaves nothing focused, so send it somewhere useful - if (shouldAllowTyping && !segmentInput.isEditing) { - segmentInput.requestInitialFocus(); - } - showDatePickerModal(); }, - [shouldAllowTyping, showDatePickerModal, segmentInput], + [shouldAllowTyping, showDatePickerModal], ); // Reaching the field by keyboard never fires a press, so focus is what opens the calendar once typing is allowed. @@ -290,6 +285,7 @@ function DatePicker({ mask: dateMask, getSegmentProps: segmentInput.getSegmentProps, focusRequest: segmentInput.focusRequest, + requestInitialFocus: segmentInput.requestInitialFocus, onFieldBlur: segmentInput.onFieldBlur, } : undefined diff --git a/src/components/DateSegmentsInput.tsx b/src/components/DateSegmentsInput.tsx index f615e5076814..81093928c3ff 100644 --- a/src/components/DateSegmentsInput.tsx +++ b/src/components/DateSegmentsInput.tsx @@ -20,6 +20,7 @@ import {View} from 'react-native'; import type {AnimatedTextInputRef} from './RNTextInput'; import type {BaseTextInputProps} from './TextInput/BaseTextInput/types'; +import {PressableWithoutFeedback} from './Pressable'; import RNTextInput from './RNTextInput'; import Text from './Text'; @@ -74,7 +75,7 @@ function DateSegmentsInput({dateSegmentsConfig, style, placeholderTextColor, dis return null; } - const {mask, getSegmentProps, onFieldBlur} = dateSegmentsConfig; + const {mask, getSegmentProps, requestInitialFocus, onFieldBlur} = dateSegmentsConfig; const handleSegmentBlur = () => { clearTimeout(blurTimeoutRef.current); @@ -138,6 +139,15 @@ function DateSegmentsInput({dateSegmentsConfig, style, placeholderTextColor, dis ); })} + {/* A single input filled the row, so clicking anywhere in the field focused it. This takes the space left + over after the day, so pressing it still lands somewhere useful rather than doing nothing. */} + ); } diff --git a/src/components/TextInput/BaseTextInput/types.ts b/src/components/TextInput/BaseTextInput/types.ts index 5a618c50b0e2..b8c5d5223e92 100644 --- a/src/components/TextInput/BaseTextInput/types.ts +++ b/src/components/TextInput/BaseTextInput/types.ts @@ -22,6 +22,7 @@ type DateSegmentsConfig = { getSegmentProps: UseDateSegmentInputResult['getSegmentProps']; focusRequest: UseDateSegmentInputResult['focusRequest']; + requestInitialFocus: UseDateSegmentInputResult['requestInitialFocus']; onFieldBlur: UseDateSegmentInputResult['onFieldBlur']; }; From 1d90b469ace89603276de9afea09ec7578807a87 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 18 Sep 2026 23:29:35 +0530 Subject: [PATCH 18/80] fix: stop the date field's props being dropped by the segmented input Signed-off-by: krishna2323 --- src/components/DatePicker/index.tsx | 7 ++--- src/components/DateSegmentsInput.tsx | 39 ++++++++++++++++++++++++---- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index 7a1779318646..c0aed93b491d 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -185,14 +185,15 @@ function DatePicker({ const handlePress = useCallback>( (event) => { - // Preventing the press would also stop focus from landing in the segment the user clicked. - if (!shouldAllowTyping && 'preventDefault' in event) { + // The field focuses its own input on any press it is not told to leave alone, which would be the year + // whichever segment was actually pressed. The segments are focused by the press itself instead. + if ('preventDefault' in event) { event.preventDefault(); } showDatePickerModal(); }, - [shouldAllowTyping, showDatePickerModal], + [showDatePickerModal], ); // Reaching the field by keyboard never fires a press, so focus is what opens the calendar once typing is allowed. diff --git a/src/components/DateSegmentsInput.tsx b/src/components/DateSegmentsInput.tsx index 81093928c3ff..ef6dc93b9ecf 100644 --- a/src/components/DateSegmentsInput.tsx +++ b/src/components/DateSegmentsInput.tsx @@ -46,7 +46,21 @@ const SIZED_TO_CONTENT = {flexGrow: 0, flexShrink: 0, flexBasis: 'auto', width: /** Holds a copy of a segment's text purely to be measured, so it must not take part in the layout it is measuring */ const MEASURED_OFF_LAYOUT = {position: 'absolute', opacity: 0} as const; -function DateSegmentsInput({dateSegmentsConfig, style, placeholderTextColor, disabled, onPressOut, forwardedFSClass, ref}: BaseTextInputProps) { +function DateSegmentsInput({ + dateSegmentsConfig, + style, + placeholderTextColor, + disabled, + onPressOut, + onFocus, + onBlur, + readOnly, + forwardedFSClass, + accessibilityLabel, + 'aria-describedby': ariaDescribedBy, + 'aria-invalid': ariaInvalid, + ref, +}: BaseTextInputProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const segmentRefs = useRef>>({}); @@ -77,15 +91,26 @@ function DateSegmentsInput({dateSegmentsConfig, style, placeholderTextColor, dis const {mask, getSegmentProps, requestInitialFocus, onFieldBlur} = dateSegmentsConfig; - const handleSegmentBlur = () => { + /** + * The field's own blur handler is what a form hangs its validation on, so it has to wait for the whole field to be + * left rather than run on every hop between segments. + */ + const handleSegmentBlur: NonNullable = (event) => { clearTimeout(blurTimeoutRef.current); - blurTimeoutRef.current = setTimeout(onFieldBlur, 0); + blurTimeoutRef.current = setTimeout(() => { + onFieldBlur(); + onBlur?.(event); + }, 0); }; // The segments stretch down the row as a single input would, so the padding they inherit lands their text in the // same place as any other text field's return ( - + {getDateMaskParts(mask).map((part) => { const segmentProps = getSegmentProps(part.name); const measuredText = segmentProps.value || part.placeholder; @@ -124,16 +149,20 @@ function DateSegmentsInput({dateSegmentsConfig, style, placeholderTextColor, dis placeholderTextColor={placeholderTextColor} onKeyPress={segmentProps.onKeyPress} onChangeText={segmentProps.onChangeText} - onFocus={() => { + onFocus={(event) => { clearTimeout(blurTimeoutRef.current); segmentProps.onFocus(); + onFocus?.(event); }} onBlur={handleSegmentBlur} onPressOut={onPressOut} accessibilityLabel={translate(`common.dateSegments.${part.name}`)} inputMode="numeric" disabled={disabled} + readOnly={readOnly} forwardedFSClass={forwardedFSClass} + aria-describedby={ariaDescribedBy} + aria-invalid={ariaInvalid} /> {!!part.separator && {part.separator}} From 996c988142ce65a9048ec6bcf43e4cea10108961 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 18 Sep 2026 23:37:30 +0530 Subject: [PATCH 19/80] fix: land the caret at the end of a date segment reached by keystroke Signed-off-by: krishna2323 --- src/components/DateSegmentsInput.tsx | 11 +++++++++-- src/hooks/useDateSegmentInput.ts | 4 ++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/components/DateSegmentsInput.tsx b/src/components/DateSegmentsInput.tsx index ef6dc93b9ecf..34907535cb5a 100644 --- a/src/components/DateSegmentsInput.tsx +++ b/src/components/DateSegmentsInput.tsx @@ -80,7 +80,14 @@ function DateSegmentsInput({ } appliedFocusVersionRef.current = focusRequest.version; - segmentRefs.current[focusRequest.name]?.focus(); + + const element = segmentRefs.current[focusRequest.name]; + element?.focus(); + + // Arriving by keystroke rests the caret after the digits already there, rather than wherever it was last left + // in this segment, so a half typed month reads as 02 and not 0 followed by a caret and a 2. + const caretPosition = element?.value?.length ?? 0; + element?.setSelectionRange?.(caretPosition, caretPosition); }, [focusRequest]); useEffect(() => () => clearTimeout(blurTimeoutRef.current), []); @@ -175,7 +182,7 @@ function DateSegmentsInput({ accessibilityLabel={translate('common.date')} onPress={requestInitialFocus} sentryLabel="DateSegmentsInput-EmptySpace" - style={styles.flex1} + style={[styles.flex1, styles.cursorText]} /> ); diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index f6801ab0012d..89b2bb28b172 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -26,7 +26,6 @@ import type {TextInputKeyPressEvent} from 'react-native'; import {useState} from 'react'; -const FIRST_SEGMENT_NAME = DATE_SEGMENT_NAMES[0]; const LAST_SEGMENT_NAME = DATE_SEGMENT_NAMES[DATE_SEGMENT_NAMES.length - 1]; const BACKSPACE_KEY = 'Backspace'; @@ -279,7 +278,8 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, onChangeText: handleChangeText, onFocus: handleSegmentFocus, }), - requestInitialFocus: () => enterSegment(getFirstUnfilledSegmentName(getSegmentsFromISODate(value)) ?? FIRST_SEGMENT_NAME), + // A date with no gaps in it has no segment waiting to be filled, so the last one takes the focus + requestInitialFocus: () => enterSegment(getFirstUnfilledSegmentName(displayedSegments) ?? LAST_SEGMENT_NAME), onFieldBlur: handleFieldBlur, }; } From 62993b779e4fcf0bf5b1b9d971f35e66f78b6506 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 18 Sep 2026 23:46:30 +0530 Subject: [PATCH 20/80] fix: reposition the calendar when the date field changes height Signed-off-by: krishna2323 --- src/components/DatePicker/index.tsx | 12 +++++- src/components/DateSegmentsInput.tsx | 40 ++++++++++++++----- .../TextInput/BaseTextInput/types.ts | 1 - src/hooks/useDateSegmentInput.ts | 7 ---- 4 files changed, 41 insertions(+), 19 deletions(-) diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index c0aed93b491d..820884080a58 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -225,6 +225,16 @@ function DatePicker({ // has to follow the field, and following it keeps an edit in progress from being interrupted. useRemeasureOnScroll({isActive: shouldAllowTyping && isModalVisible, remeasure: calculatePopoverPosition}); + // The error text renders inside the anchor, so showing or hiding it changes the height the calendar was positioned + // from. Remeasuring on the anchor's own layout covers that without having to name each thing that can resize it. + const handleAnchorLayout = () => { + if (!isModalVisible) { + return; + } + + calculatePopoverPosition(); + }; + const handleClear = () => { onTouched?.(); onInputChange?.(''); @@ -268,6 +278,7 @@ function DatePicker({ { + const element = segmentRefs.current[name]; + element?.focus(); + + const caretPosition = element?.value?.length ?? 0; + element?.setSelectionRange?.(caretPosition, caretPosition); + }; + useEffect(() => { if (!focusRequest || focusRequest.version === appliedFocusVersionRef.current) { return; } appliedFocusVersionRef.current = focusRequest.version; - - const element = segmentRefs.current[focusRequest.name]; - element?.focus(); - - // Arriving by keystroke rests the caret after the digits already there, rather than wherever it was last left - // in this segment, so a half typed month reads as 02 and not 0 followed by a caret and a 2. - const caretPosition = element?.value?.length ?? 0; - element?.setSelectionRange?.(caretPosition, caretPosition); + focusSegment(focusRequest.name); }, [focusRequest]); useEffect(() => () => clearTimeout(blurTimeoutRef.current), []); @@ -96,7 +101,22 @@ function DateSegmentsInput({ return null; } - const {mask, getSegmentProps, requestInitialFocus, onFieldBlur} = dateSegmentsConfig; + const {mask, getSegmentProps, onFieldBlur} = dateSegmentsConfig; + + /** + * Where a press on the field itself lands. The segments are read from the inputs rather than from this render, so + * a press arriving as the field is being cleared aims at what the field holds by then and not a moment earlier. + */ + const focusFirstUnfilledSegment = () => { + const parts = getDateMaskParts(mask); + const target = parts.find((part) => (segmentRefs.current[part.name]?.value?.length ?? 0) < part.placeholder.length) ?? parts.at(-1); + + if (!target) { + return; + } + + focusSegment(target.name); + }; /** * The field's own blur handler is what a form hangs its validation on, so it has to wait for the whole field to be @@ -180,7 +200,7 @@ function DateSegmentsInput({ diff --git a/src/components/TextInput/BaseTextInput/types.ts b/src/components/TextInput/BaseTextInput/types.ts index b8c5d5223e92..5a618c50b0e2 100644 --- a/src/components/TextInput/BaseTextInput/types.ts +++ b/src/components/TextInput/BaseTextInput/types.ts @@ -22,7 +22,6 @@ type DateSegmentsConfig = { getSegmentProps: UseDateSegmentInputResult['getSegmentProps']; focusRequest: UseDateSegmentInputResult['focusRequest']; - requestInitialFocus: UseDateSegmentInputResult['requestInitialFocus']; onFieldBlur: UseDateSegmentInputResult['onFieldBlur']; }; diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index 89b2bb28b172..1f20f1434c19 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -9,7 +9,6 @@ import { DATE_SEGMENT_NAMES, EMPTY_SEGMENTS, getAdjacentSegmentName, - getFirstUnfilledSegmentName, getISODateFromSegments, getSegmentDisplay, getSegmentsFromISODate, @@ -87,9 +86,6 @@ type UseDateSegmentInputResult = { getSegmentProps: (name: DateSegmentName) => DateSegmentProps; - /** Sends focus to the first segment still to be filled in, for a press that landed on the field but not on a segment */ - requestInitialFocus: () => void; - /** Called once focus has left the field altogether rather than moved between segments */ onFieldBlur: () => void; }; @@ -257,7 +253,6 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, viewDateVersion: 0, hasTypedDigits: false, getSegmentProps: () => ({value: '', onKeyPress: () => {}, onChangeText: () => {}, onFocus: () => {}}), - requestInitialFocus: () => {}, onFieldBlur: () => {}, }; } @@ -278,8 +273,6 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, onChangeText: handleChangeText, onFocus: handleSegmentFocus, }), - // A date with no gaps in it has no segment waiting to be filled, so the last one takes the focus - requestInitialFocus: () => enterSegment(getFirstUnfilledSegmentName(displayedSegments) ?? LAST_SEGMENT_NAME), onFieldBlur: handleFieldBlur, }; } From aa094c3883d4759c83e8003412e27ac3f0dba473 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 18 Sep 2026 23:58:33 +0530 Subject: [PATCH 21/80] fix: keep a committed date from restarting the segment being typed Signed-off-by: krishna2323 --- src/hooks/useDateSegmentInput.ts | 4 +++- src/libs/DateInputMaskUtils.ts | 12 +++++++++--- tests/unit/DateInputMaskUtilsTest.ts | 19 +++++++++++++++++-- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index 1f20f1434c19..059410605f47 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -111,7 +111,9 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, if (value !== appliedValue) { setAppliedValue(value); - if (isEditing) { + // A date this field has just committed already agrees with the segments, and seeding from it would replace a + // half typed segment with the padded form it is showing. The next digit would then start the segment over. + if (isEditing && value !== getISODateFromSegments(segments)) { setSegments(getSegmentsFromISODate(value)); } } diff --git a/src/libs/DateInputMaskUtils.ts b/src/libs/DateInputMaskUtils.ts index ff04ce94ec0d..e9186beb0b0a 100644 --- a/src/libs/DateInputMaskUtils.ts +++ b/src/libs/DateInputMaskUtils.ts @@ -273,13 +273,19 @@ function getSegmentsFromText(text: string): DateSegments { return filled; } -/** Returns the date in the format the rest of the app stores, or undefined while any segment is still unfinished */ +/** + * Returns the date in the format the rest of the app stores, or undefined while a segment is empty or reads as an + * impossible date. + * + * A zero padded segment counts from one digit, since that is already what it shows. A day of 3 therefore reads as the + * third rather than waiting to find out whether it was going to be the 30th, and typing that second digit revises it. + */ function getISODateFromSegments(segments: DateSegments): string | undefined { - if (segments.year.length !== YEAR_LENGTH || segments.month.length !== SEGMENT_LENGTH || segments.day.length !== SEGMENT_LENGTH) { + if (segments.year.length !== YEAR_LENGTH || !segments.month || !segments.day) { return undefined; } - const isoDate = `${segments.year}-${segments.month}-${segments.day}`; + const isoDate = `${segments.year}-${getSegmentDisplay(segments, 'month')}-${getSegmentDisplay(segments, 'day')}`; return isValid(parse(isoDate, CONST.DATE.FNS_FORMAT_STRING, new Date())) ? isoDate : undefined; } diff --git a/tests/unit/DateInputMaskUtilsTest.ts b/tests/unit/DateInputMaskUtilsTest.ts index 0014601659c9..f1ce46ae64e2 100644 --- a/tests/unit/DateInputMaskUtilsTest.ts +++ b/tests/unit/DateInputMaskUtilsTest.ts @@ -220,11 +220,26 @@ describe('DateInputMaskUtils', () => { expect(getISODateFromSegments(segments('2026', '09', '18'))).toBe('2026-09-18'); }); - it('returns undefined while a segment is unfinished', () => { - expect(getISODateFromSegments(segments('2026', '9', '18'))).toBeUndefined(); + it('reads a zero padded segment from one digit, since that is what it already shows', () => { + expect(getISODateFromSegments(segments('2026', '9', '18'))).toBe('2026-09-18'); + expect(getISODateFromSegments(segments('2026', '09', '3'))).toBe('2026-09-03'); + }); + + it('returns undefined while a segment is empty or the year is unfinished', () => { expect(getISODateFromSegments(segments('202', '09', '18'))).toBeUndefined(); + expect(getISODateFromSegments(segments('2026', '', '18'))).toBeUndefined(); + expect(getISODateFromSegments(segments('2026', '09', ''))).toBeUndefined(); expect(getISODateFromSegments(EMPTY)).toBeUndefined(); }); + + it('refuses a segment that cannot be a month or a day, which only a zero can be', () => { + expect(getISODateFromSegments(segments('2026', '0', '18'))).toBeUndefined(); + expect(getISODateFromSegments(segments('2026', '09', '0'))).toBeUndefined(); + }); + + it('refuses a day the typed month does not have', () => { + expect(getISODateFromSegments(segments('2026', '02', '31'))).toBeUndefined(); + }); }); describe('getSegmentsFromISODate', () => { From 6970cfffa4884494e9cbc6419015670d5a7b0cdb Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Sat, 19 Sep 2026 00:02:42 +0530 Subject: [PATCH 22/80] fix: drop a date segment keystroke that cannot complete a leading zero Signed-off-by: krishna2323 --- src/components/TextInput/BaseTextInput/types.ts | 2 +- src/hooks/useDateSegmentInput.ts | 2 +- src/hooks/useRemeasureOnScroll/types.ts | 1 - src/libs/DateInputMaskUtils.ts | 8 +++++++- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/components/TextInput/BaseTextInput/types.ts b/src/components/TextInput/BaseTextInput/types.ts index 5a618c50b0e2..a5fb79d5df48 100644 --- a/src/components/TextInput/BaseTextInput/types.ts +++ b/src/components/TextInput/BaseTextInput/types.ts @@ -212,4 +212,4 @@ type BaseTextInputRef = HTMLFormElement | AnimatedTextInputRef; type BaseTextInputProps = CustomBaseTextInputProps & TextInputProps; -export type {BaseTextInputProps, BaseTextInputRef, DateSegmentsConfig, InputType}; +export type {BaseTextInputProps, BaseTextInputRef, InputType}; diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index 059410605f47..76f3c3d2af1f 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -279,4 +279,4 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, }; } -export type {DateSegmentProps, SegmentFocusRequest, UseDateSegmentInputParams, UseDateSegmentInputResult}; +export type {UseDateSegmentInputParams, UseDateSegmentInputResult}; diff --git a/src/hooks/useRemeasureOnScroll/types.ts b/src/hooks/useRemeasureOnScroll/types.ts index cee7bc213303..10ab51f826d3 100644 --- a/src/hooks/useRemeasureOnScroll/types.ts +++ b/src/hooks/useRemeasureOnScroll/types.ts @@ -9,4 +9,3 @@ type UseRemeasureOnScrollParams = { type UseRemeasureOnScroll = (params: UseRemeasureOnScrollParams) => void; export default UseRemeasureOnScroll; -export type {UseRemeasureOnScrollParams}; diff --git a/src/libs/DateInputMaskUtils.ts b/src/libs/DateInputMaskUtils.ts index e9186beb0b0a..b3cefeac4dea 100644 --- a/src/libs/DateInputMaskUtils.ts +++ b/src/libs/DateInputMaskUtils.ts @@ -157,6 +157,12 @@ function typeDigitIntoOneSegment(name: DateSegmentName, typedSoFar: string, digi return {value: combined, shouldAdvance: true}; } + // Finishing the segment early only works while what it already holds can stand on its own. A leading zero + // cannot, so the keystroke is dropped and the segment keeps waiting for a digit that completes it. + if (Number(current) < FIRST_MONTH) { + return {value: current, shouldAdvance: false}; + } + return {value: current.padStart(SEGMENT_LENGTH, '0'), shouldAdvance: true, carry: digit}; } @@ -310,4 +316,4 @@ export { removeLastDigit, typeDigitIntoSegments, }; -export type {DateMaskPart, DateSegmentName, DateSegments}; +export type {DateSegmentName, DateSegments}; From c763339876066f7e8c5678cb6c1d6c6077edc235 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Sat, 19 Sep 2026 00:09:20 +0530 Subject: [PATCH 23/80] feat: keep the unreached mask letters on screen while a date is typed Signed-off-by: krishna2323 --- src/components/DateSegmentsInput.tsx | 88 ++++++++++++++++------------ 1 file changed, 51 insertions(+), 37 deletions(-) diff --git a/src/components/DateSegmentsInput.tsx b/src/components/DateSegmentsInput.tsx index 017044e772f3..f2c9412bc8e3 100644 --- a/src/components/DateSegmentsInput.tsx +++ b/src/components/DateSegmentsInput.tsx @@ -46,6 +46,9 @@ const SIZED_TO_CONTENT = {flexGrow: 0, flexShrink: 0, flexBasis: 'auto', width: /** Holds a copy of a segment's text purely to be measured, so it must not take part in the layout it is measuring */ const MEASURED_OFF_LAYOUT = {position: 'absolute', opacity: 0} as const; +/** Sits over the segment's own input, which is why the input can show the typed digits alone */ +const REMAINDER_OVERLAY = {position: 'absolute', left: 0, top: 0} as const; + function DateSegmentsInput({ dateSegmentsConfig, style, @@ -140,7 +143,10 @@ function DateSegmentsInput({ > {getDateMaskParts(mask).map((part) => { const segmentProps = getSegmentProps(part.name); - const measuredText = segmentProps.value || part.placeholder; + // The mask letters a half typed segment has not reached yet, which stay on screen in the placeholder + // color so the shape of the date is still readable while it is being filled in. + const remainder = part.placeholder.slice(segmentProps.value.length); + const measuredText = `${segmentProps.value}${remainder}`; const width = (measuredWidths[part.name] ?? measuredText.length * ESTIMATED_CHARACTER_WIDTH) + CARET_ALLOWANCE; return ( @@ -155,42 +161,50 @@ function DateSegmentsInput({ > {measuredText} - { - segmentRefs.current[part.name] = element; - - // The field's own ref has to lead somewhere, and the year is where typing starts - if (part.name !== 'year') { - return; - } - if (typeof ref === 'function') { - ref(element); - } else if (ref && 'current' in ref) { - // eslint-disable-next-line no-param-reassign - ref.current = element; - } - }} - style={[style, NO_HORIZONTAL_PADDING, SIZED_TO_CONTENT, {width}]} - value={segmentProps.value} - placeholder={part.placeholder} - placeholderTextColor={placeholderTextColor} - onKeyPress={segmentProps.onKeyPress} - onChangeText={segmentProps.onChangeText} - onFocus={(event) => { - clearTimeout(blurTimeoutRef.current); - segmentProps.onFocus(); - onFocus?.(event); - }} - onBlur={handleSegmentBlur} - onPressOut={onPressOut} - accessibilityLabel={translate(`common.dateSegments.${part.name}`)} - inputMode="numeric" - disabled={disabled} - readOnly={readOnly} - forwardedFSClass={forwardedFSClass} - aria-describedby={ariaDescribedBy} - aria-invalid={ariaInvalid} - /> + + { + segmentRefs.current[part.name] = element; + + // The field's own ref has to lead somewhere, and the year is where typing starts + if (part.name !== 'year') { + return; + } + if (typeof ref === 'function') { + ref(element); + } else if (ref && 'current' in ref) { + // eslint-disable-next-line no-param-reassign + ref.current = element; + } + }} + style={[style, NO_HORIZONTAL_PADDING, styles.w100]} + value={segmentProps.value} + onKeyPress={segmentProps.onKeyPress} + onChangeText={segmentProps.onChangeText} + onFocus={(event) => { + clearTimeout(blurTimeoutRef.current); + segmentProps.onFocus(); + onFocus?.(event); + }} + onBlur={handleSegmentBlur} + onPressOut={onPressOut} + accessibilityLabel={translate(`common.dateSegments.${part.name}`)} + inputMode="numeric" + disabled={disabled} + readOnly={readOnly} + forwardedFSClass={forwardedFSClass} + aria-describedby={ariaDescribedBy} + aria-invalid={ariaInvalid} + /> + {/* The digits already typed are repeated invisibly so the text flow puts the remaining mask + letters exactly where the input's own text ends, without measuring anything. */} + {!!remainder && ( + + {segmentProps.value} + {remainder} + + )} + {!!part.separator && {part.separator}} ); From ef0e09adb7a77b72a8af726f57f0b6e331702258 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 22 Sep 2026 02:34:10 +0530 Subject: [PATCH 24/80] fix: keep the date field's empty space from taking focus off its segments Signed-off-by: krishna2323 --- src/components/DateSegmentsInput.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/DateSegmentsInput.tsx b/src/components/DateSegmentsInput.tsx index f2c9412bc8e3..90b1f39c0723 100644 --- a/src/components/DateSegmentsInput.tsx +++ b/src/components/DateSegmentsInput.tsx @@ -214,6 +214,10 @@ function DateSegmentsInput({ event.preventDefault()} onPress={focusFirstUnfilledSegment} sentryLabel="DateSegmentsInput-EmptySpace" style={[styles.flex1, styles.cursorText]} From 32108c3fe699e5bc34d5467d2a50fdac80db689e Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 22 Sep 2026 02:50:24 +0530 Subject: [PATCH 25/80] fix: block submission when the date field is left holding an unusable entry Signed-off-by: krishna2323 --- src/components/DateSegmentsInput.tsx | 3 -- src/hooks/useDateSegmentInput.ts | 41 +++++++++++++++++++++++----- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/components/DateSegmentsInput.tsx b/src/components/DateSegmentsInput.tsx index 90b1f39c0723..881210667a15 100644 --- a/src/components/DateSegmentsInput.tsx +++ b/src/components/DateSegmentsInput.tsx @@ -214,9 +214,6 @@ function DateSegmentsInput({ event.preventDefault()} onPress={focusFirstUnfilledSegment} sentryLabel="DateSegmentsInput-EmptySpace" diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index 76f3c3d2af1f..14aaa34ae2e4 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -47,7 +47,7 @@ type UseDateSegmentInputParams = { /** The newest date the calendar can show */ maxDate: Date; - /** Called with a stored format date once every segment holds a valid value */ + /** Called with a stored format date once every segment holds a valid value, or with an empty string once the field is left holding digits that cannot produce one */ onCommit: (isoDate: string) => void; }; @@ -105,6 +105,9 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, // Whether the next digit replaces the segment instead of extending it, set on arriving at a segment const [shouldOverwrite, setShouldOverwrite] = useState(false); const [appliedValue, setAppliedValue] = useState(value); + // Whether the field was left holding digits that do not add up to a date, which stay on screen so the user can see + // what still has to be corrected + const [hasInvalidEntry, setHasInvalidEntry] = useState(false); // A date set from outside, by the calendar or by a restored draft, has to reach the segments as well. Without this // an edit in progress would keep showing the date it started from, since the segments are what the field renders. @@ -116,6 +119,12 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, if (isEditing && value !== getISODateFromSegments(segments)) { setSegments(getSegmentsFromISODate(value)); } + + // A date picked from the calendar settles what the field holds, so there is no longer an entry to correct. An + // empty value is the field reporting its own unusable entry, which is what put those digits on screen. + if (value) { + setHasInvalidEntry(false); + } } const requestFocus = (name: DateSegmentName) => { @@ -231,19 +240,35 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, return; } - const seededSegments = getSegmentsFromISODate(value); + // Returning to an entry that was left unusable resumes it, since the date it reported was the empty one + const seededSegments = hasInvalidEntry ? segments : getSegmentsFromISODate(value); + setHasInvalidEntry(false); setSegments(seededSegments); assertViewDate(getViewDateFromSegments(seededSegments, new Date().getMonth(), minDate, maxDate)); setIsEditing(true); }; - // An unfinished edit is dropped rather than cleared, so leaving the field restores the last committed date + /** + * Leaving the field settles what it holds. An entry that cannot produce a date reports the date as unset and stays + * on screen, so a form blocks on it rather than submitting whatever was there before the edit began. + * + * Only an entry the user has changed counts. Passing through a field without touching it leaves its date alone. + */ const handleFieldBlur = () => { setIsEditing(false); - setSegments(EMPTY_SEGMENTS); setViewDate(undefined); setFocusRequest(undefined); setShouldOverwrite(false); + + const hasUnusableEntry = !getISODateFromSegments(segments) && (hasAnySegment(segments) || !!value); + setHasInvalidEntry(hasUnusableEntry); + + if (hasUnusableEntry) { + onCommit(''); + return; + } + + setSegments(EMPTY_SEGMENTS); }; if (!isEnabled) { @@ -259,8 +284,10 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, }; } - // The segments describe an edit in progress, so outside of one the committed date is what the field has to show - const displayedSegments = isEditing ? segments : getSegmentsFromISODate(value); + // The segments describe an edit in progress or one left unusable, so outside of those the committed date is what + // the field has to show + const shouldShowSegments = isEditing || hasInvalidEntry; + const displayedSegments = shouldShowSegments ? segments : getSegmentsFromISODate(value); return { displayValue: value, @@ -268,7 +295,7 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, focusRequest, viewDate: isEditing ? viewDate : undefined, viewDateVersion, - hasTypedDigits: isEditing && hasAnySegment(segments), + hasTypedDigits: shouldShowSegments && hasAnySegment(segments), getSegmentProps: (name: DateSegmentName) => ({ value: getSegmentDisplay(displayedSegments, name), onKeyPress: (event: TextInputKeyPressEvent) => handleKeyPress(name, event), From 44b6350dbb85ff431c5750ca9029c5c5ac6a351a Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 22 Sep 2026 02:56:01 +0530 Subject: [PATCH 26/80] fix: return the date field to its first segment when it is cleared Signed-off-by: krishna2323 --- src/components/DatePicker/index.tsx | 1 + src/hooks/useDateSegmentInput.ts | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index 4605d06d9dac..83d63ef6090a 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -241,6 +241,7 @@ function DatePicker({ onTouched?.(); onInputChange?.(''); setSelectedDate(''); + segmentInput.onClear(); }; useEffect(() => { diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index 14aaa34ae2e4..66d844e97631 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -25,6 +25,7 @@ import type {TextInputKeyPressEvent} from 'react-native'; import {useState} from 'react'; +const FIRST_SEGMENT_NAME = DATE_SEGMENT_NAMES[0]; const LAST_SEGMENT_NAME = DATE_SEGMENT_NAMES[DATE_SEGMENT_NAMES.length - 1]; const BACKSPACE_KEY = 'Backspace'; @@ -88,6 +89,9 @@ type UseDateSegmentInputResult = { /** Called once focus has left the field altogether rather than moved between segments */ onFieldBlur: () => void; + + /** Called when the field's clear button empties it, so the segments start again rather than being left behind */ + onClear: () => void; }; function isMoveKey(key: string): key is keyof typeof MOVE_KEYS { @@ -271,6 +275,17 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, setSegments(EMPTY_SEGMENTS); }; + /** + * Emptying the field puts the user back at its first segment. The clear button unmounts as it is pressed, so the + * press lands on whatever is underneath, and leaving it to decide where focus goes makes that a race. + */ + const handleClear = () => { + setSegments(EMPTY_SEGMENTS); + setHasInvalidEntry(false); + setViewDate(undefined); + enterSegment(FIRST_SEGMENT_NAME); + }; + if (!isEnabled) { return { displayValue: value, @@ -281,6 +296,7 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, hasTypedDigits: false, getSegmentProps: () => ({value: '', onKeyPress: () => {}, onChangeText: () => {}, onFocus: () => {}}), onFieldBlur: () => {}, + onClear: () => {}, }; } @@ -303,6 +319,7 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, onFocus: handleSegmentFocus, }), onFieldBlur: handleFieldBlur, + onClear: handleClear, }; } From 2588a05bbafe81544bed7aa6e6e4c727247ac086 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 22 Sep 2026 03:09:02 +0530 Subject: [PATCH 27/80] fix: report a date field's value as it is typed rather than when it is left Signed-off-by: krishna2323 --- src/components/DatePicker/index.tsx | 8 ++++++- src/hooks/useDateSegmentInput.ts | 35 +++++++++++++---------------- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index 83d63ef6090a..62790da28ad4 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -80,7 +80,13 @@ function DatePicker({ // Updates the field without ending the selection, so the calendar stays open for whatever the user does next const commitDate = (newDate: string) => { setSelectedDate(newDate); - onTouched?.(); + + // A date being typed reads as empty until it is finished. Marking the field touched then would show a + // required error over a date the user is part way through. + if (newDate) { + onTouched?.(); + } + onInputChange?.(newDate); }; diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index 66d844e97631..ba906043a660 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -48,7 +48,7 @@ type UseDateSegmentInputParams = { /** The newest date the calendar can show */ maxDate: Date; - /** Called with a stored format date once every segment holds a valid value, or with an empty string once the field is left holding digits that cannot produce one */ + /** Called with a stored format date whenever the segments produce one, and with an empty string whenever they do not */ onCommit: (isoDate: string) => void; }; @@ -120,7 +120,7 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, // A date this field has just committed already agrees with the segments, and seeding from it would replace a // half typed segment with the padded form it is showing. The next digit would then start the segment over. - if (isEditing && value !== getISODateFromSegments(segments)) { + if (isEditing && value !== (getISODateFromSegments(segments) ?? '')) { setSegments(getSegmentsFromISODate(value)); } @@ -141,13 +141,13 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, requestFocus(name); }; - const commitIfComplete = (newSegments: DateSegments) => { - const isoDate = getISODateFromSegments(newSegments); - if (!isoDate) { - return; - } - - onCommit(isoDate); + /** + * Reports what the segments now amount to, on every keystroke rather than once the date is finished. A form reads + * its value when it is submitted, which can happen before the field has even been left, so an entry part way + * through has to read as no date at all instead of leaving the date from before the edit in place. + */ + const commitSegments = (newSegments: DateSegments) => { + onCommit(getISODateFromSegments(newSegments) ?? ''); }; /** @@ -170,7 +170,7 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, const applySegments = (newSegments: DateSegments) => { setSegments(newSegments); assertViewDate(getViewDateFromSegments(newSegments, (viewDate ?? new Date()).getMonth(), minDate, maxDate)); - commitIfComplete(newSegments); + commitSegments(newSegments); }; const handleKeyPress = (name: DateSegmentName, event: TextInputKeyPressEvent) => { @@ -253,10 +253,8 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, }; /** - * Leaving the field settles what it holds. An entry that cannot produce a date reports the date as unset and stays - * on screen, so a form blocks on it rather than submitting whatever was there before the edit began. - * - * Only an entry the user has changed counts. Passing through a field without touching it leaves its date alone. + * Digits that do not add up to a date stay on screen once the field is left, so the user can see what still has to + * be corrected. The date they report was already emptied as they were typed. */ const handleFieldBlur = () => { setIsEditing(false); @@ -264,15 +262,12 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, setFocusRequest(undefined); setShouldOverwrite(false); - const hasUnusableEntry = !getISODateFromSegments(segments) && (hasAnySegment(segments) || !!value); + const hasUnusableEntry = hasAnySegment(segments) && !getISODateFromSegments(segments); setHasInvalidEntry(hasUnusableEntry); - if (hasUnusableEntry) { - onCommit(''); - return; + if (!hasUnusableEntry) { + setSegments(EMPTY_SEGMENTS); } - - setSegments(EMPTY_SEGMENTS); }; /** From acadecd21d97b808d0f393f2d963ecb62354ae22 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 23 Sep 2026 16:07:06 +0530 Subject: [PATCH 28/80] fix: address the design review on the typed date field Signed-off-by: krishna2323 --- src/components/DatePicker/index.tsx | 1 + src/components/DateSegmentsInput.tsx | 180 +++++++++++------- .../TextInput/BaseTextInput/types.ts | 1 + src/hooks/useDateSegmentInput.ts | 43 ++++- src/hooks/useRemeasureOnScroll/index.ts | 3 +- src/styles/theme/themes/dark.ts | 1 + src/styles/theme/themes/light.ts | 1 + src/styles/theme/types.ts | 1 + src/types/modules/react-native.d.ts | 6 + 9 files changed, 168 insertions(+), 69 deletions(-) diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index 50ece55905d5..772782e1628c 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -307,6 +307,7 @@ function DatePicker({ mask: dateMask, getSegmentProps: segmentInput.getSegmentProps, focusRequest: segmentInput.focusRequest, + isAllSelected: segmentInput.isAllSelected, onFieldBlur: segmentInput.onFieldBlur, } : undefined diff --git a/src/components/DateSegmentsInput.tsx b/src/components/DateSegmentsInput.tsx index 881210667a15..89d4618cfafc 100644 --- a/src/components/DateSegmentsInput.tsx +++ b/src/components/DateSegmentsInput.tsx @@ -7,6 +7,7 @@ * nothing here has to reason about where a caret sits inside a longer string. */ import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {getDateMaskParts} from '@libs/DateInputMaskUtils'; @@ -14,8 +15,10 @@ import type {DateSegmentName} from '@libs/DateInputMaskUtils'; import CONST from '@src/CONST'; +import type {ViewStyle} from 'react-native'; + import React, {useEffect, useRef, useState} from 'react'; -import {View} from 'react-native'; +import {StyleSheet, View} from 'react-native'; import type {AnimatedTextInputRef} from './RNTextInput'; import type {BaseTextInputProps} from './TextInput/BaseTextInput/types'; @@ -43,9 +46,28 @@ const ESTIMATED_CHARACTER_WIDTH = CONST.CHARACTER_WIDTH; */ const SIZED_TO_CONTENT = {flexGrow: 0, flexShrink: 0, flexBasis: 'auto', width: 'auto'} as const; +/** + * The field hands its input `flex: 1`, whose basis of zero applies down the main axis. A column would therefore take + * the segment's height away from it and leave the row to be sized by the separators alone. + */ +const SEGMENT_MAIN_AXIS = {flexDirection: 'row'} as const; + /** Holds a copy of a segment's text purely to be measured, so it must not take part in the layout it is measuring */ const MEASURED_OFF_LAYOUT = {position: 'absolute', opacity: 0} as const; +/** + * The selection sits behind the date rather than around it, so that drawing it cannot change where anything ends up. + * It is the first child so the segments, which are positioned too, paint over it. + */ +const SELECTION_LAYER = {position: 'absolute', left: 0, right: 0, top: 0, bottom: 0} as const; + +/** Reads back the padding the field hands its text, which is what a selection has to leave alone to cover the line */ +function getVerticalPadding(inputStyle: BaseTextInputProps['style']): ViewStyle { + const {paddingTop, paddingBottom, paddingVertical} = StyleSheet.flatten(inputStyle) ?? {}; + + return {paddingTop, paddingBottom, paddingVertical}; +} + /** Sits over the segment's own input, which is why the input can show the typed digits alone */ const REMAINDER_OVERLAY = {position: 'absolute', left: 0, top: 0} as const; @@ -64,6 +86,7 @@ function DateSegmentsInput({ 'aria-invalid': ariaInvalid, ref, }: BaseTextInputProps) { + const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); const segmentRefs = useRef>>({}); @@ -104,7 +127,7 @@ function DateSegmentsInput({ return null; } - const {mask, getSegmentProps, onFieldBlur} = dateSegmentsConfig; + const {mask, getSegmentProps, isAllSelected, onFieldBlur} = dateSegmentsConfig; /** * Where a press on the field itself lands. The segments are read from the inputs rather than from this render, so @@ -133,6 +156,11 @@ function DateSegmentsInput({ }, 0); }; + const parts = getDateMaskParts(mask).map((part) => ({...part, segmentProps: getSegmentProps(part.name)})); + // The separators belong to the mask rather than to the date, so they read as placeholder text for as long as any + // of the mask is still on screen + const isAnyMaskShowing = parts.some((part) => part.segmentProps.value.length < part.placeholder.length); + // The segments stretch down the row as a single input would, so the padding they inherit lands their text in the // same place as any other text field's return ( @@ -141,74 +169,94 @@ function DateSegmentsInput({ role="group" accessibilityLabel={accessibilityLabel} > - {getDateMaskParts(mask).map((part) => { - const segmentProps = getSegmentProps(part.name); - // The mask letters a half typed segment has not reached yet, which stay on screen in the placeholder - // color so the shape of the date is still readable while it is being filled in. - const remainder = part.placeholder.slice(segmentProps.value.length); - const measuredText = `${segmentProps.value}${remainder}`; - const width = (measuredWidths[part.name] ?? measuredText.length * ESTIMATED_CHARACTER_WIDTH) + CARET_ALLOWANCE; - - return ( - - { - const layoutWidth = event.nativeEvent.layout.width; - - setMeasuredWidths((previous) => (previous[part.name] === layoutWidth ? previous : {...previous, [part.name]: layoutWidth})); - }} - > - {measuredText} - - - { - segmentRefs.current[part.name] = element; - - // The field's own ref has to lead somewhere, and the year is where typing starts - if (part.name !== 'year') { - return; - } - if (typeof ref === 'function') { - ref(element); - } else if (ref && 'current' in ref) { - // eslint-disable-next-line no-param-reassign - ref.current = element; - } - }} - style={[style, NO_HORIZONTAL_PADDING, styles.w100]} - value={segmentProps.value} - onKeyPress={segmentProps.onKeyPress} - onChangeText={segmentProps.onChangeText} - onFocus={(event) => { - clearTimeout(blurTimeoutRef.current); - segmentProps.onFocus(); - onFocus?.(event); + {/* Sized to the date rather than to the row, so selecting it covers the digits and not the space after them */} + + {/* The field's vertical padding is what sits its text below the label, so taking that same padding + leaves exactly the line of text to fill and the selection needs no measuring to line up with it. */} + {isAllSelected && ( + + + + )} + {parts.map((part) => { + const segmentProps = part.segmentProps; + // The mask letters a half typed segment has not reached yet, which stay on screen in the placeholder + // color so the shape of the date is still readable while it is being filled in. + const remainder = part.placeholder.slice(segmentProps.value.length); + const measuredText = `${segmentProps.value}${remainder}`; + const width = (measuredWidths[part.name] ?? measuredText.length * ESTIMATED_CHARACTER_WIDTH) + CARET_ALLOWANCE; + + return ( + + { + const layoutWidth = event.nativeEvent.layout.width; + + setMeasuredWidths((previous) => (previous[part.name] === layoutWidth ? previous : {...previous, [part.name]: layoutWidth})); }} - onBlur={handleSegmentBlur} - onPressOut={onPressOut} - accessibilityLabel={translate(`common.dateSegments.${part.name}`)} - inputMode="numeric" - disabled={disabled} - readOnly={readOnly} - forwardedFSClass={forwardedFSClass} - aria-describedby={ariaDescribedBy} - aria-invalid={ariaInvalid} - /> - {/* The digits already typed are repeated invisibly so the text flow puts the remaining mask + > + {measuredText} + + + { + segmentRefs.current[part.name] = element; + + // The field's own ref has to lead somewhere, and the year is where typing starts + if (part.name !== 'year') { + return; + } + if (typeof ref === 'function') { + ref(element); + } else if (ref && 'current' in ref) { + // eslint-disable-next-line no-param-reassign + ref.current = element; + } + }} + style={[style, NO_HORIZONTAL_PADDING, styles.w100]} + value={segmentProps.value} + onKeyPress={segmentProps.onKeyPress} + onChangeText={segmentProps.onChangeText} + onFocus={(event) => { + clearTimeout(blurTimeoutRef.current); + segmentProps.onFocus(); + onFocus?.(event); + }} + onBlur={handleSegmentBlur} + onPressOut={(event) => { + segmentProps.onPressOut(); + onPressOut?.(event); + }} + accessibilityLabel={translate(`common.dateSegments.${part.name}`)} + // A caret blinking inside a selected date would read as a place the next digit + // goes, when it is the whole date that is about to be replaced + caretHidden={isAllSelected} + inputMode="numeric" + disabled={disabled} + readOnly={readOnly} + forwardedFSClass={forwardedFSClass} + aria-describedby={ariaDescribedBy} + aria-invalid={ariaInvalid} + /> + {/* The digits already typed are repeated invisibly so the text flow puts the remaining mask letters exactly where the input's own text ends, without measuring anything. */} - {!!remainder && ( - - {segmentProps.value} - {remainder} + {!!remainder && ( + + {segmentProps.value} + {remainder} + + )} + + {!!part.separator && ( + + {part.separator} )} - - {!!part.separator && {part.separator}} - - ); - })} + + ); + })} + {/* A single input filled the row, so clicking anywhere in the field focused it. This takes the space left over after the day, so pressing it still lands somewhere useful rather than doing nothing. */} void; onChangeText: (text: string) => void; onFocus: () => void; + + /** Puts the caret back where the user pressed, which is what collapses a whole field selection */ + onPressOut: () => void; }; type UseDateSegmentInputResult = { @@ -85,6 +89,9 @@ type UseDateSegmentInputResult = { /** Whether any digit has been typed, so the field is showing more than an untouched mask */ hasTypedDigits: boolean; + /** Whether the whole date is selected, so the field draws it as one selection and the next keystroke replaces it */ + isAllSelected: boolean; + getSegmentProps: (name: DateSegmentName) => DateSegmentProps; /** Called once focus has left the field altogether rather than moved between segments */ @@ -112,6 +119,8 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, // Whether the field was left holding digits that do not add up to a date, which stay on screen so the user can see // what still has to be corrected const [hasInvalidEntry, setHasInvalidEntry] = useState(false); + // Native selection cannot span separate inputs, so selecting the whole date is tracked here and drawn by the field + const [isAllSelected, setIsAllSelected] = useState(false); // A date set from outside, by the calendar or by a restored draft, has to reach the segments as well. Without this // an edit in progress would keep showing the date it started from, since the segments are what the field renders. @@ -176,9 +185,25 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, const handleKeyPress = (name: DateSegmentName, event: TextInputKeyPressEvent) => { const key = event.nativeEvent.key; + // A shortcut is the browser's to handle, apart from the one that selects a field the browser cannot select + if (event.nativeEvent.metaKey || event.nativeEvent.ctrlKey) { + if (key.toLowerCase() !== SELECT_ALL_KEY || !hasAnySegment(segments)) { + return; + } + + event.preventDefault(); + setIsAllSelected(true); + // Selecting the date puts the caret at its start, so replacing it does not have to move focus first + enterSegment(FIRST_SEGMENT_NAME); + return; + } + + const wasAllSelected = isAllSelected; + setIsAllSelected(false); + if (isNumeric(key)) { event.preventDefault(); - const result = typeDigitIntoSegments(segments, name, key, shouldOverwrite); + const result = typeDigitIntoSegments(wasAllSelected ? EMPTY_SEGMENTS : segments, name, key, shouldOverwrite); setShouldOverwrite(false); applySegments(result.segments); @@ -196,6 +221,14 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, if (key === BACKSPACE_KEY || key === DELETE_KEY) { event.preventDefault(); + + // Deleting the whole date leaves the user at the start of the empty one, ready to type it again + if (wasAllSelected) { + applySegments(EMPTY_SEGMENTS); + enterSegment(FIRST_SEGMENT_NAME); + return; + } + const trimmedSegments = removeLastDigit(segments, name); // An empty segment has nothing to delete, so the keystroke falls back to leaving it @@ -229,6 +262,7 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, return; } + setIsAllSelected(false); applySegments(pastedSegments); enterSegment(LAST_SEGMENT_NAME); }; @@ -261,6 +295,7 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, setViewDate(undefined); setFocusRequest(undefined); setShouldOverwrite(false); + setIsAllSelected(false); const hasUnusableEntry = hasAnySegment(segments) && !getISODateFromSegments(segments); setHasInvalidEntry(hasUnusableEntry); @@ -277,6 +312,7 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, const handleClear = () => { setSegments(EMPTY_SEGMENTS); setHasInvalidEntry(false); + setIsAllSelected(false); setViewDate(undefined); enterSegment(FIRST_SEGMENT_NAME); }; @@ -289,7 +325,8 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, viewDate: undefined, viewDateVersion: 0, hasTypedDigits: false, - getSegmentProps: () => ({value: '', onKeyPress: () => {}, onChangeText: () => {}, onFocus: () => {}}), + isAllSelected: false, + getSegmentProps: () => ({value: '', onKeyPress: () => {}, onChangeText: () => {}, onFocus: () => {}, onPressOut: () => {}}), onFieldBlur: () => {}, onClear: () => {}, }; @@ -307,11 +344,13 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, viewDate: isEditing ? viewDate : undefined, viewDateVersion, hasTypedDigits: shouldShowSegments && hasAnySegment(segments), + isAllSelected, getSegmentProps: (name: DateSegmentName) => ({ value: getSegmentDisplay(displayedSegments, name), onKeyPress: (event: TextInputKeyPressEvent) => handleKeyPress(name, event), onChangeText: handleChangeText, onFocus: handleSegmentFocus, + onPressOut: () => setIsAllSelected(false), }), onFieldBlur: handleFieldBlur, onClear: handleClear, diff --git a/src/hooks/useRemeasureOnScroll/index.ts b/src/hooks/useRemeasureOnScroll/index.ts index 348bc122faf2..132fb30569db 100644 --- a/src/hooks/useRemeasureOnScroll/index.ts +++ b/src/hooks/useRemeasureOnScroll/index.ts @@ -16,7 +16,8 @@ const useRemeasureOnScroll: UseRemeasureOnScroll = ({isActive, remeasure}) => { return; } - const handleScroll = throttle(remeasure, CONST.TIMING.MIN_SMOOTH_SCROLL_EVENT_THROTTLE); + // Listening hands the scroll event to whatever it calls, and a remeasure takes an argument of its own + const handleScroll = throttle(() => remeasure(), CONST.TIMING.MIN_SMOOTH_SCROLL_EVENT_THROTTLE); document.addEventListener('scroll', handleScroll, true); return () => { diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts index 658c23a66748..0af57a5e33fe 100644 --- a/src/styles/theme/themes/dark.ts +++ b/src/styles/theme/themes/dark.ts @@ -63,6 +63,7 @@ const darkTheme = { textDark: colors.productDark100, textReversed: colors.productLight900, textBackground: colors.productDark200, + textSelectionBackground: colors.blue700, textMutedReversed: colors.productDark700, textError: colors.red, offline: colors.productDark700, diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts index dfdd66540d9b..f70745d22cc4 100644 --- a/src/styles/theme/themes/light.ts +++ b/src/styles/theme/themes/light.ts @@ -63,6 +63,7 @@ const lightTheme = { textDark: colors.productLight900, textReversed: colors.productDark900, textBackground: colors.productLight200, + textSelectionBackground: colors.blue100, textMutedReversed: colors.productLight700, textError: colors.red, offline: colors.productLight700, diff --git a/src/styles/theme/types.ts b/src/styles/theme/types.ts index d1a4f83d901a..c8f4769ad07c 100644 --- a/src/styles/theme/types.ts +++ b/src/styles/theme/types.ts @@ -70,6 +70,7 @@ type ThemeColors = { textDark: Color; textReversed: Color; textBackground: Color; + textSelectionBackground: Color; textMutedReversed: Color; textError: Color; offline: Color; diff --git a/src/types/modules/react-native.d.ts b/src/types/modules/react-native.d.ts index e43d30a8631e..9ce2d5d0b075 100644 --- a/src/types/modules/react-native.d.ts +++ b/src/types/modules/react-native.d.ts @@ -46,6 +46,12 @@ declare module 'react-native' { }; } + /** On web this event is raised from a key down, so it carries the modifiers the browser reports with the key */ + interface TextInputKeyPressEventData { + metaKey?: boolean; + ctrlKey?: boolean; + } + interface PressableStateCallbackType extends WebPressableStateCallbackType { readonly isScreenReaderActive: boolean; readonly isDisabled: boolean; From 6b3221629261f928ceacbf7d972c95b3cfba5233 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 24 Sep 2026 08:42:26 +0530 Subject: [PATCH 29/80] fix: limit month and year commits to typing, and hide decorative date text from screen readers Signed-off-by: krishna2323 --- src/components/DatePicker/index.tsx | 2 +- src/components/DateSegmentsInput.tsx | 28 ++++++++++++++++++++++------ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index 772782e1628c..2ca572c3d735 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -350,7 +350,7 @@ function DatePicker({ shouldCloseOnWheel={!shouldAllowTyping} viewDate={segmentInput.viewDate} viewDateVersion={segmentInput.viewDateVersion} - onMonthOrYearSelected={commitDate} + onMonthOrYearSelected={shouldAllowTyping ? commitDate : undefined} /> ); diff --git a/src/components/DateSegmentsInput.tsx b/src/components/DateSegmentsInput.tsx index 89d4618cfafc..0b45b67fcdaf 100644 --- a/src/components/DateSegmentsInput.tsx +++ b/src/components/DateSegmentsInput.tsx @@ -52,9 +52,6 @@ const SIZED_TO_CONTENT = {flexGrow: 0, flexShrink: 0, flexBasis: 'auto', width: */ const SEGMENT_MAIN_AXIS = {flexDirection: 'row'} as const; -/** Holds a copy of a segment's text purely to be measured, so it must not take part in the layout it is measuring */ -const MEASURED_OFF_LAYOUT = {position: 'absolute', opacity: 0} as const; - /** * The selection sits behind the date rather than around it, so that drawing it cannot change where anything ends up. * It is the first child so the segments, which are positioned too, paint over it. @@ -189,9 +186,16 @@ function DateSegmentsInput({ return ( { const layoutWidth = event.nativeEvent.layout.width; + if (!layoutWidth) { + return; + } setMeasuredWidths((previous) => (previous[part.name] === layoutWidth ? previous : {...previous, [part.name]: layoutWidth})); }} @@ -242,14 +246,26 @@ function DateSegmentsInput({ {/* The digits already typed are repeated invisibly so the text flow puts the remaining mask letters exactly where the input's own text ends, without measuring anything. */} {!!remainder && ( - + {segmentProps.value} {remainder} )} {!!part.separator && ( - + {part.separator} )} From c09056e0e97cb799d568bd02bba9f516b95c0a8b Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 24 Sep 2026 08:42:55 +0530 Subject: [PATCH 30/80] refactor: follow existing patterns in the typed date field Signed-off-by: krishna2323 --- src/components/DatePicker/index.tsx | 9 +- src/components/DateSegmentsInput.tsx | 131 ++---- .../TextInput/BaseTextInput/types.ts | 4 +- src/hooks/useDateSegmentInput.ts | 55 ++- src/libs/Debug101504.ts | 43 ++ src/libs/Debug95430.ts | 407 ++++++++++++++++++ src/libs/Debug96732.ts | 372 ++++++++++++++++ .../Write merchant-rules-test.csv (po4841) | 10 + .../isTypedDateInputSupported/index.native.ts | 5 - src/libs/isTypedDateInputSupported/index.ts | 11 - src/libs/isTypedDateInputSupported/types.ts | 3 - 11 files changed, 911 insertions(+), 139 deletions(-) create mode 100644 src/libs/Debug101504.ts create mode 100644 src/libs/Debug95430.ts create mode 100644 src/libs/Debug96732.ts create mode 100644 src/libs/Write merchant-rules-test.csv (po4841) delete mode 100644 src/libs/isTypedDateInputSupported/index.native.ts delete mode 100644 src/libs/isTypedDateInputSupported/index.ts delete mode 100644 src/libs/isTypedDateInputSupported/types.ts diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index 2ca572c3d735..d78e9cb5a6da 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -11,7 +11,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import ComposerFocusManager from '@libs/ComposerFocusManager'; -import isTypedDateInputSupported from '@libs/isTypedDateInputSupported'; +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import {isNumeric} from '@libs/ValidationUtils'; import {setDraftValues} from '@userActions/FormActions'; @@ -73,7 +73,8 @@ function DatePicker({ // picker was dismissed before it resolved. const openIntentRef = useRef(false); - const shouldAllowTyping = isTypedDateInputSupported(); + // Touch devices keep the calendar on its own, so the soft keyboard does not cover it + const shouldAllowTyping = !canUseTouchScreen(); const dateMask = translate('common.dateFormat'); // Updates the field without ending the selection, so the calendar stays open for whatever the user does next @@ -306,7 +307,9 @@ function DatePicker({ ? { mask: dateMask, getSegmentProps: segmentInput.getSegmentProps, - focusRequest: segmentInput.focusRequest, + setSegmentRef: segmentInput.setSegmentRef, + focusSegment: segmentInput.focusSegment, + isSegmentElement: segmentInput.isSegmentElement, isAllSelected: segmentInput.isAllSelected, onFieldBlur: segmentInput.onFieldBlur, } diff --git a/src/components/DateSegmentsInput.tsx b/src/components/DateSegmentsInput.tsx index 0b45b67fcdaf..51b26de55346 100644 --- a/src/components/DateSegmentsInput.tsx +++ b/src/components/DateSegmentsInput.tsx @@ -7,17 +7,19 @@ * nothing here has to reason about where a caret sits inside a longer string. */ import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {getDateMaskParts} from '@libs/DateInputMaskUtils'; import type {DateSegmentName} from '@libs/DateInputMaskUtils'; +import mergeRefs from '@libs/mergeRefs'; import CONST from '@src/CONST'; import type {ViewStyle} from 'react-native'; -import React, {useEffect, useRef, useState} from 'react'; +import React, {useState} from 'react'; import {StyleSheet, View} from 'react-native'; import type {AnimatedTextInputRef} from './RNTextInput'; @@ -30,34 +32,9 @@ import Text from './Text'; /** Leaves the caret somewhere to sit, since a segment sized to its text exactly would clip it at the end */ const CARET_ALLOWANCE = 1; -/** - * Only the horizontal padding is dropped. The vertical padding that the field hands down is what sits the text below - * the floating label, so the segments keep it and stay in line with any other text field. - */ -const NO_HORIZONTAL_PADDING = {paddingHorizontal: 0} as const; - /** Used until the real text has been measured, so the first paint is close rather than collapsed */ const ESTIMATED_CHARACTER_WIDTH = CONST.CHARACTER_WIDTH; -/** - * The style the field hands down stretches its input across the whole row, which would spread the segments evenly and - * make each separator as wide as the field. Everything here is sized to its own text instead, so the date reads as one - * run of text. - */ -const SIZED_TO_CONTENT = {flexGrow: 0, flexShrink: 0, flexBasis: 'auto', width: 'auto'} as const; - -/** - * The field hands its input `flex: 1`, whose basis of zero applies down the main axis. A column would therefore take - * the segment's height away from it and leave the row to be sized by the separators alone. - */ -const SEGMENT_MAIN_AXIS = {flexDirection: 'row'} as const; - -/** - * The selection sits behind the date rather than around it, so that drawing it cannot change where anything ends up. - * It is the first child so the segments, which are positioned too, paint over it. - */ -const SELECTION_LAYER = {position: 'absolute', left: 0, right: 0, top: 0, bottom: 0} as const; - /** Reads back the padding the field hands its text, which is what a selection has to leave alone to cover the line */ function getVerticalPadding(inputStyle: BaseTextInputProps['style']): ViewStyle { const {paddingTop, paddingBottom, paddingVertical} = StyleSheet.flatten(inputStyle) ?? {}; @@ -65,9 +42,6 @@ function getVerticalPadding(inputStyle: BaseTextInputProps['style']): ViewStyle return {paddingTop, paddingBottom, paddingVertical}; } -/** Sits over the segment's own input, which is why the input can show the typed digits alone */ -const REMAINDER_OVERLAY = {position: 'absolute', left: 0, top: 0} as const; - function DateSegmentsInput({ dateSegmentsConfig, style, @@ -85,54 +59,22 @@ function DateSegmentsInput({ }: BaseTextInputProps) { const theme = useTheme(); const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); - const segmentRefs = useRef>>({}); - // Focus moves between segments as one blur followed by one focus, so leaving the field can only be told apart from - // moving within it once the next focus has had its chance to arrive. - const blurTimeoutRef = useRef(undefined); - const appliedFocusVersionRef = useRef(0); // The mask letters are wider than the digits that replace them, so a fixed width would either clip the placeholder // or leave a gap once the segment is filled in. Each one is measured from the text it is currently showing. const [measuredWidths, setMeasuredWidths] = useState>>({}); - const focusRequest = dateSegmentsConfig?.focusRequest; - - /** - * Arriving at a segment rests the caret after the digits already in it, rather than wherever the browser last left - * the caret there, so a half typed month reads as 02 and not 0 followed by a caret and a 2. - */ - const focusSegment = (name: DateSegmentName) => { - const element = segmentRefs.current[name]; - element?.focus(); - - const caretPosition = element?.value?.length ?? 0; - element?.setSelectionRange?.(caretPosition, caretPosition); - }; - - useEffect(() => { - if (!focusRequest || focusRequest.version === appliedFocusVersionRef.current) { - return; - } - - appliedFocusVersionRef.current = focusRequest.version; - focusSegment(focusRequest.name); - }, [focusRequest]); - - useEffect(() => () => clearTimeout(blurTimeoutRef.current), []); - if (!dateSegmentsConfig) { return null; } - const {mask, getSegmentProps, isAllSelected, onFieldBlur} = dateSegmentsConfig; + const {mask, getSegmentProps, setSegmentRef, focusSegment, isSegmentElement, isAllSelected, onFieldBlur} = dateSegmentsConfig; + + const parts = getDateMaskParts(mask).map((part) => ({...part, segmentProps: getSegmentProps(part.name)})); - /** - * Where a press on the field itself lands. The segments are read from the inputs rather than from this render, so - * a press arriving as the field is being cleared aims at what the field holds by then and not a moment earlier. - */ const focusFirstUnfilledSegment = () => { - const parts = getDateMaskParts(mask); - const target = parts.find((part) => (segmentRefs.current[part.name]?.value?.length ?? 0) < part.placeholder.length) ?? parts.at(-1); + const target = parts.find((part) => part.segmentProps.value.length < part.placeholder.length) ?? parts.at(-1); if (!target) { return; @@ -142,22 +84,26 @@ function DateSegmentsInput({ }; /** - * The field's own blur handler is what a form hangs its validation on, so it has to wait for the whole field to be - * left rather than run on every hop between segments. + * The field's own blur handler is what a form hangs its validation on, so it only runs once focus leaves the whole + * field rather than on every hop between segments. */ const handleSegmentBlur: NonNullable = (event) => { - clearTimeout(blurTimeoutRef.current); - blurTimeoutRef.current = setTimeout(() => { - onFieldBlur(); - onBlur?.(event); - }, 0); + if ('relatedTarget' in event.nativeEvent && isSegmentElement(event.nativeEvent.relatedTarget)) { + return; + } + + onFieldBlur(); + onBlur?.(event); }; - const parts = getDateMaskParts(mask).map((part) => ({...part, segmentProps: getSegmentProps(part.name)})); // The separators belong to the mask rather than to the date, so they read as placeholder text for as long as any // of the mask is still on screen const isAnyMaskShowing = parts.some((part) => part.segmentProps.value.length < part.placeholder.length); + // The style the field hands down stretches its input across the whole row, which would spread the segments evenly. + // Everything here is sized to its own text instead, so the date reads as one run of text. + const sizedToContent = [styles.flexGrow0, styles.flexShrink0, styles.flexBasisAuto, styles.wAuto]; + // The segments stretch down the row as a single input would, so the padding they inherit lands their text in the // same place as any other text field's return ( @@ -168,11 +114,11 @@ function DateSegmentsInput({ > {/* Sized to the date rather than to the row, so selecting it covers the digits and not the space after them */} - {/* The field's vertical padding is what sits its text below the label, so taking that same padding - leaves exactly the line of text to fill and the selection needs no measuring to line up with it. */} + {/* Drawn behind the segments so it cannot move them. Taking the field's vertical padding leaves exactly + the line of text to fill, so the selection needs no measuring to line up with it. */} {isAllSelected && ( - - + + )} {parts.map((part) => { @@ -186,7 +132,7 @@ function DateSegmentsInput({ return ( {measuredText} - + {/* A row, because the `flex: 1` the field hands its input takes its zero basis down the main + axis, and a column would leave the segment with no height of its own */} + { - segmentRefs.current[part.name] = element; - - // The field's own ref has to lead somewhere, and the year is where typing starts - if (part.name !== 'year') { - return; - } - if (typeof ref === 'function') { - ref(element); - } else if (ref && 'current' in ref) { - // eslint-disable-next-line no-param-reassign - ref.current = element; - } - }} - style={[style, NO_HORIZONTAL_PADDING, styles.w100]} + // The field's own ref has to lead somewhere, and the year is where typing starts + ref={mergeRefs(part.name === 'year' ? ref : undefined, (element: AnimatedTextInputRef | null) => setSegmentRef(part.name, element))} + style={[style, styles.ph0, styles.w100]} value={segmentProps.value} onKeyPress={segmentProps.onKeyPress} onChangeText={segmentProps.onChangeText} onFocus={(event) => { - clearTimeout(blurTimeoutRef.current); segmentProps.onFocus(); onFocus?.(event); }} @@ -247,7 +182,7 @@ function DateSegmentsInput({ letters exactly where the input's own text ends, without measuring anything. */} {!!remainder && ( {!!part.separator && ( void; }; -/** A request for a segment to take focus. The count is what carries it, so asking twice for the same segment works */ -type SegmentFocusRequest = { - name: DateSegmentName; - version: number; -}; - /** Everything one segment's input needs. The segment itself is stateless and reports back through these */ type DateSegmentProps = { value: string; @@ -77,8 +73,14 @@ type UseDateSegmentInputResult = { /** Whether the user is inside the field, so the segments rather than the committed date are what to render */ isEditing: boolean; - /** The segment the caller should move focus to, or undefined when no move has been asked for */ - focusRequest: SegmentFocusRequest | undefined; + /** Hands over each segment's input, so a keystroke can move focus as it is handled */ + setSegmentRef: (name: DateSegmentName, element: AnimatedTextInputRef | null) => void; + + /** Moves focus to a segment and rests the caret after its digits */ + focusSegment: (name: DateSegmentName) => void; + + /** Whether focus is going to another segment, which is moving within the field rather than leaving it */ + isSegmentElement: (target: unknown) => boolean; /** The month the calendar should show, so it follows the date being typed. Undefined leaves the calendar alone */ viewDate: Date | undefined; @@ -109,7 +111,7 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, // The segments only describe an edit in progress, so they are seeded on focus rather than synced with the value const [segments, setSegments] = useState(EMPTY_SEGMENTS); const [isEditing, setIsEditing] = useState(false); - const [focusRequest, setFocusRequest] = useState(undefined); + const segmentRefs = useRef>>({}); // The month the calendar should show, which follows the typed date once the year is complete const [viewDate, setViewDate] = useState(undefined); const [viewDateVersion, setViewDateVersion] = useState(0); @@ -140,14 +142,28 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, } } - const requestFocus = (name: DateSegmentName) => { - setFocusRequest((previous) => ({name, version: (previous?.version ?? 0) + 1})); + const setSegmentRef = (name: DateSegmentName, element: AnimatedTextInputRef | null) => { + segmentRefs.current[name] = element; + }; + + const isSegmentElement = (target: unknown) => !!target && Object.values(segmentRefs.current).some((element) => element === target); + + /** + * Arriving at a segment rests the caret after the digits already in it, rather than wherever the browser last left + * the caret there, so a half typed month reads as 02 and not 0 followed by a caret and a 2. + */ + const focusSegment = (name: DateSegmentName) => { + const element = segmentRefs.current[name]; + element?.focus(); + + const caretPosition = element?.value?.length ?? 0; + element?.setSelectionRange?.(caretPosition, caretPosition); }; /** Landing on a segment arms the overwrite, so the next digit replaces what is there rather than extending it */ const enterSegment = (name: DateSegmentName) => { setShouldOverwrite(true); - requestFocus(name); + focusSegment(name); }; /** @@ -293,7 +309,6 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, const handleFieldBlur = () => { setIsEditing(false); setViewDate(undefined); - setFocusRequest(undefined); setShouldOverwrite(false); setIsAllSelected(false); @@ -321,7 +336,9 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, return { displayValue: value, isEditing: false, - focusRequest: undefined, + setSegmentRef: () => {}, + focusSegment: () => {}, + isSegmentElement: () => false, viewDate: undefined, viewDateVersion: 0, hasTypedDigits: false, @@ -340,7 +357,9 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, return { displayValue: value, isEditing, - focusRequest, + setSegmentRef, + focusSegment, + isSegmentElement, viewDate: isEditing ? viewDate : undefined, viewDateVersion, hasTypedDigits: shouldShowSegments && hasAnySegment(segments), diff --git a/src/libs/Debug101504.ts b/src/libs/Debug101504.ts new file mode 100644 index 000000000000..e135d0d86340 --- /dev/null +++ b/src/libs/Debug101504.ts @@ -0,0 +1,43 @@ +import {WRITE_COMMANDS} from '@libs/API/types'; + +import type Middleware from './Middleware/types'; + +/** + * TEMPORARY instrumentation for https://github.com/Expensify/App/issues/101504 + * (Support better rule creation suggestions). + * + * Logs the full raw response of every expense-edit command, so we can see whether Auth already sends + * `suggestNewRuleCreation`, what shape it takes, and how many identical edits it takes to flip. + * + * Grep the console for `[D101504]`. Delete this file and its registration in src/libs/Middleware/register.ts + * before opening a PR. + */ +const WATCHED_COMMANDS = new Set([ + WRITE_COMMANDS.UPDATE_MONEY_REQUEST_CATEGORY, + WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAG, + WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAX_RATE, + WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAX_AMOUNT, + WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DESCRIPTION, + WRITE_COMMANDS.UPDATE_MONEY_REQUEST_BILLABLE, + WRITE_COMMANDS.UPDATE_MONEY_REQUEST_REIMBURSABLE, + WRITE_COMMANDS.UPDATE_MONEY_REQUEST_MERCHANT, + WRITE_COMMANDS.UPDATE_MONEY_REQUEST_VENDOR, +]); + +const Debug101504: Middleware = (requestResponse, request) => { + if (!WATCHED_COMMANDS.has(request.command)) { + return requestResponse; + } + + return requestResponse.then((response) => { + const keys = response ? Object.keys(response) : []; + // Printed separately from the body below, because the body is long enough that the interesting keys scroll away. + console.log(`[D101504] ${request.command} responseKeys`, keys.join(', ')); + console.log(`[D101504] ${request.command} suggestNewRuleCreation`, JSON.stringify((response as Record)?.suggestNewRuleCreation ?? null)); + console.log(`[D101504] ${request.command} params`, JSON.stringify(request.data ?? {})); + console.log(`[D101504] ${request.command} response`, JSON.stringify(response ?? null)); + return response; + }); +}; + +export default Debug101504; diff --git a/src/libs/Debug95430.ts b/src/libs/Debug95430.ts new file mode 100644 index 000000000000..93e911692a16 --- /dev/null +++ b/src/libs/Debug95430.ts @@ -0,0 +1,407 @@ +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetailsList, Report, ReportAction, ReportActions, Transaction} from '@src/types/onyx'; + +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; + +/** + * TEMPORARY instrumentation for https://github.com/Expensify/App/issues/95430 + * ("Hidden" is shown in the LHN for card expense / transaction threads). + * + * Every line is printed as `[95430][] {...json...}` on a single line so it can be filtered + * with a plain text search in the browser console, Metro or `adb logcat`. + * + * `dump95430()` and `dump95430('')` are exposed globally for on-demand inspection. + * + * Delete this file together with its call sites in `ReportNameUtils.ts` and `SidebarUtils.ts` + * once the root cause is confirmed. + */ +import Onyx from 'react-native-onyx'; + +/** Search for this string to find every line produced by this module */ +const DEBUG_TAG = '[95430]'; + +/** The same report is recomputed many times, so identical payloads are only printed once */ +const lastPayloadByKey = new Map(); + +/** What each report last rendered as in the LHN, so the on-demand dump can report it */ +const lastLHNTextByReportID = new Map(); + +type OnyxSnapshot = { + reports: OnyxCollection; + transactions: OnyxCollection; + reportActions: OnyxCollection; + personalDetailsList: OnyxEntry; +}; + +/** Latest collections seen by `computeReportName`, used by the on-demand dump */ +let lastSnapshot: OnyxSnapshot | undefined; + +function print(event: string, dedupeKey: string, payload: Record, shouldDedupe = true) { + const serialized = JSON.stringify(payload); + const key = `${event}:${dedupeKey}`; + if (shouldDedupe && lastPayloadByKey.get(key) === serialized) { + return; + } + lastPayloadByKey.set(key, serialized); + // eslint-disable-next-line no-console -- temporary debugging output for issue #95430 + console.log(`${DEBUG_TAG}[${event}] ${serialized}`); +} + +function getOriginalMessage(action: OnyxEntry): Record | undefined { + if (!action) { + return undefined; + } + return (action as {originalMessage?: Record}).originalMessage; +} + +function getLinkedTransactionID(action: OnyxEntry): string | undefined { + const transactionID = getOriginalMessage(action)?.IOUTransactionID; + return typeof transactionID === 'string' ? transactionID : undefined; +} + +function getParentReportAction(report: OnyxEntry, reportActions: OnyxCollection): OnyxEntry { + if (!report?.parentReportID || !report.parentReportActionID) { + return undefined; + } + return reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`]?.[report.parentReportActionID]; +} + +function isCardTransaction(transaction: OnyxEntry): boolean { + return !!transaction?.managedCard || !!transaction?.cardID || !!transaction?.bank; +} + +/** Unreported expenses live on the placeholder report IDs `0` and `-4` instead of a real expense report */ +function isUnreportedTransaction(transaction: OnyxEntry): boolean { + return !transaction?.reportID || transaction.reportID === '0' || transaction.reportID === '-4'; +} + +/** + * Only threads hanging off a real expense report can hit this issue. Track expenses in the self DM + * resolve their name through a different, always-available path. + */ +function getThreadKind(parentReport: OnyxEntry): string { + if (!parentReport) { + return 'PARENT_NOT_IN_ONYX'; + } + if (parentReport.type === CONST.REPORT.TYPE.EXPENSE) { + return 'EXPENSE_REPORT_THREAD'; + } + if (parentReport.type === CONST.REPORT.TYPE.IOU) { + return 'IOU_REPORT_THREAD'; + } + if (parentReport.chatType === CONST.REPORT.CHAT_TYPE.SELF_DM) { + return 'SELF_DM_TRACK_THREAD'; + } + return `OTHER_${parentReport.type ?? 'UNKNOWN'}`; +} + +function summarizeTransaction(transaction: OnyxEntry) { + if (!transaction) { + return null; + } + return { + transactionID: transaction.transactionID, + reportID: transaction.reportID, + isCardTransaction: isCardTransaction(transaction), + amount: transaction.amount, + modifiedAmount: transaction.modifiedAmount, + currency: transaction.currency, + merchant: transaction.merchant, + modifiedMerchant: transaction.modifiedMerchant, + created: transaction.created, + cardID: transaction.cardID, + cardName: transaction.cardName, + managedCard: transaction.managedCard, + bank: transaction.bank, + hasEReceipt: transaction.hasEReceipt, + liabilityType: transaction.comment?.liabilityType, + }; +} + +function summarizeReportAction(action: OnyxEntry) { + if (!action) { + return null; + } + const originalMessage = getOriginalMessage(action); + return { + reportActionID: action.reportActionID, + actionName: action.actionName, + childReportID: action.childReportID, + childType: action.childType, + actorAccountID: action.actorAccountID, + originalMessageType: originalMessage?.type, + IOUTransactionID: originalMessage?.IOUTransactionID, + IOUReportID: originalMessage?.IOUReportID, + }; +} + +function summarizeReport(report: OnyxEntry) { + if (!report) { + return null; + } + return { + reportID: report.reportID, + type: report.type, + chatType: report.chatType, + reportName: report.reportName, + policyID: report.policyID, + ownerAccountID: report.ownerAccountID, + managerID: report.managerID, + parentReportID: report.parentReportID, + parentReportActionID: report.parentReportActionID, + stateNum: report.stateNum, + statusNum: report.statusNum, + participantAccountIDs: Object.keys(report.participants ?? {}), + }; +} + +/** + * Transaction threads are plain chat threads with no `chatType` whose parent action is an IOU action. + * When the parent action has not loaded yet we cannot tell for sure, and that unresolved state is + * exactly what this issue is about, so it counts as a candidate. + */ +function isTransactionThreadCandidate(report: OnyxEntry, parentReportAction: OnyxEntry): boolean { + if (report?.type !== CONST.REPORT.TYPE.CHAT || !report.parentReportID || !report.parentReportActionID || report.chatType) { + return false; + } + if (parentReportAction) { + return parentReportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU; + } + return true; +} + +function buildDiagnostics(report: Report, snapshot: OnyxSnapshot, parentReportAction: OnyxEntry, resolvedName: string | undefined) { + const {reports, transactions, reportActions, personalDetailsList} = snapshot; + + const parentReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`]; + const parentReportActions = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`]; + const parentReportActionList = Object.values(parentReportActions ?? {}); + const actionPointingToThisThread = parentReportActionList.find((action) => action?.childReportID === report.reportID); + + const linkedTransactionID = getLinkedTransactionID(parentReportAction); + const linkedTransaction = linkedTransactionID ? transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${linkedTransactionID}`] : undefined; + + // Scanning the whole transaction collection is expensive, so only do it when something is actually missing + const shouldScanAllTransactions = !resolvedName || !linkedTransaction; + const transactionsOnParentReport = shouldScanAllTransactions ? Object.values(transactions ?? {}).filter((transaction) => transaction?.reportID === report.parentReportID) : []; + + const participants = Object.keys(report.participants ?? {}).map((accountID) => { + const personalDetails = personalDetailsList?.[accountID]; + return { + accountID, + hasPersonalDetails: !!personalDetails, + displayName: personalDetails?.displayName, + login: personalDetails?.login, + }; + }); + + let likelyCause = 'OK_NAME_RESOLVED'; + if (!resolvedName) { + if (!parentReport) { + likelyCause = 'PARENT_REPORT_MISSING'; + } else if (!parentReportActions) { + likelyCause = 'PARENT_REPORT_ACTIONS_NOT_LOADED'; + } else if (!parentReportAction) { + likelyCause = actionPointingToThisThread ? 'PARENT_REPORT_ACTION_ID_MISMATCH' : 'PARENT_REPORT_ACTION_MISSING'; + } else if (!linkedTransaction) { + likelyCause = 'TRANSACTION_MISSING'; + } else { + likelyCause = 'NAME_EMPTY_WITH_FULL_DATA'; + } + } + + return { + likelyCause, + threadKind: getThreadKind(parentReport), + transactionIsUnreported: isUnreportedTransaction(linkedTransaction), + resolvedName: resolvedName ?? null, + lastRenderedInLHNAs: lastLHNTextByReportID.get(report.reportID) ?? null, + willFallBackToParticipantNames: !resolvedName, + report: summarizeReport(report), + parentReport: summarizeReport(parentReport), + parentReportActionsLoaded: !!parentReportActions, + parentReportActionsCount: parentReportActionList.length, + parentReportAction: summarizeReportAction(parentReportAction), + actionPointingToThisThread: summarizeReportAction(actionPointingToThisThread), + linkedTransactionID: linkedTransactionID ?? null, + linkedTransaction: summarizeTransaction(linkedTransaction), + transactionsOnParentReport: transactionsOnParentReport.map(summarizeTransaction), + participants, + }; +} + +type ReportNameDiagnosticsParams = OnyxSnapshot & { + report: OnyxEntry; + parentReportAction: OnyxEntry; + resolvedName: string | undefined; +}; + +/** + * Dumps everything `computeReportName` needs to name a transaction thread, so we can tell which + * piece of data is missing when the LHN falls back to "Hidden". + */ +function logReportNameComputation({report, reports, transactions, reportActions, personalDetailsList, parentReportAction, resolvedName}: ReportNameDiagnosticsParams) { + lastSnapshot = {reports, transactions, reportActions, personalDetailsList}; + + if (!report || !isTransactionThreadCandidate(report, parentReportAction)) { + return; + } + + print('name', report.reportID, buildDiagnostics(report, lastSnapshot, parentReportAction, resolvedName)); +} + +/** + * Logs what the LHN row actually renders, so the computed name above can be matched with the visible row. + */ +function logSidebarRow(report: OnyxEntry, text: string, hiddenTranslation: string) { + if (!report) { + return; + } + lastLHNTextByReportID.set(report.reportID, text); + + const parentReportAction = getParentReportAction(report, lastSnapshot?.reportActions); + const showsHidden = !text || text === hiddenTranslation; + if (!showsHidden && !isTransactionThreadCandidate(report, parentReportAction)) { + return; + } + + print('lhn', report.reportID, { + showsHidden, + text, + reportID: report.reportID, + type: report.type, + chatType: report.chatType, + parentReportID: report.parentReportID, + parentReportActionID: report.parentReportActionID, + }); +} + +/** + * Call `dump95430()` in the console for an overview of every card expense and transaction thread, + * or `dump95430('')` for the full diagnostics of a single report. + */ +function dump95430(reportID?: string) { + if (!lastSnapshot) { + print('dump', 'no-snapshot', {error: 'No Onyx snapshot captured yet - open the LHN first'}, false); + return; + } + const {reports, transactions, reportActions} = lastSnapshot; + + if (reportID) { + const report = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + if (!report) { + print('dump', reportID, {error: `Report ${reportID} is not in Onyx`}, false); + return; + } + print('dump', reportID, buildDiagnostics(report, lastSnapshot, getParentReportAction(report, reportActions), undefined), false); + return; + } + + const reportList = Object.values(reports ?? {}); + const threadsByParentActionID = new Map(); + for (const candidate of reportList) { + if (candidate?.parentReportActionID) { + threadsByParentActionID.set(candidate.parentReportActionID, candidate); + } + } + + const cardExpenses = Object.values(transactions ?? {}) + .filter(isCardTransaction) + .map((transaction) => { + const expenseReportActions = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transaction?.reportID}`]; + const iouAction = Object.values(expenseReportActions ?? {}).find((action) => getLinkedTransactionID(action) === transaction?.transactionID); + const thread = iouAction ? threadsByParentActionID.get(iouAction.reportActionID) : undefined; + return { + transactionID: transaction?.transactionID, + merchant: transaction?.modifiedMerchant ?? transaction?.merchant, + expenseReportID: transaction?.reportID, + isUnreported: isUnreportedTransaction(transaction), + expenseReportType: reports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`]?.type ?? null, + expenseReportActionsLoaded: !!expenseReportActions, + iouActionID: iouAction?.reportActionID ?? null, + threadReportIDFromAction: iouAction?.childReportID ?? null, + threadReportInOnyx: !!thread, + threadLastRenderedInLHNAs: thread ? (lastLHNTextByReportID.get(thread.reportID) ?? null) : null, + }; + }); + + const threads = reportList + .filter((candidate): candidate is Report => isTransactionThreadCandidate(candidate, getParentReportAction(candidate, reportActions))) + .map((thread) => { + const parentReportAction = getParentReportAction(thread, reportActions); + const parentReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${thread.parentReportID}`]; + return { + reportID: thread.reportID, + threadKind: getThreadKind(parentReport), + parentReportID: thread.parentReportID, + parentReportActionID: thread.parentReportActionID, + hasParentReport: !!parentReport, + hasParentReportAction: !!parentReportAction, + linkedTransactionID: getLinkedTransactionID(parentReportAction) ?? null, + lastRenderedInLHNAs: lastLHNTextByReportID.get(thread.reportID) ?? null, + }; + }); + + print( + 'dump', + 'overview', + { + reportCount: reportList.length, + transactionCount: Object.keys(transactions ?? {}).length, + cardExpenseCount: cardExpenses.length, + unreportedCardExpenseCount: cardExpenses.filter((cardExpense) => cardExpense.isUnreported).length, + transactionThreadCount: threads.length, + // Only these can hit the issue - if this is 0 the account has no data to reproduce with + expenseReportThreadCount: threads.filter((thread) => thread.threadKind === 'EXPENSE_REPORT_THREAD').length, + threadsMissingParentAction: threads.filter((thread) => !thread.hasParentReportAction).length, + cardExpenses, + threads, + }, + false, + ); +} + +/** The parent action removed by `break95430`, kept so `restore95430` can put it back */ +let removedParentAction: {reportActionsKey: `${typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS}${string}`; reportActionID: string; reportAction: ReportAction} | undefined; + +/** + * Forces the state this issue is about by deleting a thread's parent IOU action from Onyx, which is + * what the hydration race produces naturally. The LHN row for the thread should flip to "Hidden". + * Call `restore95430()` to put the action back. + */ +function break95430(threadReportID: string) { + const report = lastSnapshot?.reports?.[`${ONYXKEYS.COLLECTION.REPORT}${threadReportID}`]; + if (!report?.parentReportID || !report.parentReportActionID) { + print('break', threadReportID, {error: `Report ${threadReportID} is not a thread, or is not in Onyx`}, false); + return; + } + const reportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}` as const; + const reportAction = lastSnapshot?.reportActions?.[reportActionsKey]?.[report.parentReportActionID]; + if (!reportAction) { + print('break', threadReportID, {error: `Parent action ${report.parentReportActionID} is already absent`}, false); + return; + } + + removedParentAction = {reportActionsKey, reportActionID: report.parentReportActionID, reportAction}; + print('break', threadReportID, {removedParentActionID: report.parentReportActionID, fromReportActionsKey: reportActionsKey}, false); + // eslint-disable-next-line rulesdir/prefer-actions-set-data -- temporary debugging helper for issue #95430, not production code + Onyx.merge(reportActionsKey, {[report.parentReportActionID]: null}); +} + +/** Restores the parent action removed by `break95430` */ +function restore95430() { + if (!removedParentAction) { + print('restore', 'none', {error: 'Nothing to restore - call break95430(threadReportID) first'}, false); + return; + } + const {reportActionsKey, reportActionID, reportAction} = removedParentAction; + removedParentAction = undefined; + print('restore', reportActionID, {restoredInto: reportActionsKey}, false); + // eslint-disable-next-line rulesdir/prefer-actions-set-data -- temporary debugging helper for issue #95430, not production code + Onyx.merge(reportActionsKey, {[reportActionID]: reportAction}); +} + +Object.assign(globalThis, {dump95430, break95430, restore95430}); + +export {break95430, dump95430, logReportNameComputation, logSidebarRow, restore95430}; diff --git a/src/libs/Debug96732.ts b/src/libs/Debug96732.ts new file mode 100644 index 000000000000..280c26a0a016 --- /dev/null +++ b/src/libs/Debug96732.ts @@ -0,0 +1,372 @@ +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Transaction, TransactionViolation, TransactionViolations} from '@src/types/onyx'; + +import type {OnyxCollection, OnyxKey, OnyxUpdate} from 'react-native-onyx'; + +import {format} from 'date-fns'; +/** + * TEMPORARY instrumentation for https://github.com/Expensify/App/issues/96732 + * ("Future date not allowed" briefly displays and disappears). + * + * Every line is printed as `[96732][] {...json...}` on a single line so it can be filtered + * with a plain text search in the browser console, Metro or `adb logcat`. + * + * What each event proves: + * - `compute` - the client-side futureDate decision inside `ViolationsUtils.getViolationsOnyxData`. + * Includes both the current `new Date(...)` + `isFutureDay` result and a + * timezone-safe `yyyy-MM-dd` string comparison, so a disagreement between the two + * exposes the UTC-midnight parsing bug. + * - `create-optimistic` - the violations array the create flow pushes to `optimisticData` (and the fact + * that no `successData` re-asserts it). + * - `server` - the raw `transactionViolations_*` payload the backend sent back, tagged with the + * API command. This is the decisive one: it shows whether the server returns + * `futureDate` at all, and for which dates. + * - `write` - every net change to `transactionViolations_` in Onyx, with before/after. + * A `write` that drops `futureDate` with NO `compute` line immediately before it + * means the removal came from the server, not from a client recompute. + * + * `dump96732()` and `dump96732('')` are exposed globally for on-demand inspection. + * + * Delete this file together with its call sites in `ViolationsUtils.ts`, `MoneyRequestBuilder.ts` + * and `actions/OnyxUpdates.ts` once the root cause is confirmed. + */ +import Onyx from 'react-native-onyx'; + +/** Search for this string to find every line produced by this module */ +const DEBUG_TAG = '[96732]'; + +/** `getViolationsOnyxData` runs on nearly every Onyx tick, so identical payloads are only printed once */ +const lastPayloadByKey = new Map(); + +/** Previous value of every `transactionViolations_*` key, so writes can be logged as before -> after */ +const lastViolationsByTransactionID = new Map(); + +/** Latest transaction collection, used to enrich violation writes with the expense date */ +let lastTransactions: OnyxCollection; + +function print(event: string, dedupeKey: string, payload: Record, shouldDedupe = true) { + const serialized = JSON.stringify(payload); + const key = `${event}:${dedupeKey}`; + if (shouldDedupe && lastPayloadByKey.get(key) === serialized) { + return; + } + lastPayloadByKey.set(key, serialized); + // eslint-disable-next-line no-console -- temporary debugging output for issue #96732 + console.log(`${DEBUG_TAG}[${event}] ${serialized}`); +} + +function hasFutureDate(violations: TransactionViolations | undefined): boolean { + return !!violations?.some((violation) => violation.name === CONST.VIOLATIONS.FUTURE_DATE); +} + +function violationNames(violations: TransactionViolations | undefined): string[] { + return (violations ?? []).map((violation) => violation.name); +} + +/** The date the app is actually using for this expense, mirroring `TransactionUtils.getCreated` */ +function getEffectiveCreated(transaction: OnyxEntryLike): string { + return transaction?.modifiedCreated ? transaction.modifiedCreated : (transaction?.created ?? ''); +} + +type OnyxEntryLike = T | undefined | null; + +function summarizeTransactionDates(transaction: OnyxEntryLike) { + if (!transaction) { + return null; + } + return { + transactionID: transaction.transactionID, + reportID: transaction.reportID, + created: transaction.created, + // Empty string here is what breaks `modifiedCreated ?? created` in ViolationsUtils + modifiedCreated: transaction.modifiedCreated, + modifiedCreatedIsEmptyString: transaction.modifiedCreated === '', + effectiveCreated: getEffectiveCreated(transaction), + merchant: transaction.modifiedMerchant ?? transaction.merchant, + amount: transaction.modifiedAmount ?? transaction.amount, + pendingAction: transaction.pendingAction, + }; +} + +type FutureDateComputeParams = { + /** Where the compute was triggered from, e.g. `create` or `edit` */ + caller: string; + transaction: OnyxEntryLike; + policyType: string | undefined; + policyID: string | undefined; + isInvoiceTransaction: boolean; + isControlPolicy: boolean; + /** The `new Date(modifiedCreated ?? created)` value ViolationsUtils actually built */ + inputDate: Date; + /** Result of `DateUtils.isFutureDay(inputDate)` */ + isFutureDayResult: boolean; + shouldDisplayFutureDateViolation: boolean; + /** Whether the incoming violations array already had `futureDate` */ + hadFutureDateViolation: boolean; + violationsIn: TransactionViolations; + violationsOut: TransactionViolations; +}; + +/** + * Logs the client-side futureDate decision. The two `isFuture*` fields are computed independently: + * when `isFutureDayResult` and `isFutureByStringCompare` disagree, the UTC-midnight parse in + * `ViolationsUtils.ts` is the reason the violation is missing (or present) for that user's timezone. + */ +function logFutureDateCompute({ + caller, + transaction, + policyType, + policyID, + isInvoiceTransaction, + isControlPolicy, + inputDate, + isFutureDayResult, + shouldDisplayFutureDateViolation, + hadFutureDateViolation, + violationsIn, + violationsOut, +}: FutureDateComputeParams) { + const effectiveCreated = getEffectiveCreated(transaction); + const todayLocal = format(new Date(), CONST.DATE.FNS_FORMAT_STRING); + const inputDateIsValid = !Number.isNaN(inputDate.getTime()); + + print('compute', transaction?.transactionID ?? 'unknown', { + caller, + transactionID: transaction?.transactionID, + policyID, + policyType, + isControlPolicy, + isInvoiceTransaction, + + // --- date inputs --- + created: transaction?.created, + modifiedCreated: transaction?.modifiedCreated, + modifiedCreatedIsEmptyString: transaction?.modifiedCreated === '', + effectiveCreated, + + // --- how ViolationsUtils reads that date today --- + inputDateIsValid, + inputDateISO: inputDateIsValid ? inputDate.toISOString() : 'Invalid Date', + isFutureDayResult, + + // --- timezone-safe comparison for reference --- + todayLocal, + isFutureByStringCompare: !!effectiveCreated && effectiveCreated > todayLocal, + timezoneOffsetMinutes: new Date().getTimezoneOffset(), + // true => the current implementation and a plain calendar-date compare disagree + timezoneParsingMismatch: isFutureDayResult !== (!!effectiveCreated && effectiveCreated > todayLocal), + + // --- decision --- + shouldDisplayFutureDateViolation, + hadFutureDateViolation, + violationsIn: violationNames(violationsIn), + violationsOut: violationNames(violationsOut), + futureDateInResult: hasFutureDate(violationsOut), + }); +} + +/** + * Logs the violations array the create flow writes to `optimisticData`. `hasSuccessDataForViolations` + * is expected to be false - that is the gap this issue is about. + */ +function logCreateOptimisticViolations(transaction: OnyxEntryLike, violations: unknown, hasSuccessDataForViolations: boolean) { + const violationList = Array.isArray(violations) ? (violations as TransactionViolations) : undefined; + print( + 'create-optimistic', + transaction?.transactionID ?? 'unknown', + { + transactionID: transaction?.transactionID, + transaction: summarizeTransactionDates(transaction), + onyxMethod: 'SET', + optimisticViolations: violationNames(violationList), + futureDateInOptimistic: hasFutureDate(violationList), + // If this is false, nothing re-asserts the violation after the API responds + hasSuccessDataForViolations, + }, + false, + ); +} + +/** + * Logs every `transactionViolations_*` payload the backend sends, tagged with the API command that + * produced it. This is what tells us whether the server computes `futureDate` at all. + */ +function logServerViolations(source: string, command: string, updates: Array> | undefined) { + if (!updates?.length) { + return; + } + for (const update of updates) { + const key = (update as {key?: string}).key ?? ''; + if (!key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS)) { + continue; + } + const transactionID = key.slice(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS.length); + const value = (update as {value?: unknown}).value; + const violations = Array.isArray(value) ? (value as TransactionViolations) : undefined; + print( + 'server', + transactionID, + { + source, + command, + transactionID, + onyxMethod: (update as {onyxMethod?: string}).onyxMethod, + serverViolations: violations ? violationNames(violations) : value, + futureDateFromServer: hasFutureDate(violations), + transaction: summarizeTransactionDates(lastTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]), + }, + false, + ); + } +} + +/** Logs the raw onyxData a WRITE response carried, so we can see the server's transaction payload too */ +function logServerTransactionDates(source: string, command: string, updates: Array> | undefined) { + if (!updates?.length) { + return; + } + for (const update of updates) { + const key = (update as {key?: string}).key ?? ''; + if (!key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION) || key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS)) { + continue; + } + const value = (update as {value?: unknown}).value as Transaction | undefined; + if (!value || (value.created === undefined && value.modifiedCreated === undefined)) { + continue; + } + print( + 'server-transaction', + key, + { + source, + command, + key, + onyxMethod: (update as {onyxMethod?: string}).onyxMethod, + created: value.created, + modifiedCreated: value.modifiedCreated, + modifiedCreatedIsEmptyString: value.modifiedCreated === '', + }, + false, + ); + } +} + +// We use `connectWithoutView` because this module is pure instrumentation and renders nothing. +Onyx.connectWithoutView({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + callback: (transactions) => { + lastTransactions = transactions; + }, +}); + +Onyx.connectWithoutView({ + key: ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, + callback: (violationsCollection) => { + for (const [collectionKey, violations] of Object.entries(violationsCollection ?? {})) { + const transactionID = collectionKey.slice(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS.length); + const after = violationNames(violations); + const before = lastViolationsByTransactionID.get(transactionID); + if (before && JSON.stringify(before) === JSON.stringify(after)) { + continue; + } + lastViolationsByTransactionID.set(transactionID, after); + + const hadFutureDate = !!before?.includes(CONST.VIOLATIONS.FUTURE_DATE); + const hasFutureDateNow = after.includes(CONST.VIOLATIONS.FUTURE_DATE); + + let verdict = 'UNCHANGED_FUTURE_DATE'; + if (!hadFutureDate && hasFutureDateNow) { + verdict = 'FUTURE_DATE_ADDED'; + } else if (hadFutureDate && !hasFutureDateNow) { + // This is the disappearing red brick road. Check whether a `compute` line was printed + // immediately before it - if not, the removal came from the server payload. + verdict = 'FUTURE_DATE_REMOVED'; + } + + print( + 'write', + transactionID, + { + transactionID, + verdict, + before: before ?? null, + after, + transaction: summarizeTransactionDates(lastTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]), + }, + false, + ); + } + + // A deleted collection member produces no entry above, so removals are detected by diffing the keys. + // `transactionViolations_: null` from the server lands here. + for (const [transactionID, before] of lastViolationsByTransactionID.entries()) { + if (violationsCollection?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]) { + continue; + } + lastViolationsByTransactionID.delete(transactionID); + + print( + 'write', + transactionID, + { + transactionID, + verdict: before.includes(CONST.VIOLATIONS.FUTURE_DATE) ? 'FUTURE_DATE_REMOVED_BY_KEY_DELETE' : 'KEY_DELETED', + before, + after: null, + transaction: summarizeTransactionDates(lastTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]), + }, + false, + ); + } + }, +}); + +/** + * Call `dump96732()` in the console for every transaction that currently has violations, or + * `dump96732('')` for a single expense. + */ +function dump96732(transactionID?: string) { + const todayLocal = format(new Date(), CONST.DATE.FNS_FORMAT_STRING); + + if (transactionID) { + const transaction = lastTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + const violations = lastViolationsByTransactionID.get(transactionID); + print( + 'dump', + transactionID, + { + todayLocal, + timezoneOffsetMinutes: new Date().getTimezoneOffset(), + transaction: summarizeTransactionDates(transaction), + currentViolations: violations ?? null, + }, + false, + ); + return; + } + + const entries = [...lastViolationsByTransactionID.entries()].map(([id, violations]) => ({ + transactionID: id, + violations, + transaction: summarizeTransactionDates(lastTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`]), + })); + + print( + 'dump', + 'overview', + { + todayLocal, + timezoneOffsetMinutes: new Date().getTimezoneOffset(), + trackedTransactionCount: entries.length, + withFutureDate: entries.filter((entry) => entry.violations.includes(CONST.VIOLATIONS.FUTURE_DATE)).map((entry) => entry.transactionID), + entries, + }, + false, + ); +} + +Object.assign(globalThis, {dump96732}); + +export {dump96732, logCreateOptimisticViolations, logFutureDateCompute, logServerTransactionDates, logServerViolations}; +export type {TransactionViolation}; diff --git a/src/libs/Write merchant-rules-test.csv (po4841) b/src/libs/Write merchant-rules-test.csv (po4841) new file mode 100644 index 000000000000..8092011dea50 --- /dev/null +++ b/src/libs/Write merchant-rules-test.csv (po4841) @@ -0,0 +1,10 @@ +Merchant is,Merchant contains,Updated merchant,Category,Tag,Description,Reimbursable,Billable +STARBUCKS #1234,,Starbucks,Meals and entertainment,,,yes, +,uber,Uber,Travel,,,,no +,amzn mktp,Amazon,Office supplies,,"Office supplies, restocked monthly",, +DELTA AIR LINES,,Delta,Travel,,,true,false +,shell oil,,,,,no,yes +,notarealmerchant,Ghost Vendor,ThisCategoryDoesNotExist,,,, +STARBUCKS #1234,,Starbucks,Meals and entertainment,,,yes, +,lyft,,,,,, +,,Orphan Merchant,Travel,,,, diff --git a/src/libs/isTypedDateInputSupported/index.native.ts b/src/libs/isTypedDateInputSupported/index.native.ts deleted file mode 100644 index a831cc67c730..000000000000 --- a/src/libs/isTypedDateInputSupported/index.native.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type IsTypedDateInputSupported from './types'; - -const isTypedDateInputSupported: IsTypedDateInputSupported = () => false; - -export default isTypedDateInputSupported; diff --git a/src/libs/isTypedDateInputSupported/index.ts b/src/libs/isTypedDateInputSupported/index.ts deleted file mode 100644 index 61021e14acc2..000000000000 --- a/src/libs/isTypedDateInputSupported/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Whether a date field accepts typed input alongside the calendar picker. Touch devices keep the picker on its own so - * the soft keyboard does not cover the calendar. - */ -import {canUseTouchScreen} from '@libs/DeviceCapabilities'; - -import type IsTypedDateInputSupported from './types'; - -const isTypedDateInputSupported: IsTypedDateInputSupported = () => !canUseTouchScreen(); - -export default isTypedDateInputSupported; diff --git a/src/libs/isTypedDateInputSupported/types.ts b/src/libs/isTypedDateInputSupported/types.ts deleted file mode 100644 index 02f08dc515a7..000000000000 --- a/src/libs/isTypedDateInputSupported/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -type IsTypedDateInputSupported = () => boolean; - -export default IsTypedDateInputSupported; From 1deb7569e428d9497914d5b97c87bc3fff102813 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 24 Sep 2026 08:49:29 +0530 Subject: [PATCH 31/80] chore: remove local debug files committed by mistake Signed-off-by: krishna2323 --- src/libs/Debug101504.ts | 43 -- src/libs/Debug95430.ts | 407 ------------------ src/libs/Debug96732.ts | 372 ---------------- .../Write merchant-rules-test.csv (po4841) | 10 - 4 files changed, 832 deletions(-) delete mode 100644 src/libs/Debug101504.ts delete mode 100644 src/libs/Debug95430.ts delete mode 100644 src/libs/Debug96732.ts delete mode 100644 src/libs/Write merchant-rules-test.csv (po4841) diff --git a/src/libs/Debug101504.ts b/src/libs/Debug101504.ts deleted file mode 100644 index e135d0d86340..000000000000 --- a/src/libs/Debug101504.ts +++ /dev/null @@ -1,43 +0,0 @@ -import {WRITE_COMMANDS} from '@libs/API/types'; - -import type Middleware from './Middleware/types'; - -/** - * TEMPORARY instrumentation for https://github.com/Expensify/App/issues/101504 - * (Support better rule creation suggestions). - * - * Logs the full raw response of every expense-edit command, so we can see whether Auth already sends - * `suggestNewRuleCreation`, what shape it takes, and how many identical edits it takes to flip. - * - * Grep the console for `[D101504]`. Delete this file and its registration in src/libs/Middleware/register.ts - * before opening a PR. - */ -const WATCHED_COMMANDS = new Set([ - WRITE_COMMANDS.UPDATE_MONEY_REQUEST_CATEGORY, - WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAG, - WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAX_RATE, - WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAX_AMOUNT, - WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DESCRIPTION, - WRITE_COMMANDS.UPDATE_MONEY_REQUEST_BILLABLE, - WRITE_COMMANDS.UPDATE_MONEY_REQUEST_REIMBURSABLE, - WRITE_COMMANDS.UPDATE_MONEY_REQUEST_MERCHANT, - WRITE_COMMANDS.UPDATE_MONEY_REQUEST_VENDOR, -]); - -const Debug101504: Middleware = (requestResponse, request) => { - if (!WATCHED_COMMANDS.has(request.command)) { - return requestResponse; - } - - return requestResponse.then((response) => { - const keys = response ? Object.keys(response) : []; - // Printed separately from the body below, because the body is long enough that the interesting keys scroll away. - console.log(`[D101504] ${request.command} responseKeys`, keys.join(', ')); - console.log(`[D101504] ${request.command} suggestNewRuleCreation`, JSON.stringify((response as Record)?.suggestNewRuleCreation ?? null)); - console.log(`[D101504] ${request.command} params`, JSON.stringify(request.data ?? {})); - console.log(`[D101504] ${request.command} response`, JSON.stringify(response ?? null)); - return response; - }); -}; - -export default Debug101504; diff --git a/src/libs/Debug95430.ts b/src/libs/Debug95430.ts deleted file mode 100644 index 93e911692a16..000000000000 --- a/src/libs/Debug95430.ts +++ /dev/null @@ -1,407 +0,0 @@ -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetailsList, Report, ReportAction, ReportActions, Transaction} from '@src/types/onyx'; - -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; - -/** - * TEMPORARY instrumentation for https://github.com/Expensify/App/issues/95430 - * ("Hidden" is shown in the LHN for card expense / transaction threads). - * - * Every line is printed as `[95430][] {...json...}` on a single line so it can be filtered - * with a plain text search in the browser console, Metro or `adb logcat`. - * - * `dump95430()` and `dump95430('')` are exposed globally for on-demand inspection. - * - * Delete this file together with its call sites in `ReportNameUtils.ts` and `SidebarUtils.ts` - * once the root cause is confirmed. - */ -import Onyx from 'react-native-onyx'; - -/** Search for this string to find every line produced by this module */ -const DEBUG_TAG = '[95430]'; - -/** The same report is recomputed many times, so identical payloads are only printed once */ -const lastPayloadByKey = new Map(); - -/** What each report last rendered as in the LHN, so the on-demand dump can report it */ -const lastLHNTextByReportID = new Map(); - -type OnyxSnapshot = { - reports: OnyxCollection; - transactions: OnyxCollection; - reportActions: OnyxCollection; - personalDetailsList: OnyxEntry; -}; - -/** Latest collections seen by `computeReportName`, used by the on-demand dump */ -let lastSnapshot: OnyxSnapshot | undefined; - -function print(event: string, dedupeKey: string, payload: Record, shouldDedupe = true) { - const serialized = JSON.stringify(payload); - const key = `${event}:${dedupeKey}`; - if (shouldDedupe && lastPayloadByKey.get(key) === serialized) { - return; - } - lastPayloadByKey.set(key, serialized); - // eslint-disable-next-line no-console -- temporary debugging output for issue #95430 - console.log(`${DEBUG_TAG}[${event}] ${serialized}`); -} - -function getOriginalMessage(action: OnyxEntry): Record | undefined { - if (!action) { - return undefined; - } - return (action as {originalMessage?: Record}).originalMessage; -} - -function getLinkedTransactionID(action: OnyxEntry): string | undefined { - const transactionID = getOriginalMessage(action)?.IOUTransactionID; - return typeof transactionID === 'string' ? transactionID : undefined; -} - -function getParentReportAction(report: OnyxEntry, reportActions: OnyxCollection): OnyxEntry { - if (!report?.parentReportID || !report.parentReportActionID) { - return undefined; - } - return reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`]?.[report.parentReportActionID]; -} - -function isCardTransaction(transaction: OnyxEntry): boolean { - return !!transaction?.managedCard || !!transaction?.cardID || !!transaction?.bank; -} - -/** Unreported expenses live on the placeholder report IDs `0` and `-4` instead of a real expense report */ -function isUnreportedTransaction(transaction: OnyxEntry): boolean { - return !transaction?.reportID || transaction.reportID === '0' || transaction.reportID === '-4'; -} - -/** - * Only threads hanging off a real expense report can hit this issue. Track expenses in the self DM - * resolve their name through a different, always-available path. - */ -function getThreadKind(parentReport: OnyxEntry): string { - if (!parentReport) { - return 'PARENT_NOT_IN_ONYX'; - } - if (parentReport.type === CONST.REPORT.TYPE.EXPENSE) { - return 'EXPENSE_REPORT_THREAD'; - } - if (parentReport.type === CONST.REPORT.TYPE.IOU) { - return 'IOU_REPORT_THREAD'; - } - if (parentReport.chatType === CONST.REPORT.CHAT_TYPE.SELF_DM) { - return 'SELF_DM_TRACK_THREAD'; - } - return `OTHER_${parentReport.type ?? 'UNKNOWN'}`; -} - -function summarizeTransaction(transaction: OnyxEntry) { - if (!transaction) { - return null; - } - return { - transactionID: transaction.transactionID, - reportID: transaction.reportID, - isCardTransaction: isCardTransaction(transaction), - amount: transaction.amount, - modifiedAmount: transaction.modifiedAmount, - currency: transaction.currency, - merchant: transaction.merchant, - modifiedMerchant: transaction.modifiedMerchant, - created: transaction.created, - cardID: transaction.cardID, - cardName: transaction.cardName, - managedCard: transaction.managedCard, - bank: transaction.bank, - hasEReceipt: transaction.hasEReceipt, - liabilityType: transaction.comment?.liabilityType, - }; -} - -function summarizeReportAction(action: OnyxEntry) { - if (!action) { - return null; - } - const originalMessage = getOriginalMessage(action); - return { - reportActionID: action.reportActionID, - actionName: action.actionName, - childReportID: action.childReportID, - childType: action.childType, - actorAccountID: action.actorAccountID, - originalMessageType: originalMessage?.type, - IOUTransactionID: originalMessage?.IOUTransactionID, - IOUReportID: originalMessage?.IOUReportID, - }; -} - -function summarizeReport(report: OnyxEntry) { - if (!report) { - return null; - } - return { - reportID: report.reportID, - type: report.type, - chatType: report.chatType, - reportName: report.reportName, - policyID: report.policyID, - ownerAccountID: report.ownerAccountID, - managerID: report.managerID, - parentReportID: report.parentReportID, - parentReportActionID: report.parentReportActionID, - stateNum: report.stateNum, - statusNum: report.statusNum, - participantAccountIDs: Object.keys(report.participants ?? {}), - }; -} - -/** - * Transaction threads are plain chat threads with no `chatType` whose parent action is an IOU action. - * When the parent action has not loaded yet we cannot tell for sure, and that unresolved state is - * exactly what this issue is about, so it counts as a candidate. - */ -function isTransactionThreadCandidate(report: OnyxEntry, parentReportAction: OnyxEntry): boolean { - if (report?.type !== CONST.REPORT.TYPE.CHAT || !report.parentReportID || !report.parentReportActionID || report.chatType) { - return false; - } - if (parentReportAction) { - return parentReportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU; - } - return true; -} - -function buildDiagnostics(report: Report, snapshot: OnyxSnapshot, parentReportAction: OnyxEntry, resolvedName: string | undefined) { - const {reports, transactions, reportActions, personalDetailsList} = snapshot; - - const parentReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`]; - const parentReportActions = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`]; - const parentReportActionList = Object.values(parentReportActions ?? {}); - const actionPointingToThisThread = parentReportActionList.find((action) => action?.childReportID === report.reportID); - - const linkedTransactionID = getLinkedTransactionID(parentReportAction); - const linkedTransaction = linkedTransactionID ? transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${linkedTransactionID}`] : undefined; - - // Scanning the whole transaction collection is expensive, so only do it when something is actually missing - const shouldScanAllTransactions = !resolvedName || !linkedTransaction; - const transactionsOnParentReport = shouldScanAllTransactions ? Object.values(transactions ?? {}).filter((transaction) => transaction?.reportID === report.parentReportID) : []; - - const participants = Object.keys(report.participants ?? {}).map((accountID) => { - const personalDetails = personalDetailsList?.[accountID]; - return { - accountID, - hasPersonalDetails: !!personalDetails, - displayName: personalDetails?.displayName, - login: personalDetails?.login, - }; - }); - - let likelyCause = 'OK_NAME_RESOLVED'; - if (!resolvedName) { - if (!parentReport) { - likelyCause = 'PARENT_REPORT_MISSING'; - } else if (!parentReportActions) { - likelyCause = 'PARENT_REPORT_ACTIONS_NOT_LOADED'; - } else if (!parentReportAction) { - likelyCause = actionPointingToThisThread ? 'PARENT_REPORT_ACTION_ID_MISMATCH' : 'PARENT_REPORT_ACTION_MISSING'; - } else if (!linkedTransaction) { - likelyCause = 'TRANSACTION_MISSING'; - } else { - likelyCause = 'NAME_EMPTY_WITH_FULL_DATA'; - } - } - - return { - likelyCause, - threadKind: getThreadKind(parentReport), - transactionIsUnreported: isUnreportedTransaction(linkedTransaction), - resolvedName: resolvedName ?? null, - lastRenderedInLHNAs: lastLHNTextByReportID.get(report.reportID) ?? null, - willFallBackToParticipantNames: !resolvedName, - report: summarizeReport(report), - parentReport: summarizeReport(parentReport), - parentReportActionsLoaded: !!parentReportActions, - parentReportActionsCount: parentReportActionList.length, - parentReportAction: summarizeReportAction(parentReportAction), - actionPointingToThisThread: summarizeReportAction(actionPointingToThisThread), - linkedTransactionID: linkedTransactionID ?? null, - linkedTransaction: summarizeTransaction(linkedTransaction), - transactionsOnParentReport: transactionsOnParentReport.map(summarizeTransaction), - participants, - }; -} - -type ReportNameDiagnosticsParams = OnyxSnapshot & { - report: OnyxEntry; - parentReportAction: OnyxEntry; - resolvedName: string | undefined; -}; - -/** - * Dumps everything `computeReportName` needs to name a transaction thread, so we can tell which - * piece of data is missing when the LHN falls back to "Hidden". - */ -function logReportNameComputation({report, reports, transactions, reportActions, personalDetailsList, parentReportAction, resolvedName}: ReportNameDiagnosticsParams) { - lastSnapshot = {reports, transactions, reportActions, personalDetailsList}; - - if (!report || !isTransactionThreadCandidate(report, parentReportAction)) { - return; - } - - print('name', report.reportID, buildDiagnostics(report, lastSnapshot, parentReportAction, resolvedName)); -} - -/** - * Logs what the LHN row actually renders, so the computed name above can be matched with the visible row. - */ -function logSidebarRow(report: OnyxEntry, text: string, hiddenTranslation: string) { - if (!report) { - return; - } - lastLHNTextByReportID.set(report.reportID, text); - - const parentReportAction = getParentReportAction(report, lastSnapshot?.reportActions); - const showsHidden = !text || text === hiddenTranslation; - if (!showsHidden && !isTransactionThreadCandidate(report, parentReportAction)) { - return; - } - - print('lhn', report.reportID, { - showsHidden, - text, - reportID: report.reportID, - type: report.type, - chatType: report.chatType, - parentReportID: report.parentReportID, - parentReportActionID: report.parentReportActionID, - }); -} - -/** - * Call `dump95430()` in the console for an overview of every card expense and transaction thread, - * or `dump95430('')` for the full diagnostics of a single report. - */ -function dump95430(reportID?: string) { - if (!lastSnapshot) { - print('dump', 'no-snapshot', {error: 'No Onyx snapshot captured yet - open the LHN first'}, false); - return; - } - const {reports, transactions, reportActions} = lastSnapshot; - - if (reportID) { - const report = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; - if (!report) { - print('dump', reportID, {error: `Report ${reportID} is not in Onyx`}, false); - return; - } - print('dump', reportID, buildDiagnostics(report, lastSnapshot, getParentReportAction(report, reportActions), undefined), false); - return; - } - - const reportList = Object.values(reports ?? {}); - const threadsByParentActionID = new Map(); - for (const candidate of reportList) { - if (candidate?.parentReportActionID) { - threadsByParentActionID.set(candidate.parentReportActionID, candidate); - } - } - - const cardExpenses = Object.values(transactions ?? {}) - .filter(isCardTransaction) - .map((transaction) => { - const expenseReportActions = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transaction?.reportID}`]; - const iouAction = Object.values(expenseReportActions ?? {}).find((action) => getLinkedTransactionID(action) === transaction?.transactionID); - const thread = iouAction ? threadsByParentActionID.get(iouAction.reportActionID) : undefined; - return { - transactionID: transaction?.transactionID, - merchant: transaction?.modifiedMerchant ?? transaction?.merchant, - expenseReportID: transaction?.reportID, - isUnreported: isUnreportedTransaction(transaction), - expenseReportType: reports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`]?.type ?? null, - expenseReportActionsLoaded: !!expenseReportActions, - iouActionID: iouAction?.reportActionID ?? null, - threadReportIDFromAction: iouAction?.childReportID ?? null, - threadReportInOnyx: !!thread, - threadLastRenderedInLHNAs: thread ? (lastLHNTextByReportID.get(thread.reportID) ?? null) : null, - }; - }); - - const threads = reportList - .filter((candidate): candidate is Report => isTransactionThreadCandidate(candidate, getParentReportAction(candidate, reportActions))) - .map((thread) => { - const parentReportAction = getParentReportAction(thread, reportActions); - const parentReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${thread.parentReportID}`]; - return { - reportID: thread.reportID, - threadKind: getThreadKind(parentReport), - parentReportID: thread.parentReportID, - parentReportActionID: thread.parentReportActionID, - hasParentReport: !!parentReport, - hasParentReportAction: !!parentReportAction, - linkedTransactionID: getLinkedTransactionID(parentReportAction) ?? null, - lastRenderedInLHNAs: lastLHNTextByReportID.get(thread.reportID) ?? null, - }; - }); - - print( - 'dump', - 'overview', - { - reportCount: reportList.length, - transactionCount: Object.keys(transactions ?? {}).length, - cardExpenseCount: cardExpenses.length, - unreportedCardExpenseCount: cardExpenses.filter((cardExpense) => cardExpense.isUnreported).length, - transactionThreadCount: threads.length, - // Only these can hit the issue - if this is 0 the account has no data to reproduce with - expenseReportThreadCount: threads.filter((thread) => thread.threadKind === 'EXPENSE_REPORT_THREAD').length, - threadsMissingParentAction: threads.filter((thread) => !thread.hasParentReportAction).length, - cardExpenses, - threads, - }, - false, - ); -} - -/** The parent action removed by `break95430`, kept so `restore95430` can put it back */ -let removedParentAction: {reportActionsKey: `${typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS}${string}`; reportActionID: string; reportAction: ReportAction} | undefined; - -/** - * Forces the state this issue is about by deleting a thread's parent IOU action from Onyx, which is - * what the hydration race produces naturally. The LHN row for the thread should flip to "Hidden". - * Call `restore95430()` to put the action back. - */ -function break95430(threadReportID: string) { - const report = lastSnapshot?.reports?.[`${ONYXKEYS.COLLECTION.REPORT}${threadReportID}`]; - if (!report?.parentReportID || !report.parentReportActionID) { - print('break', threadReportID, {error: `Report ${threadReportID} is not a thread, or is not in Onyx`}, false); - return; - } - const reportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}` as const; - const reportAction = lastSnapshot?.reportActions?.[reportActionsKey]?.[report.parentReportActionID]; - if (!reportAction) { - print('break', threadReportID, {error: `Parent action ${report.parentReportActionID} is already absent`}, false); - return; - } - - removedParentAction = {reportActionsKey, reportActionID: report.parentReportActionID, reportAction}; - print('break', threadReportID, {removedParentActionID: report.parentReportActionID, fromReportActionsKey: reportActionsKey}, false); - // eslint-disable-next-line rulesdir/prefer-actions-set-data -- temporary debugging helper for issue #95430, not production code - Onyx.merge(reportActionsKey, {[report.parentReportActionID]: null}); -} - -/** Restores the parent action removed by `break95430` */ -function restore95430() { - if (!removedParentAction) { - print('restore', 'none', {error: 'Nothing to restore - call break95430(threadReportID) first'}, false); - return; - } - const {reportActionsKey, reportActionID, reportAction} = removedParentAction; - removedParentAction = undefined; - print('restore', reportActionID, {restoredInto: reportActionsKey}, false); - // eslint-disable-next-line rulesdir/prefer-actions-set-data -- temporary debugging helper for issue #95430, not production code - Onyx.merge(reportActionsKey, {[reportActionID]: reportAction}); -} - -Object.assign(globalThis, {dump95430, break95430, restore95430}); - -export {break95430, dump95430, logReportNameComputation, logSidebarRow, restore95430}; diff --git a/src/libs/Debug96732.ts b/src/libs/Debug96732.ts deleted file mode 100644 index 280c26a0a016..000000000000 --- a/src/libs/Debug96732.ts +++ /dev/null @@ -1,372 +0,0 @@ -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {Transaction, TransactionViolation, TransactionViolations} from '@src/types/onyx'; - -import type {OnyxCollection, OnyxKey, OnyxUpdate} from 'react-native-onyx'; - -import {format} from 'date-fns'; -/** - * TEMPORARY instrumentation for https://github.com/Expensify/App/issues/96732 - * ("Future date not allowed" briefly displays and disappears). - * - * Every line is printed as `[96732][] {...json...}` on a single line so it can be filtered - * with a plain text search in the browser console, Metro or `adb logcat`. - * - * What each event proves: - * - `compute` - the client-side futureDate decision inside `ViolationsUtils.getViolationsOnyxData`. - * Includes both the current `new Date(...)` + `isFutureDay` result and a - * timezone-safe `yyyy-MM-dd` string comparison, so a disagreement between the two - * exposes the UTC-midnight parsing bug. - * - `create-optimistic` - the violations array the create flow pushes to `optimisticData` (and the fact - * that no `successData` re-asserts it). - * - `server` - the raw `transactionViolations_*` payload the backend sent back, tagged with the - * API command. This is the decisive one: it shows whether the server returns - * `futureDate` at all, and for which dates. - * - `write` - every net change to `transactionViolations_` in Onyx, with before/after. - * A `write` that drops `futureDate` with NO `compute` line immediately before it - * means the removal came from the server, not from a client recompute. - * - * `dump96732()` and `dump96732('')` are exposed globally for on-demand inspection. - * - * Delete this file together with its call sites in `ViolationsUtils.ts`, `MoneyRequestBuilder.ts` - * and `actions/OnyxUpdates.ts` once the root cause is confirmed. - */ -import Onyx from 'react-native-onyx'; - -/** Search for this string to find every line produced by this module */ -const DEBUG_TAG = '[96732]'; - -/** `getViolationsOnyxData` runs on nearly every Onyx tick, so identical payloads are only printed once */ -const lastPayloadByKey = new Map(); - -/** Previous value of every `transactionViolations_*` key, so writes can be logged as before -> after */ -const lastViolationsByTransactionID = new Map(); - -/** Latest transaction collection, used to enrich violation writes with the expense date */ -let lastTransactions: OnyxCollection; - -function print(event: string, dedupeKey: string, payload: Record, shouldDedupe = true) { - const serialized = JSON.stringify(payload); - const key = `${event}:${dedupeKey}`; - if (shouldDedupe && lastPayloadByKey.get(key) === serialized) { - return; - } - lastPayloadByKey.set(key, serialized); - // eslint-disable-next-line no-console -- temporary debugging output for issue #96732 - console.log(`${DEBUG_TAG}[${event}] ${serialized}`); -} - -function hasFutureDate(violations: TransactionViolations | undefined): boolean { - return !!violations?.some((violation) => violation.name === CONST.VIOLATIONS.FUTURE_DATE); -} - -function violationNames(violations: TransactionViolations | undefined): string[] { - return (violations ?? []).map((violation) => violation.name); -} - -/** The date the app is actually using for this expense, mirroring `TransactionUtils.getCreated` */ -function getEffectiveCreated(transaction: OnyxEntryLike): string { - return transaction?.modifiedCreated ? transaction.modifiedCreated : (transaction?.created ?? ''); -} - -type OnyxEntryLike = T | undefined | null; - -function summarizeTransactionDates(transaction: OnyxEntryLike) { - if (!transaction) { - return null; - } - return { - transactionID: transaction.transactionID, - reportID: transaction.reportID, - created: transaction.created, - // Empty string here is what breaks `modifiedCreated ?? created` in ViolationsUtils - modifiedCreated: transaction.modifiedCreated, - modifiedCreatedIsEmptyString: transaction.modifiedCreated === '', - effectiveCreated: getEffectiveCreated(transaction), - merchant: transaction.modifiedMerchant ?? transaction.merchant, - amount: transaction.modifiedAmount ?? transaction.amount, - pendingAction: transaction.pendingAction, - }; -} - -type FutureDateComputeParams = { - /** Where the compute was triggered from, e.g. `create` or `edit` */ - caller: string; - transaction: OnyxEntryLike; - policyType: string | undefined; - policyID: string | undefined; - isInvoiceTransaction: boolean; - isControlPolicy: boolean; - /** The `new Date(modifiedCreated ?? created)` value ViolationsUtils actually built */ - inputDate: Date; - /** Result of `DateUtils.isFutureDay(inputDate)` */ - isFutureDayResult: boolean; - shouldDisplayFutureDateViolation: boolean; - /** Whether the incoming violations array already had `futureDate` */ - hadFutureDateViolation: boolean; - violationsIn: TransactionViolations; - violationsOut: TransactionViolations; -}; - -/** - * Logs the client-side futureDate decision. The two `isFuture*` fields are computed independently: - * when `isFutureDayResult` and `isFutureByStringCompare` disagree, the UTC-midnight parse in - * `ViolationsUtils.ts` is the reason the violation is missing (or present) for that user's timezone. - */ -function logFutureDateCompute({ - caller, - transaction, - policyType, - policyID, - isInvoiceTransaction, - isControlPolicy, - inputDate, - isFutureDayResult, - shouldDisplayFutureDateViolation, - hadFutureDateViolation, - violationsIn, - violationsOut, -}: FutureDateComputeParams) { - const effectiveCreated = getEffectiveCreated(transaction); - const todayLocal = format(new Date(), CONST.DATE.FNS_FORMAT_STRING); - const inputDateIsValid = !Number.isNaN(inputDate.getTime()); - - print('compute', transaction?.transactionID ?? 'unknown', { - caller, - transactionID: transaction?.transactionID, - policyID, - policyType, - isControlPolicy, - isInvoiceTransaction, - - // --- date inputs --- - created: transaction?.created, - modifiedCreated: transaction?.modifiedCreated, - modifiedCreatedIsEmptyString: transaction?.modifiedCreated === '', - effectiveCreated, - - // --- how ViolationsUtils reads that date today --- - inputDateIsValid, - inputDateISO: inputDateIsValid ? inputDate.toISOString() : 'Invalid Date', - isFutureDayResult, - - // --- timezone-safe comparison for reference --- - todayLocal, - isFutureByStringCompare: !!effectiveCreated && effectiveCreated > todayLocal, - timezoneOffsetMinutes: new Date().getTimezoneOffset(), - // true => the current implementation and a plain calendar-date compare disagree - timezoneParsingMismatch: isFutureDayResult !== (!!effectiveCreated && effectiveCreated > todayLocal), - - // --- decision --- - shouldDisplayFutureDateViolation, - hadFutureDateViolation, - violationsIn: violationNames(violationsIn), - violationsOut: violationNames(violationsOut), - futureDateInResult: hasFutureDate(violationsOut), - }); -} - -/** - * Logs the violations array the create flow writes to `optimisticData`. `hasSuccessDataForViolations` - * is expected to be false - that is the gap this issue is about. - */ -function logCreateOptimisticViolations(transaction: OnyxEntryLike, violations: unknown, hasSuccessDataForViolations: boolean) { - const violationList = Array.isArray(violations) ? (violations as TransactionViolations) : undefined; - print( - 'create-optimistic', - transaction?.transactionID ?? 'unknown', - { - transactionID: transaction?.transactionID, - transaction: summarizeTransactionDates(transaction), - onyxMethod: 'SET', - optimisticViolations: violationNames(violationList), - futureDateInOptimistic: hasFutureDate(violationList), - // If this is false, nothing re-asserts the violation after the API responds - hasSuccessDataForViolations, - }, - false, - ); -} - -/** - * Logs every `transactionViolations_*` payload the backend sends, tagged with the API command that - * produced it. This is what tells us whether the server computes `futureDate` at all. - */ -function logServerViolations(source: string, command: string, updates: Array> | undefined) { - if (!updates?.length) { - return; - } - for (const update of updates) { - const key = (update as {key?: string}).key ?? ''; - if (!key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS)) { - continue; - } - const transactionID = key.slice(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS.length); - const value = (update as {value?: unknown}).value; - const violations = Array.isArray(value) ? (value as TransactionViolations) : undefined; - print( - 'server', - transactionID, - { - source, - command, - transactionID, - onyxMethod: (update as {onyxMethod?: string}).onyxMethod, - serverViolations: violations ? violationNames(violations) : value, - futureDateFromServer: hasFutureDate(violations), - transaction: summarizeTransactionDates(lastTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]), - }, - false, - ); - } -} - -/** Logs the raw onyxData a WRITE response carried, so we can see the server's transaction payload too */ -function logServerTransactionDates(source: string, command: string, updates: Array> | undefined) { - if (!updates?.length) { - return; - } - for (const update of updates) { - const key = (update as {key?: string}).key ?? ''; - if (!key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION) || key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS)) { - continue; - } - const value = (update as {value?: unknown}).value as Transaction | undefined; - if (!value || (value.created === undefined && value.modifiedCreated === undefined)) { - continue; - } - print( - 'server-transaction', - key, - { - source, - command, - key, - onyxMethod: (update as {onyxMethod?: string}).onyxMethod, - created: value.created, - modifiedCreated: value.modifiedCreated, - modifiedCreatedIsEmptyString: value.modifiedCreated === '', - }, - false, - ); - } -} - -// We use `connectWithoutView` because this module is pure instrumentation and renders nothing. -Onyx.connectWithoutView({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - callback: (transactions) => { - lastTransactions = transactions; - }, -}); - -Onyx.connectWithoutView({ - key: ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, - callback: (violationsCollection) => { - for (const [collectionKey, violations] of Object.entries(violationsCollection ?? {})) { - const transactionID = collectionKey.slice(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS.length); - const after = violationNames(violations); - const before = lastViolationsByTransactionID.get(transactionID); - if (before && JSON.stringify(before) === JSON.stringify(after)) { - continue; - } - lastViolationsByTransactionID.set(transactionID, after); - - const hadFutureDate = !!before?.includes(CONST.VIOLATIONS.FUTURE_DATE); - const hasFutureDateNow = after.includes(CONST.VIOLATIONS.FUTURE_DATE); - - let verdict = 'UNCHANGED_FUTURE_DATE'; - if (!hadFutureDate && hasFutureDateNow) { - verdict = 'FUTURE_DATE_ADDED'; - } else if (hadFutureDate && !hasFutureDateNow) { - // This is the disappearing red brick road. Check whether a `compute` line was printed - // immediately before it - if not, the removal came from the server payload. - verdict = 'FUTURE_DATE_REMOVED'; - } - - print( - 'write', - transactionID, - { - transactionID, - verdict, - before: before ?? null, - after, - transaction: summarizeTransactionDates(lastTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]), - }, - false, - ); - } - - // A deleted collection member produces no entry above, so removals are detected by diffing the keys. - // `transactionViolations_: null` from the server lands here. - for (const [transactionID, before] of lastViolationsByTransactionID.entries()) { - if (violationsCollection?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]) { - continue; - } - lastViolationsByTransactionID.delete(transactionID); - - print( - 'write', - transactionID, - { - transactionID, - verdict: before.includes(CONST.VIOLATIONS.FUTURE_DATE) ? 'FUTURE_DATE_REMOVED_BY_KEY_DELETE' : 'KEY_DELETED', - before, - after: null, - transaction: summarizeTransactionDates(lastTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]), - }, - false, - ); - } - }, -}); - -/** - * Call `dump96732()` in the console for every transaction that currently has violations, or - * `dump96732('')` for a single expense. - */ -function dump96732(transactionID?: string) { - const todayLocal = format(new Date(), CONST.DATE.FNS_FORMAT_STRING); - - if (transactionID) { - const transaction = lastTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; - const violations = lastViolationsByTransactionID.get(transactionID); - print( - 'dump', - transactionID, - { - todayLocal, - timezoneOffsetMinutes: new Date().getTimezoneOffset(), - transaction: summarizeTransactionDates(transaction), - currentViolations: violations ?? null, - }, - false, - ); - return; - } - - const entries = [...lastViolationsByTransactionID.entries()].map(([id, violations]) => ({ - transactionID: id, - violations, - transaction: summarizeTransactionDates(lastTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`]), - })); - - print( - 'dump', - 'overview', - { - todayLocal, - timezoneOffsetMinutes: new Date().getTimezoneOffset(), - trackedTransactionCount: entries.length, - withFutureDate: entries.filter((entry) => entry.violations.includes(CONST.VIOLATIONS.FUTURE_DATE)).map((entry) => entry.transactionID), - entries, - }, - false, - ); -} - -Object.assign(globalThis, {dump96732}); - -export {dump96732, logCreateOptimisticViolations, logFutureDateCompute, logServerTransactionDates, logServerViolations}; -export type {TransactionViolation}; diff --git a/src/libs/Write merchant-rules-test.csv (po4841) b/src/libs/Write merchant-rules-test.csv (po4841) deleted file mode 100644 index 8092011dea50..000000000000 --- a/src/libs/Write merchant-rules-test.csv (po4841) +++ /dev/null @@ -1,10 +0,0 @@ -Merchant is,Merchant contains,Updated merchant,Category,Tag,Description,Reimbursable,Billable -STARBUCKS #1234,,Starbucks,Meals and entertainment,,,yes, -,uber,Uber,Travel,,,,no -,amzn mktp,Amazon,Office supplies,,"Office supplies, restocked monthly",, -DELTA AIR LINES,,Delta,Travel,,,true,false -,shell oil,,,,,no,yes -,notarealmerchant,Ghost Vendor,ThisCategoryDoesNotExist,,,, -STARBUCKS #1234,,Starbucks,Meals and entertainment,,,yes, -,lyft,,,,,, -,,Orphan Merchant,Travel,,,, From f394b4596e6f69c056c513fc40e3f41a59c6d28d Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 24 Sep 2026 08:50:27 +0530 Subject: [PATCH 32/80] refactor: tidy the typed date field to match codebase conventions Signed-off-by: krishna2323 --- src/components/DatePicker/index.tsx | 13 +-- src/components/DateSegmentsInput.tsx | 64 ++++--------- .../TextInput/BaseTextInput/types.ts | 2 +- src/hooks/useDateSegmentInput.ts | 90 +++++++------------ src/libs/DateInputMaskUtils.ts | 1 - tests/unit/DateInputMaskUtilsTest.ts | 9 -- 6 files changed, 52 insertions(+), 127 deletions(-) diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index d78e9cb5a6da..583b9958b901 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -204,12 +204,6 @@ function DatePicker({ [showDatePickerModal], ); - // Reaching the field by keyboard never fires a press, so focus is what opens the calendar once typing is allowed. - // The segments report their own focus to the hook, so there is nothing to seed here. - const handleFocus = () => { - showDatePickerModal(); - }; - const handleInputKeyPress = useCallback( (event: TextInputKeyPressEvent) => { if (!isNumeric(event.nativeEvent.key)) { @@ -308,14 +302,14 @@ function DatePicker({ mask: dateMask, getSegmentProps: segmentInput.getSegmentProps, setSegmentRef: segmentInput.setSegmentRef, - focusSegment: segmentInput.focusSegment, + focusFirstUnfilledSegment: segmentInput.focusFirstUnfilledSegment, isSegmentElement: segmentInput.isSegmentElement, isAllSelected: segmentInput.isAllSelected, onFieldBlur: segmentInput.onFieldBlur, } : undefined } - value={segmentInput.displayValue} + value={selectedDate} placeholder={placeholder ?? dateMask} errorText={errorText} inputStyle={shouldAllowTyping ? undefined : styles.pointerEventsNone} @@ -323,7 +317,8 @@ function DatePicker({ hideFocusedState={shouldDismissKeyboardBeforeShow && !shouldAllowTyping} onPress={shouldDismissKeyboardBeforeShow || shouldAllowTyping ? handlePress : () => showDatePickerModal()} onSubmitEditing={shouldAllowTyping ? undefined : () => showDatePickerModal()} - onFocus={shouldAllowTyping ? handleFocus : undefined} + // Reaching the field by keyboard never fires a press, so focus is what opens the calendar + onFocus={shouldAllowTyping ? showDatePickerModal : undefined} onKeyPress={shouldAllowTyping ? undefined : handleInputKeyPress} textInputContainerStyles={isModalVisible ? styles.borderColorFocus : {}} shouldHideClearButton={shouldHideClearButton} diff --git a/src/components/DateSegmentsInput.tsx b/src/components/DateSegmentsInput.tsx index 51b26de55346..3ba7dfad0d17 100644 --- a/src/components/DateSegmentsInput.tsx +++ b/src/components/DateSegmentsInput.tsx @@ -1,16 +1,13 @@ /** - * The inner part of the date field, rendering one input per date segment instead of one input for the whole date. It is - * registered as a `BaseTextInput` input type, so it sits inside the same label, border and error chrome as any other - * text field. - * - * One input per segment is what makes the caret a non issue: moving between segments is an ordinary focus change, so - * nothing here has to reason about where a caret sits inside a longer string. + * The inner part of the date field, rendering one input per date segment. It is registered as a `BaseTextInput` input + * type, so it sits inside the same label, border and error chrome as any other text field. */ import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import {isSafari} from '@libs/Browser'; import {getDateMaskParts} from '@libs/DateInputMaskUtils'; import type {DateSegmentName} from '@libs/DateInputMaskUtils'; import mergeRefs from '@libs/mergeRefs'; @@ -29,11 +26,8 @@ import {PressableWithoutFeedback} from './Pressable'; import RNTextInput from './RNTextInput'; import Text from './Text'; -/** Leaves the caret somewhere to sit, since a segment sized to its text exactly would clip it at the end */ -const CARET_ALLOWANCE = 1; - -/** Used until the real text has been measured, so the first paint is close rather than collapsed */ -const ESTIMATED_CHARACTER_WIDTH = CONST.CHARACTER_WIDTH; +/** Safari clips the caret of an input sized to its text exactly, the same allowance TextInputMeasurement makes */ +const SAFARI_CARET_ALLOWANCE = 2; /** Reads back the padding the field hands its text, which is what a selection has to leave alone to cover the line */ function getVerticalPadding(inputStyle: BaseTextInputProps['style']): ViewStyle { @@ -61,32 +55,19 @@ function DateSegmentsInput({ const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); - // The mask letters are wider than the digits that replace them, so a fixed width would either clip the placeholder - // or leave a gap once the segment is filled in. Each one is measured from the text it is currently showing. + // Mask letters are wider than the digits that replace them, so each segment is measured from the text it shows const [measuredWidths, setMeasuredWidths] = useState>>({}); if (!dateSegmentsConfig) { return null; } - const {mask, getSegmentProps, setSegmentRef, focusSegment, isSegmentElement, isAllSelected, onFieldBlur} = dateSegmentsConfig; + const {mask, getSegmentProps, setSegmentRef, focusFirstUnfilledSegment, isSegmentElement, isAllSelected, onFieldBlur} = dateSegmentsConfig; const parts = getDateMaskParts(mask).map((part) => ({...part, segmentProps: getSegmentProps(part.name)})); + const caretAllowance = isSafari() ? SAFARI_CARET_ALLOWANCE : 0; - const focusFirstUnfilledSegment = () => { - const target = parts.find((part) => part.segmentProps.value.length < part.placeholder.length) ?? parts.at(-1); - - if (!target) { - return; - } - - focusSegment(target.name); - }; - - /** - * The field's own blur handler is what a form hangs its validation on, so it only runs once focus leaves the whole - * field rather than on every hop between segments. - */ + // A form hangs its validation on the field's blur, so it only runs once focus leaves the whole field const handleSegmentBlur: NonNullable = (event) => { if ('relatedTarget' in event.nativeEvent && isSegmentElement(event.nativeEvent.relatedTarget)) { return; @@ -96,16 +77,12 @@ function DateSegmentsInput({ onBlur?.(event); }; - // The separators belong to the mask rather than to the date, so they read as placeholder text for as long as any - // of the mask is still on screen + // The separators belong to the mask, so they read as placeholder text while any of it is on screen const isAnyMaskShowing = parts.some((part) => part.segmentProps.value.length < part.placeholder.length); - // The style the field hands down stretches its input across the whole row, which would spread the segments evenly. - // Everything here is sized to its own text instead, so the date reads as one run of text. + // The field's input style stretches across the row, which would spread the segments apart const sizedToContent = [styles.flexGrow0, styles.flexShrink0, styles.flexBasisAuto, styles.wAuto]; - // The segments stretch down the row as a single input would, so the padding they inherit lands their text in the - // same place as any other text field's return ( {/* Sized to the date rather than to the row, so selecting it covers the digits and not the space after them */} - {/* Drawn behind the segments so it cannot move them. Taking the field's vertical padding leaves exactly - the line of text to fill, so the selection needs no measuring to line up with it. */} + {/* Taking the field's vertical padding leaves exactly the line of text to fill */} {isAllSelected && ( @@ -123,11 +99,10 @@ function DateSegmentsInput({ )} {parts.map((part) => { const segmentProps = part.segmentProps; - // The mask letters a half typed segment has not reached yet, which stay on screen in the placeholder - // color so the shape of the date is still readable while it is being filled in. + // The mask letters a half typed segment has not reached yet stay on screen, so the date keeps its shape const remainder = part.placeholder.slice(segmentProps.value.length); const measuredText = `${segmentProps.value}${remainder}`; - const width = (measuredWidths[part.name] ?? measuredText.length * ESTIMATED_CHARACTER_WIDTH) + CARET_ALLOWANCE; + const width = (measuredWidths[part.name] ?? measuredText.length * CONST.CHARACTER_WIDTH) + caretAllowance; return ( @@ -148,8 +123,7 @@ function DateSegmentsInput({ > {measuredText} - {/* A row, because the `flex: 1` the field hands its input takes its zero basis down the main - axis, and a column would leave the segment with no height of its own */} + {/* A row, since the input's `flex: 1` would take a column's height away */} - {/* The digits already typed are repeated invisibly so the text flow puts the remaining mask - letters exactly where the input's own text ends, without measuring anything. */} + {/* The typed digits repeat invisibly so the mask letters start where the input's text ends */} {!!remainder && ( - {/* A single input filled the row, so clicking anywhere in the field focused it. This takes the space left - over after the day, so pressing it still lands somewhere useful rather than doing nothing. */} void; - /** Moves focus to a segment and rests the caret after its digits */ - focusSegment: (name: DateSegmentName) => void; + /** Where a press on the field rather than on a segment lands */ + focusFirstUnfilledSegment: () => void; /** Whether focus is going to another segment, which is moving within the field rather than leaving it */ isSegmentElement: (target: unknown) => boolean; @@ -107,25 +103,22 @@ function isMoveKey(key: string): key is keyof typeof MOVE_KEYS { return key in MOVE_KEYS; } -export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: UseDateSegmentInputParams): UseDateSegmentInputResult { +function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: UseDateSegmentInputParams): UseDateSegmentInputResult { // The segments only describe an edit in progress, so they are seeded on focus rather than synced with the value const [segments, setSegments] = useState(EMPTY_SEGMENTS); const [isEditing, setIsEditing] = useState(false); const segmentRefs = useRef>>({}); - // The month the calendar should show, which follows the typed date once the year is complete const [viewDate, setViewDate] = useState(undefined); const [viewDateVersion, setViewDateVersion] = useState(0); - // Whether the next digit replaces the segment instead of extending it, set on arriving at a segment + // Set on arriving at a segment, so the next digit replaces it instead of extending it const [shouldOverwrite, setShouldOverwrite] = useState(false); const [appliedValue, setAppliedValue] = useState(value); - // Whether the field was left holding digits that do not add up to a date, which stay on screen so the user can see - // what still has to be corrected + // Digits that do not add up to a date stay on screen after the field is left, so the user can correct them const [hasInvalidEntry, setHasInvalidEntry] = useState(false); // Native selection cannot span separate inputs, so selecting the whole date is tracked here and drawn by the field const [isAllSelected, setIsAllSelected] = useState(false); - // A date set from outside, by the calendar or by a restored draft, has to reach the segments as well. Without this - // an edit in progress would keep showing the date it started from, since the segments are what the field renders. + // A date set from outside, by the calendar or a restored draft, has to reach an edit in progress too if (value !== appliedValue) { setAppliedValue(value); @@ -148,10 +141,7 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, const isSegmentElement = (target: unknown) => !!target && Object.values(segmentRefs.current).some((element) => element === target); - /** - * Arriving at a segment rests the caret after the digits already in it, rather than wherever the browser last left - * the caret there, so a half typed month reads as 02 and not 0 followed by a caret and a 2. - */ + /** Rests the caret after the segment's digits, so a half typed month reads as 02 and not 0, a caret, then 2 */ const focusSegment = (name: DateSegmentName) => { const element = segmentRefs.current[name]; element?.focus(); @@ -160,25 +150,20 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, element?.setSelectionRange?.(caretPosition, caretPosition); }; - /** Landing on a segment arms the overwrite, so the next digit replaces what is there rather than extending it */ const enterSegment = (name: DateSegmentName) => { setShouldOverwrite(true); focusSegment(name); }; /** - * Reports what the segments now amount to, on every keystroke rather than once the date is finished. A form reads - * its value when it is submitted, which can happen before the field has even been left, so an entry part way - * through has to read as no date at all instead of leaving the date from before the edit in place. + * Runs on every keystroke rather than once the date is finished. A form can be submitted before the field is left, + * so an entry part way through has to read as no date at all rather than the date from before the edit. */ const commitSegments = (newSegments: DateSegments) => { onCommit(getISODateFromSegments(newSegments) ?? ''); }; - /** - * Asks the calendar to follow the input. The count is what carries the request, since the user can have moved the - * calendar elsewhere with its arrows or its month picker and then typed the month it was already showing. - */ + /** The count carries the request, since the user may have moved the calendar away and typed the month it was on */ const assertViewDate = (nextViewDate: Date | undefined) => { if (!nextViewDate) { return; @@ -188,10 +173,7 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, setViewDateVersion((version) => version + 1); }; - /** - * The one place segments are written, so the calendar cannot fall out of step with them. The month already on - * screen is the fallback while the typed month is unfinished, which keeps the calendar where the user left it. - */ + /** The one place segments are written, so the calendar cannot fall out of step with them */ const applySegments = (newSegments: DateSegments) => { setSegments(newSegments); assertViewDate(getViewDateFromSegments(newSegments, (viewDate ?? new Date()).getMonth(), minDate, maxDate)); @@ -235,10 +217,9 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, return; } - if (key === BACKSPACE_KEY || key === DELETE_KEY) { + if (key === CONST.KEYBOARD_SHORTCUTS.BACKSPACE.shortcutKey || key === DELETE_KEY) { event.preventDefault(); - // Deleting the whole date leaves the user at the start of the empty one, ready to type it again if (wasAllSelected) { applySegments(EMPTY_SEGMENTS); enterSegment(FIRST_SEGMENT_NAME); @@ -283,10 +264,6 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, enterSegment(LAST_SEGMENT_NAME); }; - /** - * A segment reporting that it now holds focus, whether the user clicked it or a keystroke sent them there. Arriving - * at a segment always arms the overwrite, so the first digit replaces what is already in it. - */ const handleSegmentFocus = () => { setShouldOverwrite(true); @@ -302,10 +279,6 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, setIsEditing(true); }; - /** - * Digits that do not add up to a date stay on screen once the field is left, so the user can see what still has to - * be corrected. The date they report was already emptied as they were typed. - */ const handleFieldBlur = () => { setIsEditing(false); setViewDate(undefined); @@ -320,10 +293,7 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, } }; - /** - * Emptying the field puts the user back at its first segment. The clear button unmounts as it is pressed, so the - * press lands on whatever is underneath, and leaving it to decide where focus goes makes that a race. - */ + // The clear button unmounts as it is pressed, so focus is placed here rather than left to whatever is underneath const handleClear = () => { setSegments(EMPTY_SEGMENTS); setHasInvalidEntry(false); @@ -334,10 +304,8 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, if (!isEnabled) { return { - displayValue: value, - isEditing: false, setSegmentRef: () => {}, - focusSegment: () => {}, + focusFirstUnfilledSegment: () => {}, isSegmentElement: () => false, viewDate: undefined, viewDateVersion: 0, @@ -349,16 +317,17 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, }; } - // The segments describe an edit in progress or one left unusable, so outside of those the committed date is what - // the field has to show + // Outside an edit in progress or one left unusable, the committed date is what the field shows const shouldShowSegments = isEditing || hasInvalidEntry; const displayedSegments = shouldShowSegments ? segments : getSegmentsFromISODate(value); + const focusFirstUnfilledSegment = () => { + focusSegment(getFirstUnfilledSegmentName(displayedSegments) ?? LAST_SEGMENT_NAME); + }; + return { - displayValue: value, - isEditing, setSegmentRef, - focusSegment, + focusFirstUnfilledSegment, isSegmentElement, viewDate: isEditing ? viewDate : undefined, viewDateVersion, @@ -376,4 +345,5 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, }; } +export default useDateSegmentInput; export type {UseDateSegmentInputParams, UseDateSegmentInputResult}; diff --git a/src/libs/DateInputMaskUtils.ts b/src/libs/DateInputMaskUtils.ts index b3cefeac4dea..25c86bb61406 100644 --- a/src/libs/DateInputMaskUtils.ts +++ b/src/libs/DateInputMaskUtils.ts @@ -308,7 +308,6 @@ export { getFirstUnfilledSegmentName, getISODateFromSegments, getSegmentDisplay, - getSegmentLength, getSegmentsFromISODate, getSegmentsFromText, getViewDateFromSegments, diff --git a/tests/unit/DateInputMaskUtilsTest.ts b/tests/unit/DateInputMaskUtilsTest.ts index f1ce46ae64e2..f062c320ca91 100644 --- a/tests/unit/DateInputMaskUtilsTest.ts +++ b/tests/unit/DateInputMaskUtilsTest.ts @@ -4,7 +4,6 @@ import { getFirstUnfilledSegmentName, getISODateFromSegments, getSegmentDisplay, - getSegmentLength, getSegmentsFromISODate, getSegmentsFromText, getViewDateFromSegments, @@ -159,14 +158,6 @@ describe('DateInputMaskUtils', () => { }); }); - describe('getSegmentLength', () => { - it('gives the year twice the digits of the others', () => { - expect(getSegmentLength('year')).toBe(4); - expect(getSegmentLength('month')).toBe(2); - expect(getSegmentLength('day')).toBe(2); - }); - }); - describe('getAdjacentSegmentName', () => { it('moves between segments', () => { expect(getAdjacentSegmentName('year', 1)).toBe('month'); From ef67e16b188803c5bc903ff07a52c3fd35a3cfdc Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 24 Sep 2026 08:53:54 +0530 Subject: [PATCH 33/80] chore: stop exporting the date segment hook's params type Signed-off-by: krishna2323 --- src/hooks/useDateSegmentInput.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index 6d489f4af54f..9f142ff2adaa 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -346,4 +346,4 @@ function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: Use } export default useDateSegmentInput; -export type {UseDateSegmentInputParams, UseDateSegmentInputResult}; +export type {UseDateSegmentInputResult}; From 95ae9b029a9417bed490ac8a204904ea8ec6868b Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 24 Sep 2026 08:59:23 +0530 Subject: [PATCH 34/80] fix: keep the date mask on one line Signed-off-by: krishna2323 --- src/components/DateSegmentsInput.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/components/DateSegmentsInput.tsx b/src/components/DateSegmentsInput.tsx index 3ba7dfad0d17..780e3d42d886 100644 --- a/src/components/DateSegmentsInput.tsx +++ b/src/components/DateSegmentsInput.tsx @@ -7,7 +7,6 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {isSafari} from '@libs/Browser'; import {getDateMaskParts} from '@libs/DateInputMaskUtils'; import type {DateSegmentName} from '@libs/DateInputMaskUtils'; import mergeRefs from '@libs/mergeRefs'; @@ -26,8 +25,8 @@ import {PressableWithoutFeedback} from './Pressable'; import RNTextInput from './RNTextInput'; import Text from './Text'; -/** Safari clips the caret of an input sized to its text exactly, the same allowance TextInputMeasurement makes */ -const SAFARI_CARET_ALLOWANCE = 2; +/** A measured width is rounded to a whole pixel, which can leave it short of the text and wrap the mask */ +const WIDTH_ROUNDING_ALLOWANCE = 1; /** Reads back the padding the field hands its text, which is what a selection has to leave alone to cover the line */ function getVerticalPadding(inputStyle: BaseTextInputProps['style']): ViewStyle { @@ -65,7 +64,6 @@ function DateSegmentsInput({ const {mask, getSegmentProps, setSegmentRef, focusFirstUnfilledSegment, isSegmentElement, isAllSelected, onFieldBlur} = dateSegmentsConfig; const parts = getDateMaskParts(mask).map((part) => ({...part, segmentProps: getSegmentProps(part.name)})); - const caretAllowance = isSafari() ? SAFARI_CARET_ALLOWANCE : 0; // A form hangs its validation on the field's blur, so it only runs once focus leaves the whole field const handleSegmentBlur: NonNullable = (event) => { @@ -102,7 +100,7 @@ function DateSegmentsInput({ // The mask letters a half typed segment has not reached yet stay on screen, so the date keeps its shape const remainder = part.placeholder.slice(segmentProps.value.length); const measuredText = `${segmentProps.value}${remainder}`; - const width = (measuredWidths[part.name] ?? measuredText.length * CONST.CHARACTER_WIDTH) + caretAllowance; + const width = (measuredWidths[part.name] ?? measuredText.length * CONST.CHARACTER_WIDTH) + WIDTH_ROUNDING_ALLOWANCE; return ( From c070ec0886e4beb3f45748f7e886eeb17aaf174a Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Sun, 27 Sep 2026 03:42:33 +0530 Subject: [PATCH 35/80] fix: paste into the segment the user is in, rather than rebuilding the date from the year Signed-off-by: krishna2323 --- src/hooks/useDateSegmentInput.ts | 15 ++++++--- src/libs/DateInputMaskUtils.ts | 32 ++++++++++++++----- tests/unit/DateInputMaskUtilsTest.ts | 46 +++++++++++++++++++++++++--- 3 files changed, 76 insertions(+), 17 deletions(-) diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index 9f142ff2adaa..9499909300b0 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -253,15 +253,20 @@ function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: Use }; // Every keystroke is prevented, so this only runs for text the user pasted in - const handleChangeText = (text: string) => { - const pastedSegments = getSegmentsFromText(text); - if (!hasAnySegment(pastedSegments)) { + const handleChangeText = (name: DateSegmentName, text: string) => { + // A paste over the whole date replaces it, while one into a single segment merges into what is already there + const baseSegments = isAllSelected ? EMPTY_SEGMENTS : segments; + const pastedSegments = getSegmentsFromText(text, name, baseSegments); + + // Text with no digits in it leaves every segment as it was, so there is nothing to apply and nowhere to move + if (pastedSegments === baseSegments) { return; } setIsAllSelected(false); applySegments(pastedSegments); - enterSegment(LAST_SEGMENT_NAME); + // A paste that fills only part of the date leaves the user at the digits they still have to enter + enterSegment(getFirstUnfilledSegmentName(pastedSegments) ?? LAST_SEGMENT_NAME); }; const handleSegmentFocus = () => { @@ -336,7 +341,7 @@ function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: Use getSegmentProps: (name: DateSegmentName) => ({ value: getSegmentDisplay(displayedSegments, name), onKeyPress: (event: TextInputKeyPressEvent) => handleKeyPress(name, event), - onChangeText: handleChangeText, + onChangeText: (text: string) => handleChangeText(name, text), onFocus: handleSegmentFocus, onPressOut: () => setIsAllSelected(false), }), diff --git a/src/libs/DateInputMaskUtils.ts b/src/libs/DateInputMaskUtils.ts index 25c86bb61406..0f0b717b0d8d 100644 --- a/src/libs/DateInputMaskUtils.ts +++ b/src/libs/DateInputMaskUtils.ts @@ -262,18 +262,36 @@ function getFirstUnfilledSegmentName(segments: DateSegments): DateSegmentName | return DATE_SEGMENT_NAMES.find((name) => segments[name].length < getSegmentLength(name)); } -/** Fills the segments from arbitrary text, so pasting a date works without going through it a keystroke at a time */ -function getSegmentsFromText(text: string): DateSegments { +/** + * Fills the segments from arbitrary text, so pasting a date works without going through it a keystroke at a time. + * + * The digits land in `startName` first and carry into the segments after it, so pasting a month leaves a year that is + * already there alone. A segment keeps what it held until the digits actually reach it, and is then replaced rather + * than extended. Text holding no digits at all returns `baseSegments` itself, so a caller can tell nothing was pasted. + */ +function getSegmentsFromText(text: string, startName: DateSegmentName, baseSegments: DateSegments): DateSegments { const digits = text.replaceAll(NON_DIGIT_REGEX, ''); - let filled = EMPTY_SEGMENTS; + let filled = baseSegments; + let name = startName; + let shouldOverwrite = true; for (const digit of digits) { - const name = getFirstUnfilledSegmentName(filled); - if (!name) { - break; + const previousSegments = filled; + const result = typeDigitIntoSegments(previousSegments, name, digit, shouldOverwrite); + filled = result.segments; + shouldOverwrite = false; + + if (!result.nextSegmentName) { + // The last segment is full and there is nowhere left to carry to, so the rest of the text is dropped + if (filled[name].length >= getSegmentLength(name)) { + break; + } + continue; } - filled = typeDigitIntoSegments(filled, name, digit).segments; + // A digit too big for its own segment is carried into the next one, which has then already been written + shouldOverwrite = filled[result.nextSegmentName] === previousSegments[result.nextSegmentName]; + name = result.nextSegmentName; } return filled; diff --git a/tests/unit/DateInputMaskUtilsTest.ts b/tests/unit/DateInputMaskUtilsTest.ts index f062c320ca91..6c347d08d1ba 100644 --- a/tests/unit/DateInputMaskUtilsTest.ts +++ b/tests/unit/DateInputMaskUtilsTest.ts @@ -192,17 +192,53 @@ describe('DateInputMaskUtils', () => { describe('getSegmentsFromText', () => { it('fills the segments from a pasted date', () => { - expect(getSegmentsFromText('2026-09-18')).toEqual(segments('2026', '09', '18')); - expect(getSegmentsFromText('20260918')).toEqual(segments('2026', '09', '18')); + expect(getSegmentsFromText('2026-09-18', 'year', EMPTY)).toEqual(segments('2026', '09', '18')); + expect(getSegmentsFromText('20260918', 'year', EMPTY)).toEqual(segments('2026', '09', '18')); }); it('stops once every segment is full', () => { - expect(getSegmentsFromText('20260918123')).toEqual(segments('2026', '09', '18')); + expect(getSegmentsFromText('20260918123', 'year', EMPTY)).toEqual(segments('2026', '09', '18')); }); it('fills what it can from a partial date', () => { - expect(getSegmentsFromText('2026-09')).toEqual(segments('2026', '09', '')); - expect(getSegmentsFromText('')).toEqual(EMPTY); + expect(getSegmentsFromText('2026-09', 'year', EMPTY)).toEqual(segments('2026', '09', '')); + expect(getSegmentsFromText('', 'year', EMPTY)).toEqual(EMPTY); + }); + + it('starts at the pasted-into segment rather than at the year', () => { + // Given a year that is already filled in + // When a month is pasted into the month segment + // Then the year is left alone, rather than being overwritten by the pasted digits + expect(getSegmentsFromText('12', 'month', segments('1990', '', ''))).toEqual(segments('1990', '12', '')); + }); + + it('keeps the segments the pasted digits never reach', () => { + // Given a date that is filled in + // When a single digit is pasted into the year + // Then only the year is started over, because the paste never reached the month or the day + expect(getSegmentsFromText('1', 'year', segments('1990', '12', '04'))).toEqual(segments('1', '12', '04')); + }); + + it('replaces a segment the digits reach rather than extending it', () => { + // Given a full date, and a whole date pasted over it starting at the year + // When the digits carry through every segment + // Then each one holds only the pasted digits, rather than the old ones with the new appended + expect(getSegmentsFromText('2026-09-18', 'year', segments('1990', '12', '04'))).toEqual(segments('2026', '09', '18')); + }); + + it('returns the segments it was given when the text holds no digits', () => { + // Given text that is not a date at all, which is how a caller tells that nothing was pasted + const baseSegments = segments('1990', '12', '04'); + + expect(getSegmentsFromText('hello', 'year', baseSegments)).toBe(baseSegments); + expect(getSegmentsFromText('', 'month', baseSegments)).toBe(baseSegments); + }); + + it('carries a digit too big for its segment into the next one', () => { + // Given a month that cannot read as 13 + // When the second digit overflows it + // Then the month keeps the first digit and the overflow lands in the day, rather than being dropped + expect(getSegmentsFromText('13', 'month', EMPTY)).toEqual(segments('', '01', '3')); }); }); From e947fe040d500c2755feacbc2906e69f61e61719 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Sun, 27 Sep 2026 04:07:33 +0530 Subject: [PATCH 36/80] fix: keep the typed date while the value prop catches up Signed-off-by: krishna2323 --- src/components/DatePicker/index.tsx | 7 ++++++- src/hooks/useDateSegmentInput.ts | 5 +++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index 583b9958b901..87cfc8a9f860 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -108,9 +108,14 @@ function DatePicker({ if (value === undefined) { return; } + // A date part way through being typed reports itself as empty, so the value prop trails a render behind the + // field. Putting it back while the user is still entering a date would undo the digit they just entered. + if (segmentInput.isEditing) { + return; + } setSelectedDate(value); - }, [formID, inputID, selectedDate, shouldSaveDraft, value]); + }, [formID, inputID, selectedDate, shouldSaveDraft, value, segmentInput.isEditing]); const calculatePopoverPosition = useCallback( (onMeasured?: () => void) => { diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index 9499909300b0..126facde3626 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -90,6 +90,9 @@ type UseDateSegmentInputResult = { /** Whether the whole date is selected, so the field draws it as one selection and the next keystroke replaces it */ isAllSelected: boolean; + /** Whether a date is being entered right now, so the segments rather than the committed value are what the field shows */ + isEditing: boolean; + getSegmentProps: (name: DateSegmentName) => DateSegmentProps; /** Called once focus has left the field altogether rather than moved between segments */ @@ -316,6 +319,7 @@ function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: Use viewDateVersion: 0, hasTypedDigits: false, isAllSelected: false, + isEditing: false, getSegmentProps: () => ({value: '', onKeyPress: () => {}, onChangeText: () => {}, onFocus: () => {}, onPressOut: () => {}}), onFieldBlur: () => {}, onClear: () => {}, @@ -338,6 +342,7 @@ function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: Use viewDateVersion, hasTypedDigits: shouldShowSegments && hasAnySegment(segments), isAllSelected, + isEditing, getSegmentProps: (name: DateSegmentName) => ({ value: getSegmentDisplay(displayedSegments, name), onKeyPress: (event: TextInputKeyPressEvent) => handleKeyPress(name, event), From 116d61c3a1cdf4ed7d6f662efb87b296aa4d56d7 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Sun, 27 Sep 2026 04:14:04 +0530 Subject: [PATCH 37/80] fix: show the clear button while a date is still being typed Signed-off-by: krishna2323 --- src/components/DatePicker/index.tsx | 1 + .../TextInput/BaseTextInput/implementation/index.tsx | 4 +++- src/components/TextInput/BaseTextInput/types.ts | 3 +++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index 87cfc8a9f860..dc56b936541a 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -311,6 +311,7 @@ function DatePicker({ isSegmentElement: segmentInput.isSegmentElement, isAllSelected: segmentInput.isAllSelected, onFieldBlur: segmentInput.onFieldBlur, + hasTypedDigits: segmentInput.hasTypedDigits, } : undefined } diff --git a/src/components/TextInput/BaseTextInput/implementation/index.tsx b/src/components/TextInput/BaseTextInput/implementation/index.tsx index b8b659058e5c..a0b9b14d1214 100644 --- a/src/components/TextInput/BaseTextInput/implementation/index.tsx +++ b/src/components/TextInput/BaseTextInput/implementation/index.tsx @@ -329,6 +329,8 @@ function BaseTextInput({ const shouldAddPaddingBottom = isMultiline || (autoGrowHeight && !isAutoGrowHeightMarkdown && textInputHeight > variables.componentSizeLarge); const hasLabel = !!label?.length; + // A date part way through being entered reads as no date at all, so its digits are clearable without a value + const hasContentToClear = !!value || !!inputProps.dateSegmentsConfig?.hasTypedDigits; const isReadOnly = inputProps.readOnly ?? inputProps.disabled; // Disabling this line for safeness as nullish coalescing works only if the value is undefined or null, and errorText can be an empty string @@ -547,7 +549,7 @@ function BaseTextInput({ )} - {((isFocused && !isReadOnly && shouldShowClearButton) || !shouldHideClearButton) && !!value && !inputProps.isLoading && ( + {((isFocused && !isReadOnly && shouldShowClearButton) || !shouldHideClearButton) && hasContentToClear && !inputProps.isLoading && ( { if (didScrollToEndRef.current || !input.current) { diff --git a/src/components/TextInput/BaseTextInput/types.ts b/src/components/TextInput/BaseTextInput/types.ts index cdfe08f33de6..d650b38cd71a 100644 --- a/src/components/TextInput/BaseTextInput/types.ts +++ b/src/components/TextInput/BaseTextInput/types.ts @@ -26,6 +26,9 @@ type DateSegmentsConfig = { isSegmentElement: UseDateSegmentInputResult['isSegmentElement']; isAllSelected: UseDateSegmentInputResult['isAllSelected']; onFieldBlur: UseDateSegmentInputResult['onFieldBlur']; + + /** Whether digits are on screen. A date part way through being entered has no value yet, but is still clearable */ + hasTypedDigits: UseDateSegmentInputResult['hasTypedDigits']; }; type CustomBaseTextInputProps = ForwardedFSClassProps & From 1bd6f79fa75d744c51806b93f0be3920a0806f59 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Sun, 27 Sep 2026 04:23:10 +0530 Subject: [PATCH 38/80] fix: follow a month typed before the year is filled in Signed-off-by: krishna2323 --- src/hooks/useDateSegmentInput.ts | 4 ++-- src/libs/DateInputMaskUtils.ts | 18 ++++++++++++------ tests/unit/DateInputMaskUtilsTest.ts | 26 ++++++++++++++++++++++---- 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index 126facde3626..5a5ad56af088 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -179,7 +179,7 @@ function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: Use /** The one place segments are written, so the calendar cannot fall out of step with them */ const applySegments = (newSegments: DateSegments) => { setSegments(newSegments); - assertViewDate(getViewDateFromSegments(newSegments, (viewDate ?? new Date()).getMonth(), minDate, maxDate)); + assertViewDate(getViewDateFromSegments(newSegments, viewDate ?? new Date(), minDate, maxDate)); commitSegments(newSegments); }; @@ -283,7 +283,7 @@ function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: Use const seededSegments = hasInvalidEntry ? segments : getSegmentsFromISODate(value); setHasInvalidEntry(false); setSegments(seededSegments); - assertViewDate(getViewDateFromSegments(seededSegments, new Date().getMonth(), minDate, maxDate)); + assertViewDate(getViewDateFromSegments(seededSegments, new Date(), minDate, maxDate)); setIsEditing(true); }; diff --git a/src/libs/DateInputMaskUtils.ts b/src/libs/DateInputMaskUtils.ts index 0f0b717b0d8d..e02c89c17ead 100644 --- a/src/libs/DateInputMaskUtils.ts +++ b/src/libs/DateInputMaskUtils.ts @@ -219,16 +219,22 @@ function typeDigitIntoSegments( * * A month outside the allowed range is refused rather than clamped. Every year is out of range while it is being * typed, since 1985 passes through 1, 19 and 198, and clamping those would drag the calendar to the limit and back on - * every keystroke. + * every keystroke. An unfinished year is therefore ignored, and a month typed before one lands in the year on screen. */ -function getViewDateFromSegments(segments: DateSegments, fallbackMonthIndex: number, minDate: Date, maxDate: Date): Date | undefined { - if (segments.year.length !== YEAR_LENGTH) { +function getViewDateFromSegments(segments: DateSegments, fallbackDate: Date, minDate: Date, maxDate: Date): Date | undefined { + const monthNumber = Number(segments.month); + const hasMonth = segments.month.length === SEGMENT_LENGTH && monthNumber >= FIRST_MONTH && monthNumber <= SEGMENT_LIMITS.month.max; + const hasYear = segments.year.length === YEAR_LENGTH; + + // Neither segment reads as somewhere to go yet, so the calendar keeps whatever it is showing + if (!hasYear && !hasMonth) { return undefined; } - const monthNumber = Number(segments.month); - const hasMonth = segments.month.length === SEGMENT_LENGTH && monthNumber >= FIRST_MONTH && monthNumber <= SEGMENT_LIMITS.month.max; - const viewDate = new Date(Number(segments.year), hasMonth ? monthNumber - 1 : fallbackMonthIndex, 1); + // The year on screen can sit outside the allowed range, since it starts at today even where only much older dates + // may be picked. Pulling it into range is what lets a month typed before a year land on a real month. + const fallbackYear = Math.min(Math.max(fallbackDate.getFullYear(), minDate.getFullYear()), maxDate.getFullYear()); + const viewDate = new Date(hasYear ? Number(segments.year) : fallbackYear, hasMonth ? monthNumber - 1 : fallbackDate.getMonth(), 1); // A month holding no selectable day at all is not worth moving to return endOfMonth(viewDate) < minDate || viewDate > maxDate ? undefined : viewDate; diff --git a/tests/unit/DateInputMaskUtilsTest.ts b/tests/unit/DateInputMaskUtilsTest.ts index 6c347d08d1ba..d3244d5833d5 100644 --- a/tests/unit/DateInputMaskUtilsTest.ts +++ b/tests/unit/DateInputMaskUtilsTest.ts @@ -73,17 +73,35 @@ describe('DateInputMaskUtils', () => { }); describe('getViewDateFromSegments', () => { - /** September, standing in for the month the calendar happens to be showing */ - const FALLBACK_MONTH_INDEX = 8; + /** September 2026, standing in for the month and year the calendar happens to be showing */ + const FALLBACK_DATE = new Date(2026, 8, 1); const MIN_DATE = new Date(1876, 8, 17); const MAX_DATE = new Date(2126, 8, 17); - const viewDateFor = (dateSegments: DateSegments) => getViewDateFromSegments(dateSegments, FALLBACK_MONTH_INDEX, MIN_DATE, MAX_DATE); + const viewDateFor = (dateSegments: DateSegments) => getViewDateFromSegments(dateSegments, FALLBACK_DATE, MIN_DATE, MAX_DATE); - it('leaves the calendar alone while the year is unfinished', () => { + it('leaves the calendar alone while the year is unfinished and no month has been typed', () => { expect(viewDateFor(segments('202', '', ''))).toBeUndefined(); expect(viewDateFor(EMPTY)).toBeUndefined(); }); + it('moves the month typed before a year, keeping the year on screen', () => { + // Given a month typed into a field whose year is still empty + // When the calendar is asked where to go + // Then it follows the month rather than waiting for a year it may not be given next + expect(viewDateFor(segments('', '03', ''))).toEqual(new Date(2026, 2, 1)); + expect(viewDateFor(segments('19', '03', ''))).toEqual(new Date(2026, 2, 1)); + }); + + it('pulls the year on screen into range for a month typed before a year', () => { + // Given a field that only accepts dates up to September 2008, such as a date of birth, while the calendar + // starts from today and is therefore showing a year that can never be picked + const maxDate = new Date(2008, 8, 27); + + // When a month is typed before any year + // Then the month lands in the newest year that can be picked, rather than being refused as out of range + expect(getViewDateFromSegments(segments('', '03', ''), FALLBACK_DATE, MIN_DATE, maxDate)).toEqual(new Date(2008, 2, 1)); + }); + it('moves the year while keeping the month on screen', () => { expect(viewDateFor(segments('2030', '', ''))).toEqual(new Date(2030, 8, 1)); expect(viewDateFor(segments('2030', '1', ''))).toEqual(new Date(2030, 8, 1)); From b83e90dfddc0cf29493797651c90e1656a62ebcd Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Sun, 27 Sep 2026 04:36:10 +0530 Subject: [PATCH 39/80] fix: follow the typed year into the calendar, even outside the pickable range Signed-off-by: krishna2323 --- .../DatePicker/CalendarPicker/index.tsx | 31 ++++++++++--------- src/libs/DateInputMaskUtils.ts | 14 ++++----- tests/unit/DateInputMaskUtilsTest.ts | 13 +++++--- 3 files changed, 31 insertions(+), 27 deletions(-) diff --git a/src/components/DatePicker/CalendarPicker/index.tsx b/src/components/DatePicker/CalendarPicker/index.tsx index 035093c6f192..0a7802b8d3d0 100644 --- a/src/components/DatePicker/CalendarPicker/index.tsx +++ b/src/components/DatePicker/CalendarPicker/index.tsx @@ -124,20 +124,6 @@ function CalendarPicker({ const [isMonthPickerVisible, setIsMonthPickerVisible] = useState(false); const isFirstRender = useRef(true); - // Catching up with the caller here rather than in an effect keeps the view and the month matrix in step within a - // single render, so the calendar never paints the old month first. The date arrives already inside the allowed - // range, and is deliberately not clamped: clamping would show the limit's month instead of the typed one. - if (viewDate && viewDateVersion !== appliedViewDateVersion) { - setAppliedViewDateVersion(viewDateVersion); - setCurrentDateView(viewDate); - } - - const currentMonthView = currentDateView.getMonth(); - const currentYearView = currentDateView.getFullYear(); - const calendarDaysMatrix = generateMonthMatrix(currentYearView, currentMonthView); - const initialHeight = (calendarDaysMatrix?.length || CONST.MAX_CALENDAR_PICKER_ROWS) * CONST.CALENDAR_PICKER_DAY_HEIGHT; - const heightValue = useSharedValue(initialHeight); - const minYear = CONST.CALENDAR_PICKER.MIN_YEAR; const maxYear = CONST.CALENDAR_PICKER.MAX_YEAR; @@ -150,6 +136,23 @@ function CalendarPicker({ })), ); + // Catching up with the caller here rather than in an effect keeps the view and the month matrix in step within a + // single render, so the calendar never paints the old month first. The date is deliberately not clamped, so the + // calendar shows the year that was typed rather than the nearest one that could be picked. + if (viewDate && viewDateVersion !== appliedViewDateVersion) { + setAppliedViewDateVersion(viewDateVersion); + setCurrentDateView(viewDate); + // A year the list does not cover matches nothing, which is what leaves the year picker showing no selection + // rather than the one it happened to be on before the field moved it. + setYears((prevYears) => prevYears.map((item) => ({...item, isSelected: item.value === viewDate.getFullYear()}))); + } + + const currentMonthView = currentDateView.getMonth(); + const currentYearView = currentDateView.getFullYear(); + const calendarDaysMatrix = generateMonthMatrix(currentYearView, currentMonthView); + const initialHeight = (calendarDaysMatrix?.length || CONST.MAX_CALENDAR_PICKER_ROWS) * CONST.CALENDAR_PICKER_DAY_HEIGHT; + const heightValue = useSharedValue(initialHeight); + const onYearSelected = (year: number) => { const newCurrentDateView = setYearKeepingDay(new Date(currentDateView), year); setCurrentDateView(newCurrentDateView); diff --git a/src/libs/DateInputMaskUtils.ts b/src/libs/DateInputMaskUtils.ts index e02c89c17ead..fa4b45c88b93 100644 --- a/src/libs/DateInputMaskUtils.ts +++ b/src/libs/DateInputMaskUtils.ts @@ -7,7 +7,7 @@ import CONST from '@src/CONST'; import type {TupleToUnion} from 'type-fest'; -import {endOfMonth, isValid, parse} from 'date-fns'; +import {isValid, parse} from 'date-fns'; const DATE_SEGMENT_NAMES = ['year', 'month', 'day'] as const; @@ -217,9 +217,9 @@ function typeDigitIntoSegments( * the calendar should stay where it is. A month only counts once both its digits read as a real month, so the calendar * does not lurch to January while the user is still on the first digit. * - * A month outside the allowed range is refused rather than clamped. Every year is out of range while it is being - * typed, since 1985 passes through 1, 19 and 198, and clamping those would drag the calendar to the limit and back on - * every keystroke. An unfinished year is therefore ignored, and a month typed before one lands in the year on screen. + * A year outside the allowed range is followed too, so the calendar keeps agreeing with the field while validation is + * what reports the problem. An unfinished one is ignored instead, since 1985 passes through 1, 19 and 198 on the way, + * so a month typed before a year lands in the year already on screen. */ function getViewDateFromSegments(segments: DateSegments, fallbackDate: Date, minDate: Date, maxDate: Date): Date | undefined { const monthNumber = Number(segments.month); @@ -232,12 +232,10 @@ function getViewDateFromSegments(segments: DateSegments, fallbackDate: Date, min } // The year on screen can sit outside the allowed range, since it starts at today even where only much older dates - // may be picked. Pulling it into range is what lets a month typed before a year land on a real month. + // may be picked. Pulling it into range keeps a year the user never typed from landing somewhere unselectable. const fallbackYear = Math.min(Math.max(fallbackDate.getFullYear(), minDate.getFullYear()), maxDate.getFullYear()); - const viewDate = new Date(hasYear ? Number(segments.year) : fallbackYear, hasMonth ? monthNumber - 1 : fallbackDate.getMonth(), 1); - // A month holding no selectable day at all is not worth moving to - return endOfMonth(viewDate) < minDate || viewDate > maxDate ? undefined : viewDate; + return new Date(hasYear ? Number(segments.year) : fallbackYear, hasMonth ? monthNumber - 1 : fallbackDate.getMonth(), 1); } /** Drops the last digit of a segment. Returns undefined when there was nothing left to drop */ diff --git a/tests/unit/DateInputMaskUtilsTest.ts b/tests/unit/DateInputMaskUtilsTest.ts index d3244d5833d5..975821cf07b8 100644 --- a/tests/unit/DateInputMaskUtilsTest.ts +++ b/tests/unit/DateInputMaskUtilsTest.ts @@ -116,14 +116,17 @@ describe('DateInputMaskUtils', () => { expect(viewDateFor(segments('2030', '02', '28'))).toEqual(new Date(2030, 1, 1)); }); - it('stays put for a year outside the range rather than jumping to the limit', () => { - expect(viewDateFor(segments('1111', '01', '03'))).toBeUndefined(); - expect(viewDateFor(segments('9999', '01', ''))).toBeUndefined(); + it('follows a year outside the range, which validation is what reports', () => { + // Given a year nowhere near the dates the field accepts + // When the calendar is asked where to go + // Then it follows all the same, so it does not sit on a month that disagrees with what the field shows + expect(viewDateFor(segments('1111', '01', '03'))).toEqual(new Date(1111, 0, 1)); + expect(viewDateFor(segments('9999', '01', ''))).toEqual(new Date(9999, 0, 1)); }); - it('moves to the month holding the limit itself, which still has days to select', () => { + it('follows a month at either limit, including one with no day left to select', () => { expect(viewDateFor(segments('1876', '09', ''))).toEqual(new Date(1876, 8, 1)); - expect(viewDateFor(segments('1876', '08', ''))).toBeUndefined(); + expect(viewDateFor(segments('1876', '08', ''))).toEqual(new Date(1876, 7, 1)); }); }); From b24cd31599ddb80e6d4a99811dbc83c309b1415e Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Sun, 27 Sep 2026 04:40:40 +0530 Subject: [PATCH 40/80] fix: keep the typed date when the calendar is reopened Signed-off-by: krishna2323 --- .../DatePicker/CalendarPicker/index.tsx | 33 ++++++++++--------- tests/unit/CalendarPickerTest.tsx | 19 +++++++++++ 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/src/components/DatePicker/CalendarPicker/index.tsx b/src/components/DatePicker/CalendarPicker/index.tsx index 0a7802b8d3d0..890fd36d3166 100644 --- a/src/components/DatePicker/CalendarPicker/index.tsx +++ b/src/components/DatePicker/CalendarPicker/index.tsx @@ -71,24 +71,25 @@ type CalendarPickerProps = { }; function getInitialCurrentDateView(value: Date | string, minDate: Date, maxDate: Date) { - let initialCurrentDateView: Date; - if (typeof value === 'string') { - if (!value) { - initialCurrentDateView = new Date(); - } else { - initialCurrentDateView = parseISO(value); - } - } else { - initialCurrentDateView = new Date(value); + // A date the field actually holds is followed even when it sits outside the range, so reopening the calendar does + // not lose the year that was typed. Validation is what reports a date that cannot be picked. + if (value) { + return typeof value === 'string' ? parseISO(value) : new Date(value); + } + + // Without a date to follow the calendar starts from today, pulled into range so it still opens on a month that + // has days to select. + const today = new Date(); + + if (maxDate < today) { + return maxDate; } - if (maxDate < initialCurrentDateView) { - initialCurrentDateView = maxDate; - } else if (minDate > initialCurrentDateView) { - initialCurrentDateView = minDate; + if (minDate > today) { + return minDate; } - return initialCurrentDateView; + return today; } // Keeps the day inside the target month, since setYear alone turns February 29 into March 1 on a non leap year @@ -118,7 +119,9 @@ function CalendarPicker({ const {translate, dateFnsLocale} = useLocalize(); const pressableRef = useRef>(null); const monthPressableRef = useRef>(null); - const [currentDateView, setCurrentDateView] = useState(() => getInitialCurrentDateView(value, minDate, maxDate)); + // The popover unmounts its content, so the calendar starts over every time it is reopened. A view the field has + // already asserted is what it starts from, since a date still being typed has no committed value to fall back to. + const [currentDateView, setCurrentDateView] = useState(() => viewDate ?? getInitialCurrentDateView(value, minDate, maxDate)); const [appliedViewDateVersion, setAppliedViewDateVersion] = useState(viewDateVersion); const [isYearPickerVisible, setIsYearPickerVisible] = useState(false); const [isMonthPickerVisible, setIsMonthPickerVisible] = useState(false); diff --git a/tests/unit/CalendarPickerTest.tsx b/tests/unit/CalendarPickerTest.tsx index 8d3e04388d71..bca14aa92fec 100644 --- a/tests/unit/CalendarPickerTest.tsx +++ b/tests/unit/CalendarPickerTest.tsx @@ -133,6 +133,25 @@ describe('CalendarPicker', () => { expect(screen.getByText(currentDate.getFullYear().toString())).toBeTruthy(); }); + test('opens on the date the field holds even when it cannot be picked', () => { + // Given a field that only accepts dates up to 2008, holding a date well past that, which validation reports + const minDate = new Date('1926-09-27'); + const maxDate = new Date('2008-09-27'); + + // When the calendar opens, as it does again every time it is reopened + render( + , + ); + + // Then it shows that date rather than the nearest one that can be picked, so reopening keeps the typed year + expect(screen.getByText(monthNames.at(0) ?? '')).toBeTruthy(); + expect(screen.getByText('2222')).toBeTruthy(); + }); + test('clicking next month arrow updates the displayed month', () => { const minDate = new Date('2022-01-01'); const maxDate = new Date('2030-01-01'); From 8031665a8e51ab01cf6191d0700ad77710ecf74d Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Sun, 27 Sep 2026 05:02:33 +0530 Subject: [PATCH 41/80] fix: refuse a date that was typed but does not exist Signed-off-by: krishna2323 --- src/components/DatePicker/index.tsx | 15 ++++++++++++++- src/hooks/useDateSegmentInput.ts | 5 +++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index dc56b936541a..846693c7c201 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -54,6 +54,7 @@ function DatePicker({ shouldDismissKeyboardBeforeShow = false, rightHandSideComponent, onPickerVisibilityChange, + onInvalidEntryChange, shouldHideCalendarIcon = false, }: DateInputWithPickerProps) { const icons = useMemoizedLazyExpensifyIcons(['Calendar']); @@ -242,6 +243,18 @@ function DatePicker({ calculatePopoverPosition(); }; + // Digits that do not add up to a date read as no date at all, so a form with no rule about this field would accept + // the entry in silence. + const invalidEntryError = segmentInput.hasInvalidEntry ? translate('common.error.dateInvalid') : undefined; + + // The form is told separately, since the empty value it receives cannot tell a refused entry from a blank field + useEffect(() => { + onInvalidEntryChange?.(segmentInput.hasInvalidEntry); + }, [segmentInput.hasInvalidEntry, onInvalidEntryChange]); + // Nullish coalescing would keep an empty errorText, which is a form reporting no error rather than an empty one + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const dateErrorText = errorText || invalidEntryError; + const handleClear = () => { onTouched?.(); onInputChange?.(''); @@ -317,7 +330,7 @@ function DatePicker({ } value={selectedDate} placeholder={placeholder ?? dateMask} - errorText={errorText} + errorText={dateErrorText} inputStyle={shouldAllowTyping ? undefined : styles.pointerEventsNone} disabled={disabled} hideFocusedState={shouldDismissKeyboardBeforeShow && !shouldAllowTyping} diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index 5a5ad56af088..a143374366cb 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -93,6 +93,9 @@ type UseDateSegmentInputResult = { /** Whether a date is being entered right now, so the segments rather than the committed value are what the field shows */ isEditing: boolean; + /** Whether the field was left holding digits that do not add up to a date, which reads as no date at all */ + hasInvalidEntry: boolean; + getSegmentProps: (name: DateSegmentName) => DateSegmentProps; /** Called once focus has left the field altogether rather than moved between segments */ @@ -320,6 +323,7 @@ function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: Use hasTypedDigits: false, isAllSelected: false, isEditing: false, + hasInvalidEntry: false, getSegmentProps: () => ({value: '', onKeyPress: () => {}, onChangeText: () => {}, onFocus: () => {}, onPressOut: () => {}}), onFieldBlur: () => {}, onClear: () => {}, @@ -343,6 +347,7 @@ function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: Use hasTypedDigits: shouldShowSegments && hasAnySegment(segments), isAllSelected, isEditing, + hasInvalidEntry, getSegmentProps: (name: DateSegmentName) => ({ value: getSegmentDisplay(displayedSegments, name), onKeyPress: (event: TextInputKeyPressEvent) => handleKeyPress(name, event), From c5302c2fd3c0752e4ad13011451a9d17fd892952 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Sun, 27 Sep 2026 05:06:38 +0530 Subject: [PATCH 42/80] fix: hold the day to the month that was typed Signed-off-by: krishna2323 --- src/components/DatePicker/index.tsx | 6 ----- src/libs/DateInputMaskUtils.ts | 33 +++++++++++++++++++++++----- tests/unit/DateInputMaskUtilsTest.ts | 26 +++++++++++++++++++--- 3 files changed, 50 insertions(+), 15 deletions(-) diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index 846693c7c201..637d718ffa6c 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -54,7 +54,6 @@ function DatePicker({ shouldDismissKeyboardBeforeShow = false, rightHandSideComponent, onPickerVisibilityChange, - onInvalidEntryChange, shouldHideCalendarIcon = false, }: DateInputWithPickerProps) { const icons = useMemoizedLazyExpensifyIcons(['Calendar']); @@ -246,11 +245,6 @@ function DatePicker({ // Digits that do not add up to a date read as no date at all, so a form with no rule about this field would accept // the entry in silence. const invalidEntryError = segmentInput.hasInvalidEntry ? translate('common.error.dateInvalid') : undefined; - - // The form is told separately, since the empty value it receives cannot tell a refused entry from a blank field - useEffect(() => { - onInvalidEntryChange?.(segmentInput.hasInvalidEntry); - }, [segmentInput.hasInvalidEntry, onInvalidEntryChange]); // Nullish coalescing would keep an empty errorText, which is a form reporting no error rather than an empty one // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const dateErrorText = errorText || invalidEntryError; diff --git a/src/libs/DateInputMaskUtils.ts b/src/libs/DateInputMaskUtils.ts index fa4b45c88b93..faa917866951 100644 --- a/src/libs/DateInputMaskUtils.ts +++ b/src/libs/DateInputMaskUtils.ts @@ -7,7 +7,7 @@ import CONST from '@src/CONST'; import type {TupleToUnion} from 'type-fest'; -import {isValid, parse} from 'date-fns'; +import {getDaysInMonth, isValid, parse} from 'date-fns'; const DATE_SEGMENT_NAMES = ['year', 'month', 'day'] as const; @@ -18,14 +18,16 @@ const FIRST_MONTH = 1; /** * The highest a segment may read, and the highest its leading digit may be while still allowing a second one. The day - * is capped at the longest month rather than the one that has been typed, so an impossible date such as the 31st of - * February can be entered and is then rejected by validation, rather than being silently corrected mid-keystroke. + * entry here is the longest month, which stands in until a month has been typed to say how long its own month is. */ const SEGMENT_LIMITS = { month: {max: 12, maxLeadingDigit: 1}, day: {max: 31, maxLeadingDigit: 3}, } as const; +/** Stands in for a year that is not finished, so February keeps its 29th until the year rules it out */ +const FALLBACK_LEAP_YEAR = 2024; + /** Mask characters standing in for a digit are letters, so anything else is a separator to copy through verbatim */ const MASK_LETTER_REGEX = /\p{L}/u; @@ -133,12 +135,31 @@ type SegmentDigitResult = { carry?: string; }; +/** + * How high the day may go, and how high its leading digit may be, in the month that has been typed. Falling back to + * the longest month while no month has been typed is what keeps a day entered before a month from being held to a + * limit the user has not set yet. + */ +function getDayLimits(segments: DateSegments): {max: number; maxLeadingDigit: number} { + const monthNumber = Number(segments.month); + + if (!segments.month || monthNumber < FIRST_MONTH || monthNumber > SEGMENT_LIMITS.month.max) { + return SEGMENT_LIMITS.day; + } + + const year = segments.year.length === YEAR_LENGTH ? Number(segments.year) : FALLBACK_LEAP_YEAR; + const max = getDaysInMonth(new Date(year, monthNumber - 1, 1)); + + // A month of 28 or 29 days cannot start a day with a 3, since there is no 30th to complete it + return {max, maxLeadingDigit: Math.floor(max / 10)}; +} + /** * Adds one typed digit to a single segment, without knowing about the others. A digit that cannot extend what is * already there is handed on rather than dropped, so typing 1 then 3 into the month reads as January and starts the * day off with the 3. */ -function typeDigitIntoOneSegment(name: DateSegmentName, typedSoFar: string, digit: string): SegmentDigitResult { +function typeDigitIntoOneSegment(name: DateSegmentName, typedSoFar: string, digit: string, segments: DateSegments): SegmentDigitResult { const current = typedSoFar.length >= getSegmentLength(name) ? '' : typedSoFar; if (name === 'year') { @@ -147,7 +168,7 @@ function typeDigitIntoOneSegment(name: DateSegmentName, typedSoFar: string, digi return {value: year, shouldAdvance: year.length === YEAR_LENGTH}; } - const limits = SEGMENT_LIMITS[name]; + const limits = name === 'day' ? getDayLimits(segments) : SEGMENT_LIMITS[name]; if (current.length === 1) { const combined = `${current}${digit}`; @@ -191,7 +212,7 @@ function typeDigitIntoSegments( let nextSegmentName: DateSegmentName | undefined; for (;;) { - const result = typeDigitIntoOneSegment(currentName, typedSoFar, currentDigit); + const result = typeDigitIntoOneSegment(currentName, typedSoFar, currentDigit, filled); filled[currentName] = result.value; const followingName = getFollowingSegmentName(currentName); diff --git a/tests/unit/DateInputMaskUtilsTest.ts b/tests/unit/DateInputMaskUtilsTest.ts index 975821cf07b8..ec6dabd1d253 100644 --- a/tests/unit/DateInputMaskUtilsTest.ts +++ b/tests/unit/DateInputMaskUtilsTest.ts @@ -56,14 +56,34 @@ describe('DateInputMaskUtils', () => { expect(typeDigitIntoSegments(segments('2026', '0', ''), 'month', '0')).toEqual({segments: segments('2026', '0', ''), nextSegmentName: undefined}); }); - it('caps the day at the longest month rather than the one that was typed', () => { + it('caps the day at the month that was typed', () => { + // Given January, which has a 31st expect(typeDigitIntoSegments(segments('2026', '01', '3'), 'day', '1')).toEqual({segments: segments('2026', '01', '31'), nextSegmentName: undefined}); - // February has no 31st, but the field takes it and validation is what rejects the date - expect(typeDigitIntoSegments(segments('2026', '02', '3'), 'day', '1')).toEqual({segments: segments('2026', '02', '31'), nextSegmentName: undefined}); + // Given February, which has no 31st, so the 3 can only have meant the 3rd + expect(typeDigitIntoSegments(segments('2026', '02', '3'), 'day', '1')).toEqual({segments: segments('2026', '02', '03'), nextSegmentName: undefined}); + }); + + it('finishes the day early in a month too short to start it with a 3', () => { + // Given February, where no day starts with a 3, so a 3 completes the segment instead of waiting + expect(typeDigitIntoSegments(segments('2026', '02', ''), 'day', '3')).toEqual({segments: segments('2026', '02', '03'), nextSegmentName: undefined}); + + // Given March, which has a 31st, so the 3 waits for a digit that might complete it + expect(typeDigitIntoSegments(segments('2026', '03', ''), 'day', '3')).toEqual({segments: segments('2026', '03', '3'), nextSegmentName: undefined}); + }); + + it('allows February 29 in a leap year and not otherwise', () => { + expect(typeDigitIntoSegments(segments('2024', '02', '2'), 'day', '9')).toEqual({segments: segments('2024', '02', '29'), nextSegmentName: undefined}); + expect(typeDigitIntoSegments(segments('2026', '02', '2'), 'day', '9')).toEqual({segments: segments('2026', '02', '02'), nextSegmentName: undefined}); + }); + + it('caps the day at the longest month while no month has been typed', () => { + // Given a day entered before a month, which cannot be held to a limit the user has not set yet + expect(typeDigitIntoSegments(segments('2026', '', '3'), 'day', '1')).toEqual({segments: segments('2026', '', '31'), nextSegmentName: undefined}); }); it('keeps a day the newly typed month cannot have, leaving it to validation', () => { + // Given a day typed before the month that rules it out, which the day's own limits never see expect(typeDigitIntoSegments(segments('2026', '1', '31'), 'month', '1').segments).toEqual(segments('2026', '11', '31')); }); From 2efce4366b2f738d3e9325f6a87b907d3559f992 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Sun, 27 Sep 2026 05:15:43 +0530 Subject: [PATCH 43/80] fix: move the day back when the month typed after it is too short Signed-off-by: krishna2323 --- src/libs/DateInputMaskUtils.ts | 20 +++++++++++++++++++- tests/unit/DateInputMaskUtilsTest.ts | 15 ++++++++++++--- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/libs/DateInputMaskUtils.ts b/src/libs/DateInputMaskUtils.ts index faa917866951..a7f357fd644b 100644 --- a/src/libs/DateInputMaskUtils.ts +++ b/src/libs/DateInputMaskUtils.ts @@ -195,6 +195,24 @@ function typeDigitIntoOneSegment(name: DateSegmentName, typedSoFar: string, digi return {value: digit, shouldAdvance: false}; } +/** + * Moves the day back to the last of its month when the month or year typed after it has made it impossible. The day + * the user was closest to is kept rather than cleared, so a date that cannot exist never reaches the field. + */ +function clampDayToMonth(segments: DateSegments): DateSegments { + if (!segments.day) { + return segments; + } + + const {max} = getDayLimits(segments); + + if (Number(segments.day) <= max) { + return segments; + } + + return {...segments, day: String(max)}; +} + /** * Adds one typed digit, following a carried digit into the following segments for as long as they keep handing one on. * `nextSegmentName` is where the caret belongs afterwards, and is undefined while the segment is unfinished. @@ -230,7 +248,7 @@ function typeDigitIntoSegments( currentDigit = result.carry; } - return {segments: filled, nextSegmentName}; + return {segments: clampDayToMonth(filled), nextSegmentName}; } /** diff --git a/tests/unit/DateInputMaskUtilsTest.ts b/tests/unit/DateInputMaskUtilsTest.ts index ec6dabd1d253..7bf85a2676f0 100644 --- a/tests/unit/DateInputMaskUtilsTest.ts +++ b/tests/unit/DateInputMaskUtilsTest.ts @@ -82,9 +82,18 @@ describe('DateInputMaskUtils', () => { expect(typeDigitIntoSegments(segments('2026', '', '3'), 'day', '1')).toEqual({segments: segments('2026', '', '31'), nextSegmentName: undefined}); }); - it('keeps a day the newly typed month cannot have, leaving it to validation', () => { - // Given a day typed before the month that rules it out, which the day's own limits never see - expect(typeDigitIntoSegments(segments('2026', '1', '31'), 'month', '1').segments).toEqual(segments('2026', '11', '31')); + it('moves the day back when the month typed after it is too short', () => { + // Given the 31st typed before November, which ends on the 30th + expect(typeDigitIntoSegments(segments('2026', '1', '31'), 'month', '1').segments).toEqual(segments('2026', '11', '30')); + + // Given the 31st typed before February, which the day's own limits never saw + expect(typeDigitIntoSegments(segments('2026', '0', '31'), 'month', '2').segments).toEqual(segments('2026', '02', '28')); + }); + + it('moves the day back when the year typed after it takes the leap day away', () => { + // Given February 29 typed before the year, which decides whether that day exists + expect(typeDigitIntoSegments(segments('202', '02', '29'), 'year', '6').segments).toEqual(segments('2026', '02', '28')); + expect(typeDigitIntoSegments(segments('202', '02', '29'), 'year', '4').segments).toEqual(segments('2024', '02', '29')); }); it('has nothing to carry into past the day, so a rejected pair restarts it', () => { From cf5bb1a90e86191d10ee87a82957d014ff26abd1 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Sun, 27 Sep 2026 05:37:01 +0530 Subject: [PATCH 44/80] fix: block submitting a date the calendar would never have offered Signed-off-by: krishna2323 --- src/components/DatePicker/index.tsx | 26 ++++++++++++++++--- src/components/Form/FormContext.tsx | 10 +++++++ src/components/Form/FormProvider.tsx | 20 +++++++++++++- src/libs/ValidationUtils.ts | 23 ++++++++++++++++ tests/unit/InputWrapperTest.tsx | 1 + .../unit/components/Form/FormWrapper.test.tsx | 1 + 6 files changed, 76 insertions(+), 5 deletions(-) diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index 637d718ffa6c..17cef7c179e4 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -1,3 +1,4 @@ +import FormContext from '@components/Form/FormContext'; import TextInput from '@components/TextInput'; import type {BaseTextInputProps, BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; @@ -12,7 +13,7 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import ComposerFocusManager from '@libs/ComposerFocusManager'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; -import {isNumeric} from '@libs/ValidationUtils'; +import {getDateRangeError, isNumeric} from '@libs/ValidationUtils'; import {setDraftValues} from '@userActions/FormActions'; @@ -23,7 +24,7 @@ import type {TextInputKeyPressEvent} from 'react-native'; import {format, setYear} from 'date-fns'; import debounce from 'lodash/debounce'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import {Keyboard, View} from 'react-native'; import type {DateInputWithPickerProps} from './types'; @@ -60,6 +61,7 @@ function DatePicker({ const styles = useThemeStyles(); const {windowHeight, windowWidth} = useWindowDimensions(); const {translate} = useLocalize(); + const {setInputValidationError} = useContext(FormContext); const [isModalVisible, setIsModalVisible] = useState(false); const announcementMessage = label ? `${label}, ${translate('common.calendarOpened')}` : translate('common.calendarOpened'); @@ -77,10 +79,18 @@ function DatePicker({ const shouldAllowTyping = !canUseTouchScreen(); const dateMask = translate('common.dateFormat'); + // A date the calendar could never have offered is one nothing downstream expects, and typing is what makes those + // reachable. Returns an empty string when the date is fine, which is how the form reads "no error". + const getRangeError = (date: string) => (date ? getDateRangeError(translate, date, minDate, maxDate) : ''); + // Updates the field without ending the selection, so the calendar stays open for whatever the user does next const commitDate = (newDate: string) => { setSelectedDate(newDate); + // The form validates from inside onInputChange, which runs before this render's effects, so the error has to + // be recorded here as well for it to clear in the same keystroke that fixes the date. + setInputValidationError(inputID, getRangeError(newDate)); + // A date being typed reads as empty until it is finished. Marking the field touched then would show a // required error over a date the user is part way through. if (newDate) { @@ -244,13 +254,21 @@ function DatePicker({ // Digits that do not add up to a date read as no date at all, so a form with no rule about this field would accept // the entry in silence. - const invalidEntryError = segmentInput.hasInvalidEntry ? translate('common.error.dateInvalid') : undefined; + const ownError = segmentInput.hasInvalidEntry ? translate('common.error.dateInvalid') : getRangeError(selectedDate); // Nullish coalescing would keep an empty errorText, which is a form reporting no error rather than an empty one // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const dateErrorText = errorText || invalidEntryError; + const dateErrorText = errorText || ownError; + + useEffect(() => { + setInputValidationError(inputID, ownError); + + // A field that is gone has nothing left to report, so its error must not outlive it and block the form + return () => setInputValidationError(inputID, ''); + }, [inputID, ownError, setInputValidationError]); const handleClear = () => { onTouched?.(); + setInputValidationError(inputID, ''); onInputChange?.(''); setSelectedDate(''); segmentInput.onClear(); diff --git a/src/components/Form/FormContext.tsx b/src/components/Form/FormContext.tsx index 9a94fd3f5a5f..19593da5072b 100644 --- a/src/components/Form/FormContext.tsx +++ b/src/components/Form/FormContext.tsx @@ -11,6 +11,13 @@ type FormContext = { registerInput: RegisterInput; getErrorAnnouncementKey: () => number; getFallbackAnnouncementMessage: () => string; + + /** + * Reports an error the input's own value is enough to decide, such as a date outside the range its calendar + * offers. A value the input refuses to commit never reaches the form, so without this the page would have no + * rule to write and would submit the entry in silence. An empty string withdraws the error. + */ + setInputValidationError: (inputID: keyof Form, error: string) => void; }; export default createContext({ @@ -19,6 +26,9 @@ export default createContext({ }, getErrorAnnouncementKey: () => 0, getFallbackAnnouncementMessage: () => '', + + // Inputs are usable on their own outside a form, where the page owns its own submit and there is nothing to tell + setInputValidationError: () => {}, }); export type {RegisterInput}; diff --git a/src/components/Form/FormProvider.tsx b/src/components/Form/FormProvider.tsx index 66e3b7f78e9a..8e372c092f09 100644 --- a/src/components/Form/FormProvider.tsx +++ b/src/components/Form/FormProvider.tsx @@ -160,6 +160,9 @@ function FormProvider({ const inputRefs = useRef({}); const formWrapperRef = useRef(null); const touchedInputs = useRef>({}); + + // A ref rather than state, so an input reporting its own error cannot drive a render of the form it sits in + const inputValidationErrors = useRef>({}); const [inputValues, setInputValues] = useState
(() => ({...draftValues})); const isLoadingDraftValues = isLoadingOnyxValue(draftValuesMetadata); const previousDraftValues = useRef(draftValues); @@ -208,6 +211,15 @@ function FormProvider({ const validateErrors: GenericFormInputErrors = validate?.(trimmedStringValues, translate) ?? {}; + for (const [inputID, inputError] of Object.entries(inputValidationErrors.current)) { + // A rule the page wrote itself is the more specific one, so it keeps the field + if (!inputError || validateErrors[inputID]) { + continue; + } + + validateErrors[inputID] = inputError; + } + if (!allowHTML) { // Validate the input for html tags. It should supersede any other error unless // shouldPreserveCustomValidationErrors is set and the field already has a custom error. @@ -581,7 +593,13 @@ function FormProvider({ const fallbackAnnouncementMessage = !isGeneralAlertVisible ? firstFieldErrorMessage : ''; const getErrorAnnouncementKey = useCallback(() => errorAnnouncementKey, [errorAnnouncementKey]); const getFallbackAnnouncementMessage = useCallback(() => fallbackAnnouncementMessage, [fallbackAnnouncementMessage]); - const value = useMemo(() => ({registerInput, getErrorAnnouncementKey, getFallbackAnnouncementMessage}), [registerInput, getErrorAnnouncementKey, getFallbackAnnouncementMessage]); + const setInputValidationError = useCallback((inputID: keyof Form, error: string) => { + inputValidationErrors.current[inputID] = error; + }, []); + const value = useMemo( + () => ({registerInput, getErrorAnnouncementKey, getFallbackAnnouncementMessage, setInputValidationError}), + [registerInput, getErrorAnnouncementKey, getFallbackAnnouncementMessage, setInputValidationError], + ); const submitAndAnnounce = useCallback(() => { if (hasServerError) { diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts index ae3ee5c0ac6c..c0e6745cfa62 100644 --- a/src/libs/ValidationUtils.ts +++ b/src/libs/ValidationUtils.ts @@ -288,6 +288,28 @@ function getAgeRequirementError(translate: LocalizedTranslate, date: string, min return translate('privatePersonalDetails.error.dateShouldBeAfter', format(minimalDate, CONST.DATE.FNS_FORMAT_STRING)); } +/** + * Validate that a date sits inside the range its calendar would have offered. Typing reaches dates the calendar + * cannot, so a field that once could only hold a date the calendar offered now has to say when it does not. + */ +function getDateRangeError(translate: LocalizedTranslate, date: string, minimumDate: Date, maximumDate: Date): string { + const testDate = parse(date, CONST.DATE.FNS_FORMAT_STRING, new Date()); + + if (!isValid(testDate)) { + return translate('common.error.dateInvalid'); + } + + if (isAfter(testDate, startOfDay(maximumDate))) { + return translate('privatePersonalDetails.error.dateShouldBeBefore', format(maximumDate, CONST.DATE.FNS_FORMAT_STRING)); + } + + if (isBefore(testDate, startOfDay(minimumDate))) { + return translate('privatePersonalDetails.error.dateShouldBeAfter', format(minimumDate, CONST.DATE.FNS_FORMAT_STRING)); + } + + return ''; +} + /** * Validate that given date is not in the past. */ @@ -937,6 +959,7 @@ export { isNumeric, isValidAccountRoute, getDatePassedError, + getDateRangeError, isValidRecoveryCode, validateDateTimeIsAtLeastOneMinuteInFuture, prepareValues, diff --git a/tests/unit/InputWrapperTest.tsx b/tests/unit/InputWrapperTest.tsx index d08d0e9e286e..619e7ffe3ed7 100644 --- a/tests/unit/InputWrapperTest.tsx +++ b/tests/unit/InputWrapperTest.tsx @@ -56,6 +56,7 @@ function renderAndCaptureRegistration({autoGrowSingleLine, autoGrowHeight, multi registerInput, getErrorAnnouncementKey: () => 0, getFallbackAnnouncementMessage: () => '', + setInputValidationError: () => {}, }; render( diff --git a/tests/unit/components/Form/FormWrapper.test.tsx b/tests/unit/components/Form/FormWrapper.test.tsx index 3ade8eed7cbc..bc9b10bb93a7 100644 --- a/tests/unit/components/Form/FormWrapper.test.tsx +++ b/tests/unit/components/Form/FormWrapper.test.tsx @@ -50,6 +50,7 @@ function renderFormWrapper({errors = {}, serverErrorFields, inputRefs}: {errors? registerInput: jest.fn(), getErrorAnnouncementKey: () => 0, getFallbackAnnouncementMessage: () => '', + setInputValidationError: () => {}, }} > Date: Sun, 27 Sep 2026 05:49:42 +0530 Subject: [PATCH 45/80] fix: open the calendar in range, keep pasted digits, and delete to the start Signed-off-by: krishna2323 --- src/components/DatePicker/index.tsx | 17 +++++++---------- src/hooks/useDateSegmentInput.ts | 26 +++++++++++++++++++++++--- src/libs/DateInputMaskUtils.ts | 12 ++++++++++++ tests/unit/DateInputMaskUtilsTest.ts | 18 ++++++++++++++++++ 4 files changed, 60 insertions(+), 13 deletions(-) diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index 17cef7c179e4..c9bec49bfc4b 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -22,9 +22,9 @@ import CONST from '@src/CONST'; import type {ComponentRef} from 'react'; import type {TextInputKeyPressEvent} from 'react-native'; -import {format, setYear} from 'date-fns'; +import {setYear} from 'date-fns'; import debounce from 'lodash/debounce'; -import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useContext, useEffect, useRef, useState} from 'react'; import {Keyboard, View} from 'react-native'; import type {DateInputWithPickerProps} from './types'; @@ -298,13 +298,10 @@ function DatePicker({ [autoFocus], ); - const getValidDateForCalendar = useMemo(() => { - if (!selectedDate) { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - return defaultValue || format(new Date(), CONST.DATE.FNS_FORMAT_STRING); - } - return selectedDate; - }, [selectedDate, defaultValue]); + // Standing today in for a field holding nothing would both mark today as the chosen day and send the calendar to a + // month it may have no business opening on. An empty value leaves it to work out where to start on its own. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const calendarValue = selectedDate || defaultValue || ''; return ( <> @@ -365,7 +362,7 @@ function DatePicker({ inputID={inputID} minDate={minDate} maxDate={maxDate} - value={getValidDateForCalendar} + value={calendarValue} onSelected={handleDateSelected} isVisible={isModalVisible} onClose={closeDatePicker} diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index a143374366cb..1ba6ad9f76b9 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -5,6 +5,7 @@ import type {AnimatedTextInputRef} from '@components/RNTextInput'; import { + clearSegmentsUpTo, DATE_SEGMENT_NAMES, EMPTY_SEGMENTS, getAdjacentSegmentName, @@ -189,8 +190,16 @@ function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: Use const handleKeyPress = (name: DateSegmentName, event: TextInputKeyPressEvent) => { const key = event.nativeEvent.key; - // A shortcut is the browser's to handle, apart from the one that selects a field the browser cannot select + // A shortcut is the browser's to handle, apart from the two it cannot apply across separate inputs if (event.nativeEvent.metaKey || event.nativeEvent.ctrlKey) { + if (key === CONST.KEYBOARD_SHORTCUTS.BACKSPACE.shortcutKey || key === DELETE_KEY) { + event.preventDefault(); + setIsAllSelected(false); + applySegments(isAllSelected ? EMPTY_SEGMENTS : clearSegmentsUpTo(segments, name)); + enterSegment(FIRST_SEGMENT_NAME); + return; + } + if (key.toLowerCase() !== SELECT_ALL_KEY || !hasAnySegment(segments)) { return; } @@ -271,8 +280,19 @@ function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: Use setIsAllSelected(false); applySegments(pastedSegments); - // A paste that fills only part of the date leaves the user at the digits they still have to enter - enterSegment(getFirstUnfilledSegmentName(pastedSegments) ?? LAST_SEGMENT_NAME); + + const unfilledName = getFirstUnfilledSegmentName(pastedSegments); + + // A paste that fills the date leaves the last segment ready to be started over + if (!unfilledName) { + enterSegment(LAST_SEGMENT_NAME); + return; + } + + // A paste that fills only part of the date leaves the user at the digits they still have to enter. Those are + // the start of that segment, so the next digit has to extend them rather than replace them. + focusSegment(unfilledName); + setShouldOverwrite(false); }; const handleSegmentFocus = () => { diff --git a/src/libs/DateInputMaskUtils.ts b/src/libs/DateInputMaskUtils.ts index a7f357fd644b..b2b05f120cb1 100644 --- a/src/libs/DateInputMaskUtils.ts +++ b/src/libs/DateInputMaskUtils.ts @@ -277,6 +277,17 @@ function getViewDateFromSegments(segments: DateSegments, fallbackDate: Date, min return new Date(hasYear ? Number(segments.year) : fallbackYear, hasMonth ? monthNumber - 1 : fallbackDate.getMonth(), 1); } +/** Empties a segment and every segment before it, which is what deleting to the start of the field leaves behind */ +function clearSegmentsUpTo(segments: DateSegments, name: DateSegmentName): DateSegments { + const cleared = {...segments}; + + for (const clearedName of DATE_SEGMENT_NAMES.slice(0, DATE_SEGMENT_NAMES.indexOf(name) + 1)) { + cleared[clearedName] = ''; + } + + return cleared; +} + /** Drops the last digit of a segment. Returns undefined when there was nothing left to drop */ function removeLastDigit(segments: DateSegments, name: DateSegmentName): DateSegments | undefined { if (!segments[name]) { @@ -363,6 +374,7 @@ function hasAnySegment(segments: DateSegments): boolean { export { DATE_SEGMENT_NAMES, + clearSegmentsUpTo, EMPTY_SEGMENTS, getAdjacentSegmentName, getDateMaskParts, diff --git a/tests/unit/DateInputMaskUtilsTest.ts b/tests/unit/DateInputMaskUtilsTest.ts index 7bf85a2676f0..71858e852b21 100644 --- a/tests/unit/DateInputMaskUtilsTest.ts +++ b/tests/unit/DateInputMaskUtilsTest.ts @@ -1,4 +1,5 @@ import { + clearSegmentsUpTo, getAdjacentSegmentName, getDateMaskParts, getFirstUnfilledSegmentName, @@ -170,6 +171,23 @@ describe('DateInputMaskUtils', () => { }); }); + describe('clearSegmentsUpTo', () => { + it('empties the segment and everything before it, leaving the rest alone', () => { + // Given the day, which is the last segment, so there is nothing after it to keep + expect(clearSegmentsUpTo(segments('2026', '09', '02'), 'day')).toEqual(EMPTY); + + // Given the month, which leaves the day the user has already entered + expect(clearSegmentsUpTo(segments('2026', '09', '02'), 'month')).toEqual(segments('', '', '02')); + + // Given the year, which is the first segment, so only it is emptied + expect(clearSegmentsUpTo(segments('2026', '09', '02'), 'year')).toEqual(segments('', '09', '02')); + }); + + it('leaves an already empty date empty', () => { + expect(clearSegmentsUpTo(EMPTY, 'day')).toEqual(EMPTY); + }); + }); + describe('getSegmentDisplay', () => { it('shows nothing for an empty segment, leaving its own placeholder to show through', () => { expect(getSegmentDisplay(EMPTY, 'year')).toBe(''); From 48c8e94eba91b55dfc4ec50c35917176da8b1136 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Sun, 27 Sep 2026 05:54:01 +0530 Subject: [PATCH 46/80] fix: let the calendar tell a date it holds from one standing in for none Signed-off-by: krishna2323 --- src/components/DatePicker/CalendarPicker/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/DatePicker/CalendarPicker/index.tsx b/src/components/DatePicker/CalendarPicker/index.tsx index 890fd36d3166..4c51a16e4dfd 100644 --- a/src/components/DatePicker/CalendarPicker/index.tsx +++ b/src/components/DatePicker/CalendarPicker/index.tsx @@ -28,7 +28,7 @@ import MonthPickerModal from './MonthPickerModal'; import YearPickerModal from './YearPickerModal'; type CalendarPickerProps = { - /** An initial value of date string */ + /** The date the calendar opens on and marks as chosen. Empty for a field that holds no date yet */ value?: Date | string; /** A minimum date (oldest) allowed to select */ @@ -99,7 +99,7 @@ function setYearKeepingDay(date: Date, year: number) { } function CalendarPicker({ - value = new Date(), + value = '', minDate = setYear(new Date(), CONST.CALENDAR_PICKER.MIN_YEAR), maxDate = setYear(new Date(), CONST.CALENDAR_PICKER.MAX_YEAR), onSelected, From 5726876dad8989b217f7c1383007d4063d2a879b Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Sun, 27 Sep 2026 05:58:15 +0530 Subject: [PATCH 47/80] fix: use the shared color helper for the date mask Signed-off-by: krishna2323 --- src/components/DateSegmentsInput.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/DateSegmentsInput.tsx b/src/components/DateSegmentsInput.tsx index 780e3d42d886..1f80de2c23ae 100644 --- a/src/components/DateSegmentsInput.tsx +++ b/src/components/DateSegmentsInput.tsx @@ -81,6 +81,10 @@ function DateSegmentsInput({ // The field's input style stretches across the row, which would spread the segments apart const sizedToContent = [styles.flexGrow0, styles.flexShrink0, styles.flexBasisAuto, styles.wAuto]; + // The mask letters and the separators between them read as placeholder text rather than as the entered date. The + // field always hands down a theme color, though the prop's type also allows a platform color that has no string. + const maskColorStyle = StyleUtils.getColorStyle(typeof placeholderTextColor === 'string' ? placeholderTextColor : theme.placeholderText); + return ( {segmentProps.value} - {remainder} + {remainder} )} {!!part.separator && ( Date: Wed, 30 Sep 2026 10:01:38 +0530 Subject: [PATCH 48/80] fix: collapse a selected date on press and copy it on cmd+c Signed-off-by: krishna2323 --- src/components/DateSegmentsInput.tsx | 8 ++++---- src/hooks/useDateSegmentInput.ts | 20 +++++++++++++++++--- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/components/DateSegmentsInput.tsx b/src/components/DateSegmentsInput.tsx index 1f80de2c23ae..62d1f9a65ff8 100644 --- a/src/components/DateSegmentsInput.tsx +++ b/src/components/DateSegmentsInput.tsx @@ -139,10 +139,10 @@ function DateSegmentsInput({ onFocus?.(event); }} onBlur={handleSegmentBlur} - onPressOut={(event) => { - segmentProps.onPressOut(); - onPressOut?.(event); - }} + onPressOut={onPressOut} + // react-native-web gives a text input no press events, so the press that collapses + // a whole field selection has to be caught as a pointer one + onPointerDown={segmentProps.onPointerDown} accessibilityLabel={translate(`common.dateSegments.${part.name}`)} // The next digit replaces the whole selected date, so a caret would mislead caretHidden={isAllSelected} diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index 1ba6ad9f76b9..eaf3c7d56fff 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -4,6 +4,7 @@ */ import type {AnimatedTextInputRef} from '@components/RNTextInput'; +import Clipboard from '@libs/Clipboard'; import { clearSegmentsUpTo, DATE_SEGMENT_NAMES, @@ -33,6 +34,7 @@ const LAST_SEGMENT_NAME = DATE_SEGMENT_NAMES[DATE_SEGMENT_NAMES.length - 1]; const DELETE_KEY = 'Delete'; const SELECT_ALL_KEY = 'a'; +const COPY_KEY = 'c'; const MOVE_KEYS = { [CONST.KEYBOARD_SHORTCUTS.ARROW_LEFT.shortcutKey]: -1, [CONST.KEYBOARD_SHORTCUTS.ARROW_RIGHT.shortcutKey]: 1, @@ -66,7 +68,7 @@ type DateSegmentProps = { onFocus: () => void; /** Puts the caret back where the user pressed, which is what collapses a whole field selection */ - onPressOut: () => void; + onPointerDown: () => void; }; type UseDateSegmentInputResult = { @@ -200,6 +202,17 @@ function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: Use return; } + // Selecting the whole date selects nothing in the document, so the browser has nothing of its own to copy + if (key.toLowerCase() === COPY_KEY && isAllSelected) { + const isoDate = getISODateFromSegments(segments); + + if (isoDate) { + event.preventDefault(); + Clipboard.setString(isoDate); + } + return; + } + if (key.toLowerCase() !== SELECT_ALL_KEY || !hasAnySegment(segments)) { return; } @@ -344,7 +357,7 @@ function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: Use isAllSelected: false, isEditing: false, hasInvalidEntry: false, - getSegmentProps: () => ({value: '', onKeyPress: () => {}, onChangeText: () => {}, onFocus: () => {}, onPressOut: () => {}}), + getSegmentProps: () => ({value: '', onKeyPress: () => {}, onChangeText: () => {}, onFocus: () => {}, onPointerDown: () => {}}), onFieldBlur: () => {}, onClear: () => {}, }; @@ -355,6 +368,7 @@ function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: Use const displayedSegments = shouldShowSegments ? segments : getSegmentsFromISODate(value); const focusFirstUnfilledSegment = () => { + setIsAllSelected(false); focusSegment(getFirstUnfilledSegmentName(displayedSegments) ?? LAST_SEGMENT_NAME); }; @@ -373,7 +387,7 @@ function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: Use onKeyPress: (event: TextInputKeyPressEvent) => handleKeyPress(name, event), onChangeText: (text: string) => handleChangeText(name, text), onFocus: handleSegmentFocus, - onPressOut: () => setIsAllSelected(false), + onPointerDown: () => setIsAllSelected(false), }), onFieldBlur: handleFieldBlur, onClear: handleClear, From a68efd203d84bcf23cfbd94464853fe2e3deb660 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 30 Sep 2026 16:35:48 +0530 Subject: [PATCH 49/80] fix: hold back a date being typed, and block saving one that cannot be used Signed-off-by: krishna2323 --- src/components/DatePicker/index.tsx | 21 +++++++++++++--- src/components/DatePicker/types.ts | 7 ++++++ src/hooks/useDateSegmentInput.ts | 25 ++++++++++++++++--- ...rsonalCardEditTransactionStartDatePage.tsx | 7 ++++++ ...ompanyCardEditTransactionStartDatePage.tsx | 7 ++++++ .../assignCard/TransactionStartDateStep.tsx | 7 ++++++ 6 files changed, 66 insertions(+), 8 deletions(-) diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index c9bec49bfc4b..62032b518648 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -8,6 +8,7 @@ import useDateSegmentInput from '@hooks/useDateSegmentInput'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useRemeasureOnScroll from '@hooks/useRemeasureOnScroll'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -56,10 +57,15 @@ function DatePicker({ rightHandSideComponent, onPickerVisibilityChange, shouldHideCalendarIcon = false, + onValidationErrorChange, }: DateInputWithPickerProps) { const icons = useMemoizedLazyExpensifyIcons(['Calendar']); const styles = useThemeStyles(); const {windowHeight, windowWidth} = useWindowDimensions(); + // The window's own width, and not the narrow layout, which a right hand pane reports even on a wide screen. Most + // date fields sit in one, and there is room to type beside them there. + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); const {translate} = useLocalize(); const {setInputValidationError} = useContext(FormContext); @@ -75,8 +81,9 @@ function DatePicker({ // picker was dismissed before it resolved. const openIntentRef = useRef(false); - // Touch devices keep the calendar on its own, so the soft keyboard does not cover it - const shouldAllowTyping = !canUseTouchScreen(); + // Touch devices keep the calendar on its own, so the soft keyboard does not cover it. A narrow window has no room + // to show the calendar beside the field either, so it covers what is being typed. + const shouldAllowTyping = !canUseTouchScreen() && !isSmallScreenWidth; const dateMask = translate('common.dateFormat'); // A date the calendar could never have offered is one nothing downstream expects, and typing is what makes those @@ -259,16 +266,22 @@ function DatePicker({ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const dateErrorText = errorText || ownError; + // An entry part way through is never handed over, so the field still holds the date from before it. Saving that + // would discard an edit the user is in the middle of, and the error is not shown because they are still making it. + const blockingError = segmentInput.hasIncompleteEntry ? translate('common.error.dateInvalid') : ownError; + useEffect(() => { - setInputValidationError(inputID, ownError); + setInputValidationError(inputID, blockingError); + onValidationErrorChange?.(blockingError); // A field that is gone has nothing left to report, so its error must not outlive it and block the form return () => setInputValidationError(inputID, ''); - }, [inputID, ownError, setInputValidationError]); + }, [inputID, blockingError, setInputValidationError, onValidationErrorChange]); const handleClear = () => { onTouched?.(); setInputValidationError(inputID, ''); + onValidationErrorChange?.(''); onInputChange?.(''); setSelectedDate(''); segmentInput.onClear(); diff --git a/src/components/DatePicker/types.ts b/src/components/DatePicker/types.ts index a3ee39886b5a..978dbe63f262 100644 --- a/src/components/DatePicker/types.ts +++ b/src/components/DatePicker/types.ts @@ -85,6 +85,13 @@ type DateInputWithPickerProps = DatePickerBaseProps & * @default false */ shouldHideCalendarIcon?: boolean; + + /** + * Reports an error the field works out for itself, such as a date outside the range its calendar offers or + * digits that do not make a date. A field inside a form reports these to the form, so this is for the pages + * that own their own submit. An empty string withdraws the error. + */ + onValidationErrorChange?: (error: string) => void; }; type DatePickerProps = { diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index eaf3c7d56fff..b8832c363c3f 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -99,6 +99,9 @@ type UseDateSegmentInputResult = { /** Whether the field was left holding digits that do not add up to a date, which reads as no date at all */ hasInvalidEntry: boolean; + /** Whether the segments hold digits that do not add up to a date, so there is no date to hand over yet */ + hasIncompleteEntry: boolean; + getSegmentProps: (name: DateSegmentName) => DateSegmentProps; /** Called once focus has left the field altogether rather than moved between segments */ @@ -165,11 +168,18 @@ function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: Use }; /** - * Runs on every keystroke rather than once the date is finished. A form can be submitted before the field is left, - * so an entry part way through has to read as no date at all rather than the date from before the edit. + * Runs on every keystroke rather than once the date is finished. An entry part way through is held back, since a + * consumer that acts on each change would act on a date the user has not finished writing. Emptying the field is + * reported, because that is a change the user has finished making. */ const commitSegments = (newSegments: DateSegments) => { - onCommit(getISODateFromSegments(newSegments) ?? ''); + const isoDate = getISODateFromSegments(newSegments); + + if (!isoDate && hasAnySegment(newSegments)) { + return; + } + + onCommit(isoDate ?? ''); }; /** The count carries the request, since the user may have moved the calendar away and typed the month it was on */ @@ -282,9 +292,14 @@ function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: Use // Every keystroke is prevented, so this only runs for text the user pasted in const handleChangeText = (name: DateSegmentName, text: string) => { + // The input reports its whole value, and the caret rests after the digits the segment already holds, so what + // the user pasted is whatever follows them + const shownValue = getSegmentDisplay(segments, name); + const pastedText = text.startsWith(shownValue) ? text.slice(shownValue.length) : text; + // A paste over the whole date replaces it, while one into a single segment merges into what is already there const baseSegments = isAllSelected ? EMPTY_SEGMENTS : segments; - const pastedSegments = getSegmentsFromText(text, name, baseSegments); + const pastedSegments = getSegmentsFromText(pastedText, name, baseSegments); // Text with no digits in it leaves every segment as it was, so there is nothing to apply and nowhere to move if (pastedSegments === baseSegments) { @@ -357,6 +372,7 @@ function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: Use isAllSelected: false, isEditing: false, hasInvalidEntry: false, + hasIncompleteEntry: false, getSegmentProps: () => ({value: '', onKeyPress: () => {}, onChangeText: () => {}, onFocus: () => {}, onPointerDown: () => {}}), onFieldBlur: () => {}, onClear: () => {}, @@ -382,6 +398,7 @@ function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: Use isAllSelected, isEditing, hasInvalidEntry, + hasIncompleteEntry: shouldShowSegments && hasAnySegment(segments) && !getISODateFromSegments(segments), getSegmentProps: (name: DateSegmentName) => ({ value: getSegmentDisplay(displayedSegments, name), onKeyPress: (event: TextInputKeyPressEvent) => handleKeyPress(name, event), diff --git a/src/pages/settings/Wallet/PersonalCardEditTransactionStartDatePage.tsx b/src/pages/settings/Wallet/PersonalCardEditTransactionStartDatePage.tsx index 9e28866d9db1..835d77176ae4 100644 --- a/src/pages/settings/Wallet/PersonalCardEditTransactionStartDatePage.tsx +++ b/src/pages/settings/Wallet/PersonalCardEditTransactionStartDatePage.tsx @@ -55,6 +55,8 @@ function PersonalCardEditTransactionStartDatePage({route}: PersonalCardEditTrans }); const [errorText, setErrorText] = useState(''); + // The field reports a date its calendar would never have offered, which only typing can reach + const [dateError, setDateError] = useState(''); const handleSelectDateOption = (dateOption: DateOption) => { setErrorText(''); @@ -79,6 +81,10 @@ function PersonalCardEditTransactionStartDatePage({route}: PersonalCardEditTrans return; } + if (dateOptionSelected === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM && dateError) { + return; + } + const newStartDate = getNewStartDate(); if (currentStartDate !== newStartDate) { @@ -148,6 +154,7 @@ function PersonalCardEditTransactionStartDatePage({route}: PersonalCardEditTrans }} minDate={CONST.CALENDAR_PICKER.MIN_DATE} errorText={errorText} + onValidationErrorChange={setDateError} /> ) : null diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardEditTransactionStartDatePage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardEditTransactionStartDatePage.tsx index ef3e2833d584..f08544dcff47 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardEditTransactionStartDatePage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardEditTransactionStartDatePage.tsx @@ -69,6 +69,8 @@ function WorkspaceCompanyCardEditTransactionStartDatePage({route, navigation}: W }); const [errorText, setErrorText] = useState(''); + // The field reports a date its calendar would never have offered, which only typing can reach + const [dateError, setDateError] = useState(''); const handleSelectDateOption = (dateOption: DateOption) => { setErrorText(''); @@ -88,6 +90,10 @@ function WorkspaceCompanyCardEditTransactionStartDatePage({route, navigation}: W return; } + if (dateOptionSelected === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM && dateError) { + return; + } + const newStartDate = dateOptionSelected === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.FROM_BEGINNING ? '' : startDate; updateCardTransactionStartDate(domainOrWorkspaceAccountID, cardID, newStartDate, bank, currentStartDate); @@ -161,6 +167,7 @@ function WorkspaceCompanyCardEditTransactionStartDatePage({route, navigation}: W }} minDate={CONST.CALENDAR_PICKER.MIN_DATE} errorText={errorText} + onValidationErrorChange={setDateError} /> ) : null diff --git a/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx b/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx index ce7050fa39ce..44367f6883f6 100644 --- a/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx @@ -43,6 +43,8 @@ function TransactionStartDateStep({route}: TransactionStartDateStepProps) { const [localDateOption, setLocalDateOption] = useState(); const [errorText, setErrorText] = useState(''); const [localStartDate, setLocalStartDate] = useState(); + // The field reports a date its calendar would never have offered, which only typing can reach + const [dateError, setDateError] = useState(''); const dateOptionSelected = localDateOption ?? cardToAssign?.dateOption ?? CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM; const startDate = localStartDate ?? cardToAssign?.startDate ?? format(new Date(), CONST.DATE.FNS_FORMAT_STRING); @@ -70,6 +72,10 @@ function TransactionStartDateStep({route}: TransactionStartDateStepProps) { return; } + if (dateOptionSelected === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM && dateError) { + return; + } + setAssignCardStepAndData({ cardToAssign: { dateOption: dateOptionSelected, @@ -155,6 +161,7 @@ function TransactionStartDateStep({route}: TransactionStartDateStepProps) { }} minDate={CONST.CALENDAR_PICKER.MIN_DATE} errorText={errorText} + onValidationErrorChange={setDateError} /> ) : null From d3b92dd331673350daee22a9a741b00c866f7781 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 1 Oct 2026 14:38:10 +0530 Subject: [PATCH 50/80] fix: show a date field's required error once it is left, not as it empties Signed-off-by: krishna2323 --- src/components/DatePicker/index.tsx | 8 +++++++- ...rsonalCardEditTransactionStartDatePage.tsx | 20 ++++++++----------- ...ompanyCardEditTransactionStartDatePage.tsx | 20 ++++++++----------- .../assignCard/TransactionStartDateStep.tsx | 20 ++++++++----------- 4 files changed, 31 insertions(+), 37 deletions(-) diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index 62032b518648..35902c9a850d 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -278,6 +278,12 @@ function DatePicker({ return () => setInputValidationError(inputID, ''); }, [inputID, blockingError, setInputValidationError, onValidationErrorChange]); + // Leaving the field is the point the user is finished with it, which is when a form lets a required error show + const handleFieldBlur = () => { + segmentInput.onFieldBlur(); + onTouched?.(); + }; + const handleClear = () => { onTouched?.(); setInputValidationError(inputID, ''); @@ -345,7 +351,7 @@ function DatePicker({ focusFirstUnfilledSegment: segmentInput.focusFirstUnfilledSegment, isSegmentElement: segmentInput.isSegmentElement, isAllSelected: segmentInput.isAllSelected, - onFieldBlur: segmentInput.onFieldBlur, + onFieldBlur: handleFieldBlur, hasTypedDigits: segmentInput.hasTypedDigits, } : undefined diff --git a/src/pages/settings/Wallet/PersonalCardEditTransactionStartDatePage.tsx b/src/pages/settings/Wallet/PersonalCardEditTransactionStartDatePage.tsx index 835d77176ae4..776ce99c136e 100644 --- a/src/pages/settings/Wallet/PersonalCardEditTransactionStartDatePage.tsx +++ b/src/pages/settings/Wallet/PersonalCardEditTransactionStartDatePage.tsx @@ -54,12 +54,14 @@ function PersonalCardEditTransactionStartDatePage({route}: PersonalCardEditTrans return format(new Date(), CONST.DATE.FNS_FORMAT_STRING); }); - const [errorText, setErrorText] = useState(''); + // A required error belongs after the field has been left or the page submitted, and not while the date is being filled + const [hasVisitedDateField, setHasVisitedDateField] = useState(false); // The field reports a date its calendar would never have offered, which only typing can reach const [dateError, setDateError] = useState(''); + const requiredError = hasVisitedDateField && !isRequiredFulfilled(startDate) ? translate('common.error.fieldRequired') : ''; const handleSelectDateOption = (dateOption: DateOption) => { - setErrorText(''); + setHasVisitedDateField(false); setDateOptionSelected(dateOption); if (dateOption === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.FROM_BEGINNING) { return; @@ -77,7 +79,7 @@ function PersonalCardEditTransactionStartDatePage({route}: PersonalCardEditTrans const submit = () => { if (dateOptionSelected === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM && !isRequiredFulfilled(startDate)) { - setErrorText(translate('common.error.fieldRequired')); + setHasVisitedDateField(true); return; } @@ -144,16 +146,10 @@ function PersonalCardEditTransactionStartDatePage({route}: PersonalCardEditTrans inputID="" value={startDate} label={translate('iou.startDate')} - onInputChange={(value) => { - if (!isRequiredFulfilled(value)) { - setErrorText(translate('common.error.fieldRequired')); - } else { - setErrorText(''); - } - setStartDate(value); - }} + onInputChange={setStartDate} minDate={CONST.CALENDAR_PICKER.MIN_DATE} - errorText={errorText} + errorText={requiredError} + onTouched={() => setHasVisitedDateField(true)} onValidationErrorChange={setDateError} /> diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardEditTransactionStartDatePage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardEditTransactionStartDatePage.tsx index f08544dcff47..de193b553196 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardEditTransactionStartDatePage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardEditTransactionStartDatePage.tsx @@ -68,12 +68,14 @@ function WorkspaceCompanyCardEditTransactionStartDatePage({route, navigation}: W return format(new Date(), CONST.DATE.FNS_FORMAT_STRING); }); - const [errorText, setErrorText] = useState(''); + // A required error belongs after the field has been left or the page submitted, and not while the date is being filled + const [hasVisitedDateField, setHasVisitedDateField] = useState(false); // The field reports a date its calendar would never have offered, which only typing can reach const [dateError, setDateError] = useState(''); + const requiredError = hasVisitedDateField && !isRequiredFulfilled(startDate) ? translate('common.error.fieldRequired') : ''; const handleSelectDateOption = (dateOption: DateOption) => { - setErrorText(''); + setHasVisitedDateField(false); setLocalDateOption(dateOption); if (dateOption === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.FROM_BEGINNING) { return; @@ -86,7 +88,7 @@ function WorkspaceCompanyCardEditTransactionStartDatePage({route, navigation}: W const submit = () => { if (dateOptionSelected === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM && !isRequiredFulfilled(startDate)) { - setErrorText(translate('common.error.fieldRequired')); + setHasVisitedDateField(true); return; } @@ -157,16 +159,10 @@ function WorkspaceCompanyCardEditTransactionStartDatePage({route, navigation}: W inputID="" value={startDate} label={translate('iou.startDate')} - onInputChange={(value) => { - if (!isRequiredFulfilled(value)) { - setErrorText(translate('common.error.fieldRequired')); - } else { - setErrorText(''); - } - setStartDate(value); - }} + onInputChange={setStartDate} minDate={CONST.CALENDAR_PICKER.MIN_DATE} - errorText={errorText} + errorText={requiredError} + onTouched={() => setHasVisitedDateField(true)} onValidationErrorChange={setDateError} /> diff --git a/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx b/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx index 44367f6883f6..5a6c3cbe7ee9 100644 --- a/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx @@ -41,12 +41,14 @@ function TransactionStartDateStep({route}: TransactionStartDateStepProps) { const cardToAssign = assignCard?.cardToAssign; const [localDateOption, setLocalDateOption] = useState(); - const [errorText, setErrorText] = useState(''); + // A required error belongs after the field has been left or the page submitted, and not while the date is being filled + const [hasVisitedDateField, setHasVisitedDateField] = useState(false); const [localStartDate, setLocalStartDate] = useState(); // The field reports a date its calendar would never have offered, which only typing can reach const [dateError, setDateError] = useState(''); const dateOptionSelected = localDateOption ?? cardToAssign?.dateOption ?? CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM; const startDate = localStartDate ?? cardToAssign?.startDate ?? format(new Date(), CONST.DATE.FNS_FORMAT_STRING); + const requiredError = hasVisitedDateField && !isRequiredFulfilled(startDate) ? translate('common.error.fieldRequired') : ''; const handleBackButtonPress = () => { if (isEditing) { @@ -58,7 +60,7 @@ function TransactionStartDateStep({route}: TransactionStartDateStepProps) { }; const handleSelectDateOption = (dateOption: string) => { - setErrorText(''); + setHasVisitedDateField(false); setLocalDateOption(dateOption); if (dateOption === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.FROM_BEGINNING) { return; @@ -68,7 +70,7 @@ function TransactionStartDateStep({route}: TransactionStartDateStepProps) { const submit = () => { if (dateOptionSelected === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM && !isRequiredFulfilled(startDate)) { - setErrorText(translate('common.error.fieldRequired')); + setHasVisitedDateField(true); return; } @@ -151,16 +153,10 @@ function TransactionStartDateStep({route}: TransactionStartDateStepProps) { inputID="" value={startDate} label={translate('iou.startDate')} - onInputChange={(value) => { - if (!isRequiredFulfilled(value)) { - setErrorText(translate('common.error.fieldRequired')); - } else { - setErrorText(''); - } - setLocalStartDate(value); - }} + onInputChange={setLocalStartDate} minDate={CONST.CALENDAR_PICKER.MIN_DATE} - errorText={errorText} + errorText={requiredError} + onTouched={() => setHasVisitedDateField(true)} onValidationErrorChange={setDateError} /> From 22a74afdb3fee1f0697fdae5ebb63a9fe92033bb Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 6 Oct 2026 00:32:05 +0530 Subject: [PATCH 51/80] fix: let a date be cut, keep the separator in place, and block confirming an unreadable one Signed-off-by: krishna2323 --- src/components/DatePicker/index.tsx | 5 +++- .../Provider.tsx | 5 ++++ .../MoneyRequestConfirmationFields/context.ts | 3 ++ .../hooks/useConfirmationListData.ts | 4 +++ .../hooks/useConfirmationValidation.ts | 9 ++++++ .../hooks/useFormErrorManagement.ts | 17 ++++++++++- .../sections/DateField.tsx | 9 ++++-- .../variants/DistanceConfirmationList.tsx | 1 + .../variants/InvoiceConfirmationList.tsx | 1 + .../variants/ManualConfirmationList.tsx | 1 + .../variants/PerDiemConfirmationList.tsx | 1 + .../variants/ScanConfirmationList.tsx | 1 + .../variants/TimeConfirmationList.tsx | 1 + src/hooks/useDateSegmentInput.ts | 29 +++++++++++++++---- 14 files changed, 77 insertions(+), 10 deletions(-) diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index 35902c9a850d..2129bbf77c9a 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -275,7 +275,10 @@ function DatePicker({ onValidationErrorChange?.(blockingError); // A field that is gone has nothing left to report, so its error must not outlive it and block the form - return () => setInputValidationError(inputID, ''); + return () => { + setInputValidationError(inputID, ''); + onValidationErrorChange?.(''); + }; }, [inputID, blockingError, setInputValidationError, onValidationErrorChange]); // Leaving the field is the point the user is finished with it, which is when a form lets a required error show diff --git a/src/components/MoneyRequestConfirmationFields/Provider.tsx b/src/components/MoneyRequestConfirmationFields/Provider.tsx index cdda85dfe6f3..3a016aa461fc 100644 --- a/src/components/MoneyRequestConfirmationFields/Provider.tsx +++ b/src/components/MoneyRequestConfirmationFields/Provider.tsx @@ -76,6 +76,9 @@ type ProviderProps = { /** Reports whether the inline tax amount field is currently empty, so submission can be blocked when it is left empty */ onTaxAmountEmptyChange?: (isEmpty: boolean) => void; + /** Reports whether the inline date field is holding something that is not a date, so submission can be blocked while it is */ + onDateValidationErrorChange?: (hasError: boolean) => void; + /** Block components rendered inside the Provider */ children: ReactNode; }; @@ -103,6 +106,7 @@ function Provider({ scrollFocusedInputIntoView, onSubmitForm, onTaxAmountEmptyChange, + onDateValidationErrorChange, children, }: ProviderProps) { const value = { @@ -128,6 +132,7 @@ function Provider({ scrollFocusedInputIntoView, onSubmitForm, onTaxAmountEmptyChange, + onDateValidationErrorChange, }; return {children}; } diff --git a/src/components/MoneyRequestConfirmationFields/context.ts b/src/components/MoneyRequestConfirmationFields/context.ts index 2979665143fd..4a3c5397f81d 100644 --- a/src/components/MoneyRequestConfirmationFields/context.ts +++ b/src/components/MoneyRequestConfirmationFields/context.ts @@ -45,6 +45,9 @@ type ConfirmationFieldsContextValue = { /** Reports whether the inline tax amount field is currently empty, so submission can be blocked when it is left empty (new manual expense flow). */ onTaxAmountEmptyChange?: (isEmpty: boolean) => void; + + /** Reports whether the inline date field is holding something that is not a date, so submission can be blocked while it is. */ + onDateValidationErrorChange?: (hasError: boolean) => void; }; const ConfirmationFieldsContext = createContext(null); diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationListData.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationListData.ts index 1d0b9c5a0758..eb7c613901ad 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationListData.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationListData.ts @@ -143,6 +143,7 @@ function useConfirmationListDataWithPolicy({ const [didConfirm, setDidConfirm] = useState(isConfirmed); const [didConfirmSplit, setDidConfirmSplit] = useState(false); const [isTaxAmountEmpty, setIsTaxAmountEmpty] = useState(false); + const [hasDateValidationError, setHasDateValidationError] = useState(false); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- reset the tax-empty flag whenever the transaction changes @@ -180,6 +181,7 @@ function useConfirmationListDataWithPolicy({ isDistanceRequest, isReadOnly, shouldShowDate, + hasDateValidationError, }); const isCategoryRequired = !!policy?.requiresCategory && !isTypeInvoice; @@ -248,6 +250,7 @@ function useConfirmationListDataWithPolicy({ isReadOnly, shouldShowDate, isTaxAmountEmpty, + hasDateValidationError, }); // The partially filled receipt may not be the one on screen, so bring it into view to show its inline errors. @@ -344,6 +347,7 @@ function useConfirmationListDataWithPolicy({ setFormError, clearFormErrors, setIsTaxAmountEmpty, + setHasDateValidationError, // Read from context by `ConfirmationFooterContent`, which owns the CTA label and the Test Drive tooltip confirm, diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts index 3069ddc4bcb1..d18220cfde2d 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts @@ -128,6 +128,9 @@ type UseConfirmationValidationParams = { /** Whether the inline tax amount field is currently left empty (new manual expense flow) */ isTaxAmountEmpty: boolean; + + /** Whether the inline date field is holding digits that do not read as a date, which never reach the transaction */ + hasDateValidationError: boolean; }; /** @@ -177,6 +180,7 @@ function useConfirmationValidation({ isReadOnly, shouldShowDate, isTaxAmountEmpty, + hasDateValidationError, }: UseConfirmationValidationParams): {validate: (paymentType?: PaymentMethodType) => ValidationResult | null} { const {getCurrencyDecimals} = useCurrencyListActions(); const selectedParticipantsCount = selectedParticipants.length; @@ -229,6 +233,11 @@ function useConfirmationValidation({ if (isConfirmationDateMissing(transaction, shouldShowDate, isReadOnly, canEnterScanFieldsManually)) { return {errorKey: 'common.error.fieldRequired'}; } + // An entry the field could not read as a date is never handed over, so the transaction still holds the date + // from before it. Confirming would save that one and silently discard what the user typed. + if (shouldShowDate && !isReadOnly && hasDateValidationError) { + return {errorKey: 'common.error.dateInvalid'}; + } const merchantValue = iouMerchant ?? ''; const {isValid: isMerchantLengthValid} = isValidInputLength(merchantValue, CONST.MERCHANT_NAME_MAX_BYTES); diff --git a/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts b/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts index 0544773f0627..674dbf115964 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts @@ -86,6 +86,9 @@ type UseFormErrorManagementParams = { /** Whether the confirmation is read-only (a read-only date is populated server-side, so it can't be missing) */ isReadOnly: boolean; + + /** Whether the date field is holding something that is not a date, so its error can be cleared once it is not */ + hasDateValidationError: boolean; }; type UseFormErrorManagementResult = { @@ -155,6 +158,7 @@ function useFormErrorManagement({ isDistanceRequest, shouldShowDate, isReadOnly, + hasDateValidationError, }: UseFormErrorManagementParams): UseFormErrorManagementResult { const isFocused = useIsFocused(); const {translate} = useLocalize(); @@ -235,6 +239,14 @@ function useFormErrorManagement({ setFormError(''); }, [isAmountRequiredMissing, isDateRequiredMissing, isMerchantRequiredMissing, partiallyManuallyFilledScanID, setFormError]); + // The field reports its own entry, so the error it raised on submit goes as soon as the entry reads as a date again + useEffect(() => { + if (formErrorRef.current !== 'common.error.dateInvalid' || hasDateValidationError) { + return; + } + setFormError(''); + }, [hasDateValidationError, setFormError]); + useEffect(() => { const currentFormError = formErrorRef.current; if (shouldDisplayFieldError && didConfirmSplit) { @@ -266,7 +278,10 @@ function useFormErrorManagement({ // rendered. Distance requests disable that input, and the read-only menu row it falls back to doesn't show the error, // so the distance-amount error stays in the footer. Otherwise an invalid distance expense would fail silently. const isSuppressedInline = (error: TranslationPaths | ''): boolean => - error === 'common.error.fieldRequired' || error === 'iou.error.invalidMerchant' || (!isDistanceRequest && error === 'common.error.invalidAmount'); + error === 'common.error.fieldRequired' || + error === 'common.error.dateInvalid' || + error === 'iou.error.invalidMerchant' || + (!isDistanceRequest && error === 'common.error.invalidAmount'); const computeErrorMessage = (): string | undefined => { if (routeError) { diff --git a/src/components/MoneyRequestConfirmationList/sections/DateField.tsx b/src/components/MoneyRequestConfirmationList/sections/DateField.tsx index 86bd69fc6a79..d60813ac5711 100644 --- a/src/components/MoneyRequestConfirmationList/sections/DateField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/DateField.tsx @@ -50,7 +50,7 @@ type DateFieldProps = { function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, formError, transactionID, action, iouType, reportID, reportActionID}: DateFieldProps) { const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); - const {isEditingSplitBill, canEnterScanFieldsManually} = useConfirmationFields(); + const {isEditingSplitBill, canEnterScanFieldsManually, onDateValidationErrorChange} = useConfirmationFields(); const {shouldUseDropdownRows} = useExpenseFormLayout(); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -86,6 +86,10 @@ function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, formError, const isDateRequiredMissing = isPartiallyEnteredScanExpense(dateState, canEnterScanFieldsManually) ? !dateState?.isCreatedSet : createdMissing; const inlineDateErrorText = formError === 'common.error.fieldRequired' && isDateRequiredMissing ? translate('common.error.fieldRequired') : ''; + // The field holds back an entry it cannot read as a date, so it is the only thing that knows one is there. Pressing + // Create is what raises it, which is why the message is shown from the form error rather than as the user types. + const invalidDateErrorText = formError === 'common.error.dateInvalid' ? translate('common.error.dateInvalid') : ''; + const handleDateChange = (newDate: string) => { if (!transactionID) { return; @@ -140,7 +144,8 @@ function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, formError, maxDate={CONST.CALENDAR_PICKER.MAX_DATE} onInputChange={handleDateChange} disabled={didConfirm} - errorText={inlineDateErrorText || dateErrorText} + errorText={inlineDateErrorText || invalidDateErrorText || dateErrorText} + onValidationErrorChange={(error) => onDateValidationErrorChange?.(!!error)} shouldDeferShowUntilPositioned // The hint only renders while the date is empty, and `TextInput` drops its right-hand-side // component whenever the clear button can appear, which it can't without a value to clear. diff --git a/src/components/MoneyRequestConfirmationList/variants/DistanceConfirmationList.tsx b/src/components/MoneyRequestConfirmationList/variants/DistanceConfirmationList.tsx index b58c6024ca9d..343083bb1dff 100644 --- a/src/components/MoneyRequestConfirmationList/variants/DistanceConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList/variants/DistanceConfirmationList.tsx @@ -93,6 +93,7 @@ function DistanceConfirmationList(props: MoneyRequestConfirmationListProps) { isOdometerDistanceRequest={isOdometerDistanceRequest} isGPSDistanceRequest={isGPSDistanceRequest} onTaxAmountEmptyChange={data.setIsTaxAmountEmpty} + onDateValidationErrorChange={data.setHasDateValidationError} > {renderFooter()} diff --git a/src/components/MoneyRequestConfirmationList/variants/InvoiceConfirmationList.tsx b/src/components/MoneyRequestConfirmationList/variants/InvoiceConfirmationList.tsx index c976cb5f7acb..e0baae879210 100644 --- a/src/components/MoneyRequestConfirmationList/variants/InvoiceConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList/variants/InvoiceConfirmationList.tsx @@ -23,6 +23,7 @@ function InvoiceConfirmationList(props: MoneyRequestConfirmationListProps) { {...data.confirmationFieldsProviderProps} isTypeInvoice onTaxAmountEmptyChange={data.setIsTaxAmountEmpty} + onDateValidationErrorChange={data.setHasDateValidationError} > Date: Tue, 6 Oct 2026 00:48:41 +0530 Subject: [PATCH 52/80] fix: copy a date that is only part typed instead of refusing to Signed-off-by: krishna2323 --- src/hooks/useDateSegmentInput.ts | 7 +++--- src/libs/DateInputMaskUtils.ts | 9 +++++++ tests/unit/DateInputMaskUtilsTest.ts | 25 +++++++++++++++++++ .../hooks/useConfirmationValidation.test.ts | 1 + .../hooks/useFormErrorManagement.test.tsx | 1 + 5 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index d184b17c7f55..f5d2fcee39a3 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -10,6 +10,7 @@ import { DATE_SEGMENT_NAMES, EMPTY_SEGMENTS, getAdjacentSegmentName, + getClipboardTextFromSegments, getFirstUnfilledSegmentName, getISODateFromSegments, getSegmentDisplay, @@ -216,14 +217,14 @@ function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: Use // Selecting the whole date selects nothing in the document, so the browser has neither anything of its own // to put on the clipboard nor anything to remove from the field if (isAllSelected && (key.toLowerCase() === COPY_KEY || key.toLowerCase() === CUT_KEY)) { - const isoDate = getISODateFromSegments(segments); + const clipboardText = getClipboardTextFromSegments(segments); - if (!isoDate) { + if (!clipboardText) { return; } event.preventDefault(); - Clipboard.setString(isoDate); + Clipboard.setString(clipboardText); if (key.toLowerCase() === CUT_KEY) { setIsAllSelected(false); diff --git a/src/libs/DateInputMaskUtils.ts b/src/libs/DateInputMaskUtils.ts index b2b05f120cb1..0f33e28fe226 100644 --- a/src/libs/DateInputMaskUtils.ts +++ b/src/libs/DateInputMaskUtils.ts @@ -368,6 +368,14 @@ function getISODateFromSegments(segments: DateSegments): string | undefined { return isValid(parse(isoDate, CONST.DATE.FNS_FORMAT_STRING, new Date())) ? isoDate : undefined; } +/** + * What a cut or copy puts on the clipboard. A date that is not finished has only the digits that were typed to offer, + * which is enough to put a date back together when they were entered from the start of the field. + */ +function getClipboardTextFromSegments(segments: DateSegments): string { + return getISODateFromSegments(segments) ?? DATE_SEGMENT_NAMES.map((name) => getSegmentDisplay(segments, name)).join(''); +} + function hasAnySegment(segments: DateSegments): boolean { return DATE_SEGMENT_NAMES.some((name) => !!segments[name]); } @@ -377,6 +385,7 @@ export { clearSegmentsUpTo, EMPTY_SEGMENTS, getAdjacentSegmentName, + getClipboardTextFromSegments, getDateMaskParts, getFirstUnfilledSegmentName, getISODateFromSegments, diff --git a/tests/unit/DateInputMaskUtilsTest.ts b/tests/unit/DateInputMaskUtilsTest.ts index 71858e852b21..77706b341f28 100644 --- a/tests/unit/DateInputMaskUtilsTest.ts +++ b/tests/unit/DateInputMaskUtilsTest.ts @@ -1,6 +1,7 @@ import { clearSegmentsUpTo, getAdjacentSegmentName, + getClipboardTextFromSegments, getDateMaskParts, getFirstUnfilledSegmentName, getISODateFromSegments, @@ -349,6 +350,30 @@ describe('DateInputMaskUtils', () => { }); }); + describe('getClipboardTextFromSegments', () => { + it('copies a finished date in the format the app stores', () => { + // Given every segment filled in + // When the date is cut or copied + // Then the clipboard carries the same text any other field would be given + expect(getClipboardTextFromSegments(segments('1990', '02', '03'))).toBe('1990-02-03'); + }); + + it('copies the digits typed so far while the date is unfinished', () => { + // Given a date the user has only started + // When it is cut or copied + // Then the digits go on their own, so pasting them back into a date field fills the same segments again + expect(getClipboardTextFromSegments(segments('1990', '', ''))).toBe('1990'); + expect(getClipboardTextFromSegments(segments('1990', '2', ''))).toBe('199002'); + }); + + it('has nothing to offer when no digit has been typed', () => { + // Given a field showing nothing but its mask + // When it is cut or copied + // Then the clipboard is left alone rather than being emptied + expect(getClipboardTextFromSegments(EMPTY)).toBe(''); + }); + }); + describe('hasAnySegment', () => { it('reports whether anything has been filled in', () => { expect(hasAnySegment(EMPTY)).toBe(false); diff --git a/tests/unit/hooks/useConfirmationValidation.test.ts b/tests/unit/hooks/useConfirmationValidation.test.ts index d0378318cc96..f19dcc8fa66b 100644 --- a/tests/unit/hooks/useConfirmationValidation.test.ts +++ b/tests/unit/hooks/useConfirmationValidation.test.ts @@ -105,6 +105,7 @@ const baseParams = { isReadOnly: false, shouldShowDate: true, isTaxAmountEmpty: false, + hasDateValidationError: false, } satisfies UseConfirmationValidationParams; function createValidationParamsForParticipant( diff --git a/tests/unit/hooks/useFormErrorManagement.test.tsx b/tests/unit/hooks/useFormErrorManagement.test.tsx index d21b7d7ff64f..f3d3f0475f3e 100644 --- a/tests/unit/hooks/useFormErrorManagement.test.tsx +++ b/tests/unit/hooks/useFormErrorManagement.test.tsx @@ -49,6 +49,7 @@ const baseParams: Params = { isDistanceRequest: false, shouldShowDate: false, isReadOnly: false, + hasDateValidationError: false, }; // A manual draft the user never typed a merchant into: `initMoneyRequest` seeds it with the "Expense" placeholder. From e236b1a22b97c7b39c61c3d285fea27e4cff0e34 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 6 Oct 2026 00:57:22 +0530 Subject: [PATCH 53/80] fix: report a pasted date that does not exist instead of nudging it to one that does Signed-off-by: krishna2323 --- src/libs/DateInputMaskUtils.ts | 53 ++++++++++++++++++---------- tests/unit/DateInputMaskUtilsTest.ts | 23 +++++++++--- 2 files changed, 53 insertions(+), 23 deletions(-) diff --git a/src/libs/DateInputMaskUtils.ts b/src/libs/DateInputMaskUtils.ts index 0f33e28fe226..1e9d38909aed 100644 --- a/src/libs/DateInputMaskUtils.ts +++ b/src/libs/DateInputMaskUtils.ts @@ -33,6 +33,8 @@ const MASK_LETTER_REGEX = /\p{L}/u; const NON_DIGIT_REGEX = /\D/g; +const DIGIT_GROUP_REGEX = /\d+/g; + type DateSegmentName = TupleToUnion; /** The digits typed into each segment, empty when the segment has not been filled in yet */ @@ -316,36 +318,49 @@ function getFirstUnfilledSegmentName(segments: DateSegments): DateSegmentName | return DATE_SEGMENT_NAMES.find((name) => segments[name].length < getSegmentLength(name)); } +/** Splits one run of digits across the segments in turn, which is how a date written without separators is read */ +function splitDigitsBySegmentLength(digits: string, names: DateSegmentName[]): string[] { + let remaining = digits; + + return names.map((name) => { + const taken = remaining.slice(0, getSegmentLength(name)); + remaining = remaining.slice(getSegmentLength(name)); + + return taken; + }); +} + /** * Fills the segments from arbitrary text, so pasting a date works without going through it a keystroke at a time. * * The digits land in `startName` first and carry into the segments after it, so pasting a month leaves a year that is * already there alone. A segment keeps what it held until the digits actually reach it, and is then replaced rather * than extended. Text holding no digits at all returns `baseSegments` itself, so a caller can tell nothing was pasted. + * + * What was pasted is taken at its word rather than read a digit at a time the way typing is. Typing offers to finish a + * segment early when the digit is too big to start a longer number, which would quietly turn a pasted 31st of February + * into the 3rd instead of reporting a date that does not exist. Separators say where each segment ends, and text + * without them is divided by how much each segment holds. */ function getSegmentsFromText(text: string, startName: DateSegmentName, baseSegments: DateSegments): DateSegments { - const digits = text.replaceAll(NON_DIGIT_REGEX, ''); - let filled = baseSegments; - let name = startName; - let shouldOverwrite = true; - - for (const digit of digits) { - const previousSegments = filled; - const result = typeDigitIntoSegments(previousSegments, name, digit, shouldOverwrite); - filled = result.segments; - shouldOverwrite = false; - - if (!result.nextSegmentName) { - // The last segment is full and there is nowhere left to carry to, so the rest of the text is dropped - if (filled[name].length >= getSegmentLength(name)) { - break; - } + const digitGroups = text.match(DIGIT_GROUP_REGEX); + + if (!digitGroups) { + return baseSegments; + } + + const names = DATE_SEGMENT_NAMES.slice(DATE_SEGMENT_NAMES.indexOf(startName)); + const digitsBySegment = digitGroups.length > 1 ? digitGroups : splitDigitsBySegmentLength(digitGroups[0], names); + const filled = {...baseSegments}; + + for (const [index, name] of names.entries()) { + const digits = digitsBySegment.at(index); + + if (!digits) { continue; } - // A digit too big for its own segment is carried into the next one, which has then already been written - shouldOverwrite = filled[result.nextSegmentName] === previousSegments[result.nextSegmentName]; - name = result.nextSegmentName; + filled[name] = digits.slice(0, getSegmentLength(name)); } return filled; diff --git a/tests/unit/DateInputMaskUtilsTest.ts b/tests/unit/DateInputMaskUtilsTest.ts index 77706b341f28..9e1c0f10879d 100644 --- a/tests/unit/DateInputMaskUtilsTest.ts +++ b/tests/unit/DateInputMaskUtilsTest.ts @@ -303,11 +303,26 @@ describe('DateInputMaskUtils', () => { expect(getSegmentsFromText('', 'month', baseSegments)).toBe(baseSegments); }); - it('carries a digit too big for its segment into the next one', () => { + it('keeps a pasted day its month does not have, rather than reading it as another day', () => { + // Given the 31st of a February, which is not a date + // When it is pasted + // Then the segments hold what was pasted, so the field can report it instead of showing the 3rd + expect(getSegmentsFromText('1990-02-31', 'year', EMPTY)).toEqual(segments('1990', '02', '31')); + expect(getSegmentsFromText('19900231', 'year', EMPTY)).toEqual(segments('1990', '02', '31')); + }); + + it('keeps a pasted number no segment could hold', () => { // Given a month that cannot read as 13 - // When the second digit overflows it - // Then the month keeps the first digit and the overflow lands in the day, rather than being dropped - expect(getSegmentsFromText('13', 'month', EMPTY)).toEqual(segments('', '01', '3')); + // When it is pasted + // Then the month holds it and reads as the impossible date it is, rather than becoming January the 3rd + expect(getSegmentsFromText('13', 'month', EMPTY)).toEqual(segments('', '13', '')); + }); + + it('reads a separated date that pads neither its month nor its day', () => { + // Given a date written the short way + // When it is pasted + // Then the separators say where each segment ends, so the month and the day are not run together + expect(getSegmentsFromText('1990-2-3', 'year', EMPTY)).toEqual(segments('1990', '2', '3')); }); }); From 02e667eb8061dfd587fb467340daaf82f657c4d6 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 6 Oct 2026 01:10:58 +0530 Subject: [PATCH 54/80] fix: match a text field's double and triple click beside the date Signed-off-by: krishna2323 --- src/components/DatePicker/index.tsx | 2 + src/components/DateSegmentsInput.tsx | 18 ++++++- .../TextInput/BaseTextInput/types.ts | 2 + src/hooks/useDateSegmentInput.ts | 52 +++++++++++++++++++ 4 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index 2129bbf77c9a..539fc3ff4a16 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -352,6 +352,8 @@ function DatePicker({ getSegmentProps: segmentInput.getSegmentProps, setSegmentRef: segmentInput.setSegmentRef, focusFirstUnfilledSegment: segmentInput.focusFirstUnfilledSegment, + selectLastSegment: segmentInput.selectLastSegment, + selectAllSegments: segmentInput.selectAllSegments, isSegmentElement: segmentInput.isSegmentElement, isAllSelected: segmentInput.isAllSelected, onFieldBlur: handleFieldBlur, diff --git a/src/components/DateSegmentsInput.tsx b/src/components/DateSegmentsInput.tsx index 62d1f9a65ff8..136de8bf13f7 100644 --- a/src/components/DateSegmentsInput.tsx +++ b/src/components/DateSegmentsInput.tsx @@ -61,7 +61,7 @@ function DateSegmentsInput({ return null; } - const {mask, getSegmentProps, setSegmentRef, focusFirstUnfilledSegment, isSegmentElement, isAllSelected, onFieldBlur} = dateSegmentsConfig; + const {mask, getSegmentProps, setSegmentRef, focusFirstUnfilledSegment, selectLastSegment, selectAllSegments, isSegmentElement, isAllSelected, onFieldBlur} = dateSegmentsConfig; const parts = getDateMaskParts(mask).map((part) => ({...part, segmentProps: getSegmentProps(part.name)})); @@ -185,7 +185,21 @@ function DateSegmentsInput({ event.preventDefault()} + onMouseDown={(event) => { + event.preventDefault(); + + // A text field answers a double click with the word under it and a triple click with the whole + // line. The space beside the date holds no text of its own, so the nearest segment stands in for + // the word, the way clicking past the end of a line does. + if (event.detail === 2) { + selectLastSegment(); + return; + } + + if (event.detail > 2) { + selectAllSegments(); + } + }} onPress={focusFirstUnfilledSegment} sentryLabel="DateSegmentsInput-EmptySpace" style={[styles.flex1, styles.cursorText]} diff --git a/src/components/TextInput/BaseTextInput/types.ts b/src/components/TextInput/BaseTextInput/types.ts index d650b38cd71a..0c5dd67d2e06 100644 --- a/src/components/TextInput/BaseTextInput/types.ts +++ b/src/components/TextInput/BaseTextInput/types.ts @@ -23,6 +23,8 @@ type DateSegmentsConfig = { getSegmentProps: UseDateSegmentInputResult['getSegmentProps']; setSegmentRef: UseDateSegmentInputResult['setSegmentRef']; focusFirstUnfilledSegment: UseDateSegmentInputResult['focusFirstUnfilledSegment']; + selectLastSegment: UseDateSegmentInputResult['selectLastSegment']; + selectAllSegments: UseDateSegmentInputResult['selectAllSegments']; isSegmentElement: UseDateSegmentInputResult['isSegmentElement']; isAllSelected: UseDateSegmentInputResult['isAllSelected']; onFieldBlur: UseDateSegmentInputResult['onFieldBlur']; diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index f5d2fcee39a3..7955b9654c64 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -80,6 +80,12 @@ type UseDateSegmentInputResult = { /** Where a press on the field rather than on a segment lands */ focusFirstUnfilledSegment: () => void; + /** Selects the last segment's own digits, which is the word a double click on the field lands nearest to */ + selectLastSegment: () => void; + + /** Selects the whole date, which is what a triple click on the field asks for */ + selectAllSegments: () => void; + /** Whether focus is going to another segment, which is moving within the field rather than leaving it */ isSegmentElement: (target: unknown) => boolean; @@ -131,6 +137,8 @@ function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: Use const [hasInvalidEntry, setHasInvalidEntry] = useState(false); // Native selection cannot span separate inputs, so selecting the whole date is tracked here and drawn by the field const [isAllSelected, setIsAllSelected] = useState(false); + // A repeated click selects as its last press goes down, and the press completing that click arrives after + const hasJustSelectedByClick = useRef(false); // A date set from outside, by the calendar or a restored draft, has to reach an edit in progress too if (value !== appliedValue) { @@ -383,6 +391,8 @@ function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: Use return { setSegmentRef: () => {}, focusFirstUnfilledSegment: () => {}, + selectLastSegment: () => {}, + selectAllSegments: () => {}, isSegmentElement: () => false, viewDate: undefined, viewDateVersion: 0, @@ -402,13 +412,55 @@ function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: Use const displayedSegments = shouldShowSegments ? segments : getSegmentsFromISODate(value); const focusFirstUnfilledSegment = () => { + // The press that completes a repeated click would otherwise put the caret straight back where it started + if (hasJustSelectedByClick.current) { + hasJustSelectedByClick.current = false; + return; + } + setIsAllSelected(false); focusSegment(getFirstUnfilledSegmentName(displayedSegments) ?? LAST_SEGMENT_NAME); }; + /** + * Hands the segment a selection of its own rather than the field's, so copying and replacing it are the browser's + * to answer, exactly as they are when the segment itself is double clicked. + */ + const selectLastSegment = () => { + const shownLength = getSegmentDisplay(displayedSegments, LAST_SEGMENT_NAME).length; + + // There are no digits to select yet, so the click is left to place the caret the way a single one does + if (!shownLength) { + return; + } + + hasJustSelectedByClick.current = true; + setIsAllSelected(false); + // Focus only announces itself on arrival, and the segment may be the one the caret is already in + setShouldOverwrite(true); + + const element = segmentRefs.current[LAST_SEGMENT_NAME]; + element?.focus(); + element?.setSelectionRange?.(0, shownLength); + }; + + const selectAllSegments = () => { + // There is no date to select yet, so the click is left to place the caret the way a single one does + if (!hasAnySegment(displayedSegments)) { + return; + } + + hasJustSelectedByClick.current = true; + setIsAllSelected(true); + // Selecting the date puts the caret at its start, so replacing it does not have to move focus first + enterSegment(FIRST_SEGMENT_NAME); + }; + return { setSegmentRef, focusFirstUnfilledSegment, + selectLastSegment, + selectAllSegments, isSegmentElement, viewDate: isEditing ? viewDate : undefined, viewDateVersion, From 3542415dd0b66e081adfdc6fc96e54ca6de18f31 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 7 Oct 2026 15:39:25 +0530 Subject: [PATCH 55/80] fix: keep a day its month does not have instead of moving the user off it Signed-off-by: krishna2323 --- src/libs/DateInputMaskUtils.ts | 74 ++++++++-------------------- tests/unit/DateInputMaskUtilsTest.ts | 61 +++++++++++++---------- 2 files changed, 55 insertions(+), 80 deletions(-) diff --git a/src/libs/DateInputMaskUtils.ts b/src/libs/DateInputMaskUtils.ts index 1e9d38909aed..150e31f3f144 100644 --- a/src/libs/DateInputMaskUtils.ts +++ b/src/libs/DateInputMaskUtils.ts @@ -7,7 +7,7 @@ import CONST from '@src/CONST'; import type {TupleToUnion} from 'type-fest'; -import {getDaysInMonth, isValid, parse} from 'date-fns'; +import {isValid, parse} from 'date-fns'; const DATE_SEGMENT_NAMES = ['year', 'month', 'day'] as const; @@ -18,16 +18,14 @@ const FIRST_MONTH = 1; /** * The highest a segment may read, and the highest its leading digit may be while still allowing a second one. The day - * entry here is the longest month, which stands in until a month has been typed to say how long its own month is. + * is held to the longest month rather than to the month that was typed, since narrowing it would answer the 31st of + * February with a different day instead of leaving a date that does not exist to be reported. */ const SEGMENT_LIMITS = { month: {max: 12, maxLeadingDigit: 1}, day: {max: 31, maxLeadingDigit: 3}, } as const; -/** Stands in for a year that is not finished, so February keeps its 29th until the year rules it out */ -const FALLBACK_LEAP_YEAR = 2024; - /** Mask characters standing in for a digit are letters, so anything else is a separator to copy through verbatim */ const MASK_LETTER_REGEX = /\p{L}/u; @@ -137,31 +135,12 @@ type SegmentDigitResult = { carry?: string; }; -/** - * How high the day may go, and how high its leading digit may be, in the month that has been typed. Falling back to - * the longest month while no month has been typed is what keeps a day entered before a month from being held to a - * limit the user has not set yet. - */ -function getDayLimits(segments: DateSegments): {max: number; maxLeadingDigit: number} { - const monthNumber = Number(segments.month); - - if (!segments.month || monthNumber < FIRST_MONTH || monthNumber > SEGMENT_LIMITS.month.max) { - return SEGMENT_LIMITS.day; - } - - const year = segments.year.length === YEAR_LENGTH ? Number(segments.year) : FALLBACK_LEAP_YEAR; - const max = getDaysInMonth(new Date(year, monthNumber - 1, 1)); - - // A month of 28 or 29 days cannot start a day with a 3, since there is no 30th to complete it - return {max, maxLeadingDigit: Math.floor(max / 10)}; -} - /** * Adds one typed digit to a single segment, without knowing about the others. A digit that cannot extend what is * already there is handed on rather than dropped, so typing 1 then 3 into the month reads as January and starts the * day off with the 3. */ -function typeDigitIntoOneSegment(name: DateSegmentName, typedSoFar: string, digit: string, segments: DateSegments): SegmentDigitResult { +function typeDigitIntoOneSegment(name: DateSegmentName, typedSoFar: string, digit: string): SegmentDigitResult { const current = typedSoFar.length >= getSegmentLength(name) ? '' : typedSoFar; if (name === 'year') { @@ -170,7 +149,7 @@ function typeDigitIntoOneSegment(name: DateSegmentName, typedSoFar: string, digi return {value: year, shouldAdvance: year.length === YEAR_LENGTH}; } - const limits = name === 'day' ? getDayLimits(segments) : SEGMENT_LIMITS[name]; + const limits = SEGMENT_LIMITS[name]; if (current.length === 1) { const combined = `${current}${digit}`; @@ -197,24 +176,6 @@ function typeDigitIntoOneSegment(name: DateSegmentName, typedSoFar: string, digi return {value: digit, shouldAdvance: false}; } -/** - * Moves the day back to the last of its month when the month or year typed after it has made it impossible. The day - * the user was closest to is kept rather than cleared, so a date that cannot exist never reaches the field. - */ -function clampDayToMonth(segments: DateSegments): DateSegments { - if (!segments.day) { - return segments; - } - - const {max} = getDayLimits(segments); - - if (Number(segments.day) <= max) { - return segments; - } - - return {...segments, day: String(max)}; -} - /** * Adds one typed digit, following a carried digit into the following segments for as long as they keep handing one on. * `nextSegmentName` is where the caret belongs afterwards, and is undefined while the segment is unfinished. @@ -232,7 +193,7 @@ function typeDigitIntoSegments( let nextSegmentName: DateSegmentName | undefined; for (;;) { - const result = typeDigitIntoOneSegment(currentName, typedSoFar, currentDigit, filled); + const result = typeDigitIntoOneSegment(currentName, typedSoFar, currentDigit); filled[currentName] = result.value; const followingName = getFollowingSegmentName(currentName); @@ -250,7 +211,7 @@ function typeDigitIntoSegments( currentDigit = result.carry; } - return {segments: clampDayToMonth(filled), nextSegmentName}; + return {segments: filled, nextSegmentName}; } /** @@ -337,10 +298,10 @@ function splitDigitsBySegmentLength(digits: string, names: DateSegmentName[]): s * already there alone. A segment keeps what it held until the digits actually reach it, and is then replaced rather * than extended. Text holding no digits at all returns `baseSegments` itself, so a caller can tell nothing was pasted. * - * What was pasted is taken at its word rather than read a digit at a time the way typing is. Typing offers to finish a - * segment early when the digit is too big to start a longer number, which would quietly turn a pasted 31st of February - * into the 3rd instead of reporting a date that does not exist. Separators say where each segment ends, and text - * without them is divided by how much each segment holds. + * What was pasted is taken at its word rather than read a digit at a time the way typing is. Typing hands a digit + * that cannot extend a segment on to the next one, which would quietly turn a pasted 13 into January the 3rd instead + * of reporting a month that does not exist. Separators say where each segment ends, and text without them is divided + * by how much each segment holds. */ function getSegmentsFromText(text: string, startName: DateSegmentName, baseSegments: DateSegments): DateSegments { const digitGroups = text.match(DIGIT_GROUP_REGEX); @@ -384,11 +345,18 @@ function getISODateFromSegments(segments: DateSegments): string | undefined { } /** - * What a cut or copy puts on the clipboard. A date that is not finished has only the digits that were typed to offer, - * which is enough to put a date back together when they were entered from the start of the field. + * What a cut or copy puts on the clipboard, which is the date the field is showing rather than only one it could hand + * over. An entry that does not read as a date keeps its separators, so it arrives somewhere else looking the way it + * looked here. + * + * A segment left empty before a filled one has no separator that would say which segment it was, so those digits go on + * their own and are read back by position. */ function getClipboardTextFromSegments(segments: DateSegments): string { - return getISODateFromSegments(segments) ?? DATE_SEGMENT_NAMES.map((name) => getSegmentDisplay(segments, name)).join(''); + const displays = DATE_SEGMENT_NAMES.map((name) => getSegmentDisplay(segments, name)); + const hasGap = displays.some((display, index) => !display && displays.slice(index + 1).some(Boolean)); + + return hasGap ? displays.join('') : displays.filter(Boolean).join('-'); } function hasAnySegment(segments: DateSegments): boolean { diff --git a/tests/unit/DateInputMaskUtilsTest.ts b/tests/unit/DateInputMaskUtilsTest.ts index 9e1c0f10879d..298b9b6b0d16 100644 --- a/tests/unit/DateInputMaskUtilsTest.ts +++ b/tests/unit/DateInputMaskUtilsTest.ts @@ -58,44 +58,37 @@ describe('DateInputMaskUtils', () => { expect(typeDigitIntoSegments(segments('2026', '0', ''), 'month', '0')).toEqual({segments: segments('2026', '0', ''), nextSegmentName: undefined}); }); - it('caps the day at the month that was typed', () => { - // Given January, which has a 31st - expect(typeDigitIntoSegments(segments('2026', '01', '3'), 'day', '1')).toEqual({segments: segments('2026', '01', '31'), nextSegmentName: undefined}); + it('keeps a day the month that was typed does not have, rather than reading it as another day', () => { + // Given February, which has no 31st, so the entry is left to be reported instead of answered with the 3rd + expect(typeDigitIntoSegments(segments('2026', '02', '3'), 'day', '1')).toEqual({segments: segments('2026', '02', '31'), nextSegmentName: undefined}); - // Given February, which has no 31st, so the 3 can only have meant the 3rd - expect(typeDigitIntoSegments(segments('2026', '02', '3'), 'day', '1')).toEqual({segments: segments('2026', '02', '03'), nextSegmentName: undefined}); + // Given January, where the same keystrokes read the same way + expect(typeDigitIntoSegments(segments('2026', '01', '3'), 'day', '1')).toEqual({segments: segments('2026', '01', '31'), nextSegmentName: undefined}); }); - it('finishes the day early in a month too short to start it with a 3', () => { - // Given February, where no day starts with a 3, so a 3 completes the segment instead of waiting - expect(typeDigitIntoSegments(segments('2026', '02', ''), 'day', '3')).toEqual({segments: segments('2026', '02', '03'), nextSegmentName: undefined}); - - // Given March, which has a 31st, so the 3 waits for a digit that might complete it + it('waits for a second digit after a 3 whichever month was typed', () => { + // Given February, where a 3 could still be meant to become the 31st the user is about to be told about + expect(typeDigitIntoSegments(segments('2026', '02', ''), 'day', '3')).toEqual({segments: segments('2026', '02', '3'), nextSegmentName: undefined}); expect(typeDigitIntoSegments(segments('2026', '03', ''), 'day', '3')).toEqual({segments: segments('2026', '03', '3'), nextSegmentName: undefined}); }); - it('allows February 29 in a leap year and not otherwise', () => { + it('keeps February 29 whether or not the year is a leap year', () => { expect(typeDigitIntoSegments(segments('2024', '02', '2'), 'day', '9')).toEqual({segments: segments('2024', '02', '29'), nextSegmentName: undefined}); - expect(typeDigitIntoSegments(segments('2026', '02', '2'), 'day', '9')).toEqual({segments: segments('2026', '02', '02'), nextSegmentName: undefined}); + expect(typeDigitIntoSegments(segments('2026', '02', '2'), 'day', '9')).toEqual({segments: segments('2026', '02', '29'), nextSegmentName: undefined}); }); - it('caps the day at the longest month while no month has been typed', () => { - // Given a day entered before a month, which cannot be held to a limit the user has not set yet + it('caps the day at the longest month there is', () => { expect(typeDigitIntoSegments(segments('2026', '', '3'), 'day', '1')).toEqual({segments: segments('2026', '', '31'), nextSegmentName: undefined}); + expect(typeDigitIntoSegments(segments('2026', '', '3'), 'day', '2')).toEqual({segments: segments('2026', '', '03'), nextSegmentName: undefined}); }); - it('moves the day back when the month typed after it is too short', () => { - // Given the 31st typed before November, which ends on the 30th - expect(typeDigitIntoSegments(segments('2026', '1', '31'), 'month', '1').segments).toEqual(segments('2026', '11', '30')); - - // Given the 31st typed before February, which the day's own limits never saw - expect(typeDigitIntoSegments(segments('2026', '0', '31'), 'month', '2').segments).toEqual(segments('2026', '02', '28')); + it('leaves the day alone when the month typed after it does not have that day', () => { + // Given the 31st typed before February, which the user is told about rather than moved off + expect(typeDigitIntoSegments(segments('2026', '0', '31'), 'month', '2').segments).toEqual(segments('2026', '02', '31')); }); - it('moves the day back when the year typed after it takes the leap day away', () => { - // Given February 29 typed before the year, which decides whether that day exists - expect(typeDigitIntoSegments(segments('202', '02', '29'), 'year', '6').segments).toEqual(segments('2026', '02', '28')); - expect(typeDigitIntoSegments(segments('202', '02', '29'), 'year', '4').segments).toEqual(segments('2024', '02', '29')); + it('leaves the day alone when the year typed after it takes the leap day away', () => { + expect(typeDigitIntoSegments(segments('202', '02', '29'), 'year', '6').segments).toEqual(segments('2026', '02', '29')); }); it('has nothing to carry into past the day, so a rejected pair restarts it', () => { @@ -373,12 +366,26 @@ describe('DateInputMaskUtils', () => { expect(getClipboardTextFromSegments(segments('1990', '02', '03'))).toBe('1990-02-03'); }); - it('copies the digits typed so far while the date is unfinished', () => { + it('copies a date that does not exist in the shape it was written in', () => { + // Given digits the field is holding back because they do not read as a date + // When they are cut or copied + // Then they arrive elsewhere looking the way they looked here rather than as a run of digits + expect(getClipboardTextFromSegments(segments('2003', '02', '31'))).toBe('2003-02-31'); + }); + + it('copies the segments filled in so far while the date is unfinished', () => { // Given a date the user has only started // When it is cut or copied - // Then the digits go on their own, so pasting them back into a date field fills the same segments again + // Then what was filled in is carried over, so pasting it back fills the same segments again expect(getClipboardTextFromSegments(segments('1990', '', ''))).toBe('1990'); - expect(getClipboardTextFromSegments(segments('1990', '2', ''))).toBe('199002'); + expect(getClipboardTextFromSegments(segments('1990', '2', ''))).toBe('1990-02'); + }); + + it('copies digits with a segment skipped over on their own', () => { + // Given a month filled in with no year before it, so no separator says which segment the digits came from + // When it is cut or copied + // Then they are handed over to be read back by position + expect(getClipboardTextFromSegments(segments('', '09', ''))).toBe('09'); }); it('has nothing to offer when no digit has been typed', () => { From 0d7e1e766ac25bfa5db16e6cab0619d9545c307a Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 7 Oct 2026 15:50:42 +0530 Subject: [PATCH 56/80] fix: let a typed date be undone, and dismiss the calendar on tabbing away Signed-off-by: krishna2323 --- src/components/DatePicker/index.tsx | 58 +++++++++++++++---------- src/hooks/useDateSegmentInput.ts | 65 +++++++++++++++++++++++++++-- src/types/modules/react-native.d.ts | 1 + 3 files changed, 99 insertions(+), 25 deletions(-) diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index 539fc3ff4a16..534fd81dbc53 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -107,9 +107,44 @@ function DatePicker({ onInputChange?.(newDate); }; + const setPickerVisibility = useCallback( + (isVisible: boolean) => { + setIsModalVisible(isVisible); + onPickerVisibilityChange?.(isVisible); + }, + [onPickerVisibilityChange], + ); + + const closeDatePicker = useCallback(() => { + openIntentRef.current = false; + setPickerVisibility(false); + + if (!shouldDismissKeyboardBeforeShow || shouldAllowTyping) { + return; + } + + textInputRef.current?.blur(); + ComposerFocusManager.blurActiveInput(); + Keyboard.dismiss(); + }, [shouldDismissKeyboardBeforeShow, shouldAllowTyping, setPickerVisibility]); + // The hook is the single gate on typing. When the platform does not allow it, the handlers it returns are no-ops // and the value passes straight through, so the call sites below do not have to check again. - const segmentInput = useDateSegmentInput({value: selectedDate, isEnabled: shouldAllowTyping, minDate, maxDate, onCommit: commitDate}); + const segmentInput = useDateSegmentInput({ + value: selectedDate, + isEnabled: shouldAllowTyping, + minDate, + maxDate, + onCommit: commitDate, + // Tabbing away leaves a calendar nothing has been pressed outside of, which is what otherwise dismisses it + onLeaveByKeyboard: () => { + if (!isModalVisible) { + return; + } + + closeDatePicker(); + }, + }); const {inputCallbackRef: autoFocusCallbackRef, cancelAutoFocus} = useAutoFocusInput(); const autoFocusCallbackRefRef = useRef(autoFocusCallbackRef); @@ -151,14 +186,6 @@ function DatePicker({ [windowHeight], ); - const setPickerVisibility = useCallback( - (isVisible: boolean) => { - setIsModalVisible(isVisible); - onPickerVisibilityChange?.(isVisible); - }, - [onPickerVisibilityChange], - ); - const showDatePickerModal = useCallback(() => { // Re-opening would remeasure and re-announce a calendar that is already showing. Both a press and a focus can // ask for it, and while typing both arrive for a single click. @@ -200,19 +227,6 @@ function DatePicker({ openPicker(); }, [isModalVisible, shouldDeferShowUntilPositioned, shouldDismissKeyboardBeforeShow, shouldAllowTyping, calculatePopoverPosition, cancelAutoFocus, setPickerVisibility]); - const closeDatePicker = useCallback(() => { - openIntentRef.current = false; - setPickerVisibility(false); - - if (!shouldDismissKeyboardBeforeShow || shouldAllowTyping) { - return; - } - - textInputRef.current?.blur(); - ComposerFocusManager.blurActiveInput(); - Keyboard.dismiss(); - }, [shouldDismissKeyboardBeforeShow, shouldAllowTyping, setPickerVisibility]); - const handlePress = useCallback>( (event) => { // The field focuses its own input on any press it is not told to leave alone, which would be the year diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index 7955b9654c64..001b23b635ea 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -37,6 +37,8 @@ const DELETE_KEY = 'Delete'; const SELECT_ALL_KEY = 'a'; const COPY_KEY = 'c'; const CUT_KEY = 'x'; +const UNDO_KEY = 'z'; +const REDO_KEY = 'y'; const MOVE_KEYS = { [CONST.KEYBOARD_SHORTCUTS.ARROW_LEFT.shortcutKey]: -1, [CONST.KEYBOARD_SHORTCUTS.ARROW_RIGHT.shortcutKey]: 1, @@ -60,6 +62,9 @@ type UseDateSegmentInputParams = { /** Called with a stored format date whenever the segments produce one, and with an empty string whenever they do not */ onCommit: (isoDate: string) => void; + + /** Called as a keystroke carries focus out of the field, which no press anywhere reports */ + onLeaveByKeyboard: () => void; }; /** Everything one segment's input needs. The segment itself is stateless and reports back through these */ @@ -123,7 +128,7 @@ function isMoveKey(key: string): key is keyof typeof MOVE_KEYS { return key in MOVE_KEYS; } -function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: UseDateSegmentInputParams): UseDateSegmentInputResult { +function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit, onLeaveByKeyboard}: UseDateSegmentInputParams): UseDateSegmentInputResult { // The segments only describe an edit in progress, so they are seeded on focus rather than synced with the value const [segments, setSegments] = useState(EMPTY_SEGMENTS); const [isEditing, setIsEditing] = useState(false); @@ -139,6 +144,15 @@ function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: Use const [isAllSelected, setIsAllSelected] = useState(false); // A repeated click selects as its last press goes down, and the press completing that click arrives after const hasJustSelectedByClick = useRef(false); + // Every keystroke is prevented, so each segment's input has an empty undo history of its own and the field keeps + // one covering all three. `pastSegments` is what undo walks back through and `undoneSegments` is what redo returns. + const pastSegments = useRef([]); + const undoneSegments = useRef([]); + + const forgetHistory = () => { + pastSegments.current = []; + undoneSegments.current = []; + }; // A date set from outside, by the calendar or a restored draft, has to reach an edit in progress too if (value !== appliedValue) { @@ -203,16 +217,41 @@ function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: Use }; /** The one place segments are written, so the calendar cannot fall out of step with them */ - const applySegments = (newSegments: DateSegments) => { + const writeSegments = (newSegments: DateSegments) => { setSegments(newSegments); assertViewDate(getViewDateFromSegments(newSegments, viewDate ?? new Date(), minDate, maxDate)); commitSegments(newSegments); }; + /** A change the user made, which is therefore one they can take back. Anything undone is no longer ahead of them. */ + const applySegments = (newSegments: DateSegments) => { + pastSegments.current.push(segments); + undoneSegments.current = []; + writeSegments(newSegments); + }; + + /** Steps one change back or forward, moving the state being left behind onto the other side of the history */ + const moveThroughHistory = (isRedo: boolean) => { + const from = isRedo ? undoneSegments.current : pastSegments.current; + const restored = from.pop(); + + if (!restored) { + return; + } + + (isRedo ? pastSegments : undoneSegments).current.push(segments); + setIsAllSelected(false); + writeSegments(restored); + + // The caret belongs where the user would be typing in the restored date, which is the digit it stops short at + focusSegment(getFirstUnfilledSegmentName(restored) ?? LAST_SEGMENT_NAME); + setShouldOverwrite(false); + }; + const handleKeyPress = (name: DateSegmentName, event: TextInputKeyPressEvent) => { const key = event.nativeEvent.key; - // A shortcut is the browser's to handle, apart from the two it cannot apply across separate inputs + // A shortcut is the browser's to handle, apart from the ones it cannot apply across separate inputs if (event.nativeEvent.metaKey || event.nativeEvent.ctrlKey) { if (key === CONST.KEYBOARD_SHORTCUTS.BACKSPACE.shortcutKey || key === DELETE_KEY) { event.preventDefault(); @@ -222,6 +261,13 @@ function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: Use return; } + // Each segment's input was never written to directly, so the browser has no change of its own to take back + if (key.toLowerCase() === UNDO_KEY || key.toLowerCase() === REDO_KEY) { + event.preventDefault(); + moveThroughHistory(key.toLowerCase() === REDO_KEY || !!event.nativeEvent.shiftKey); + return; + } + // Selecting the whole date selects nothing in the document, so the browser has neither anything of its own // to put on the clipboard nor anything to remove from the field if (isAllSelected && (key.toLowerCase() === COPY_KEY || key.toLowerCase() === CUT_KEY)) { @@ -256,6 +302,15 @@ function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: Use const wasAllSelected = isAllSelected; setIsAllSelected(false); + // Tab is left to the browser, which moves focus without pressing anything, so nothing else reports the field + // being left behind. Only the outermost segment hands focus out of the field rather than on to a sibling. + if (key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) { + if (event.nativeEvent.shiftKey ? name === FIRST_SEGMENT_NAME : name === LAST_SEGMENT_NAME) { + onLeaveByKeyboard(); + } + return; + } + if (isNumeric(key)) { event.preventDefault(); const result = typeDigitIntoSegments(wasAllSelected ? EMPTY_SEGMENTS : segments, name, key, shouldOverwrite); @@ -356,6 +411,8 @@ function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: Use return; } + forgetHistory(); + // Returning to an entry that was left unusable resumes it, since the date it reported was the empty one const seededSegments = hasInvalidEntry ? segments : getSegmentsFromISODate(value); setHasInvalidEntry(false); @@ -365,6 +422,7 @@ function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: Use }; const handleFieldBlur = () => { + forgetHistory(); setIsEditing(false); setViewDate(undefined); setShouldOverwrite(false); @@ -380,6 +438,7 @@ function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: Use // The clear button unmounts as it is pressed, so focus is placed here rather than left to whatever is underneath const handleClear = () => { + forgetHistory(); setSegments(EMPTY_SEGMENTS); setHasInvalidEntry(false); setIsAllSelected(false); diff --git a/src/types/modules/react-native.d.ts b/src/types/modules/react-native.d.ts index 9ce2d5d0b075..53f6d4d2c9db 100644 --- a/src/types/modules/react-native.d.ts +++ b/src/types/modules/react-native.d.ts @@ -50,6 +50,7 @@ declare module 'react-native' { interface TextInputKeyPressEventData { metaKey?: boolean; ctrlKey?: boolean; + shiftKey?: boolean; } interface PressableStateCallbackType extends WebPressableStateCallbackType { From c90b3e65372e05ca8a7ffb7ba4babb0bea379a92 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 13 Oct 2026 05:31:44 +0530 Subject: [PATCH 57/80] Merge remote-tracking branch 'upstream/main' into krishna2323/issue-101117 Signed-off-by: krishna2323 --- .../isDeployChecklistLocked/index.js | 305 +++++---- .../javascript/proposalPoliceComment/index.js | 305 +++++---- Gemfile | 2 +- Gemfile.lock | 239 ++++--- Mobile-Expensify | 2 +- android/app/build.gradle | 4 +- android/app/src/main/AndroidManifest.xml | 4 +- android/build.gradle | 2 +- ...mple-illustration__briefcase-handshake.svg | 1 - config/eslint/eslint.seatbelt.tsv | 11 +- cspell.json | 2 + docs/HELPSITE_NAMING_CONVENTIONS.md | 510 +++++++++++--- docs/HELP_AUTHORING_GUIDELINES.md | 31 +- docs/HELP_AUTHORING_GUIDELINES_TASK.md | 14 +- docs/HELP_AUTHORING_GUIDELINES_TOPIC.md | 10 +- docs/TEMPLATE-task.md | 25 +- docs/TEMPLATE-topic.md | 24 +- docs/TEMPLATE.md | 34 +- .../netsuite/Connect-To-NetSuite.md | 2 +- .../Manage-Vendor-Matching-for-Campfire.md | 26 +- .../netsuite/Connect-To-NetSuite.md | 5 - .../new-expensify/workspaces/Add-Approvals.md | 13 +- .../workspaces/Managing-Workspace-Members.md | 6 + ios/NewExpensify/Info.plist | 4 +- ios/NotificationServiceExtension/Info.plist | 4 +- ios/Podfile.lock | 48 +- ios/ShareViewController/Info.plist | 4 +- jest.config.js | 1 + jest/resolver.ts | 28 + jest/setup.ts | 5 - .../android/gradle.properties | 2 +- .../utils/contacts/HybridContactsModule.kt | 318 +++++---- .../ios/contacts/HybridContactsModule.swift | 7 +- .../android/c++/JHybridContactsModuleSpec.cpp | 35 + .../android/c++/JHybridContactsModuleSpec.hpp | 1 + .../nitro/utils/HybridContactsModuleSpec.kt | 4 + .../ios/c++/HybridContactsModuleSpecSwift.hpp | 9 + .../ios/swift/HybridContactsModuleSpec.swift | 1 + .../swift/HybridContactsModuleSpec_cxx.swift | 25 + .../shared/c++/HybridContactsModuleSpec.cpp | 1 + .../shared/c++/HybridContactsModuleSpec.hpp | 1 + .../src/specs/ContactsModule.nitro.ts | 3 + package-lock.json | 603 +++++++++-------- package.json | 36 +- ...-parser-worklet-in-insertion-effect.patch} | 8 +- .../react-native-live-markdown/details.md | 2 +- patches/expo-modules-core/details.md | 2 +- ...re+57.0.15+001+disableViewRecycling.patch} | 4 +- patches/react-native-reanimated/details.md | 9 - ...5.5+001+catch-all-exceptions-on-stoi.patch | 13 - patches/react-native-screens/details.md | 17 + ...lready-added-in-screenstack-onupdate.patch | 15 + patches/react-native-tab-view/details.md | 14 +- ...ab-view+4.3.2+001+fix-tab-animation.patch} | 7 +- ...2+002+fix-glitching-on-initial-load.patch} | 11 +- ....2+003+fix-web-onTabSelect-on-mount.patch} | 8 +- ...004+fix-native-onTabSelect-on-mount.patch} | 2 +- ....3.2+005+fix-pager-scrollleft-91610.patch} | 6 +- ...-failing-jest-by-disabling-esmodule.patch} | 0 ...-failing-jest-by-disabling-esmodule.patch} | 0 ...002+platform-navigation-stack-types.patch} | 10 +- ...+7.22.1+003+root-state-not-undefined.patch | 26 + ...-failing-jest-by-disabling-esmodule.patch} | 0 ...-failing-jest-by-disabling-esmodule.patch} | 0 ...history-go-popstate-fallback-timeout.patch | 29 - ...navigation+native+7.4.1+001+initial.patch} | 81 +-- ...-failing-jest-by-disabling-esmodule.patch} | 0 ...-failing-jest-by-disabling-esmodule.patch} | 0 ...+stack+7.11.2+001+edge-drag-gesture.patch} | 17 +- ...n+stack+7.11.2+002+dontDetachScreen.patch} | 34 +- ...-failing-jest-by-disabling-esmodule.patch} | 0 ...5+005+clip-inactive-card-inside-card.patch | 48 -- patches/react-navigation/details.md | 73 +- ....0.patch => @sentry+browser+10.75.0.patch} | 0 patches/sentry-browser/details.md | 2 +- ...=> @sentry+core+10.75.0+001+initial.patch} | 0 patches/sentry-core/details.md | 2 +- ...atch => @sentry+react-native+8.27.0.patch} | 2 +- patches/sentry-react-native/details.md | 2 +- patches/whatwg-fetch/details.md | 13 - ...1+use-microtask-queue-for-xhr-events.patch | 80 --- src/CONST/index.ts | 52 +- src/ONYXKEYS.ts | 18 + src/ROUTES.ts | 61 +- src/SCREENS.ts | 4 + .../AddPaymentCard/PaymentCardForm.tsx | 14 +- src/components/ApproverSelectionList.tsx | 16 +- .../AttachmentViewVideo/index.tsx | 6 +- .../Attachments/AttachmentView/index.tsx | 1 + src/components/AvatarWithDisplayName.tsx | 2 +- .../CardAuthenticationView/index.native.tsx | 5 + .../CardAuthenticationView/index.tsx | 64 ++ .../CategoryPicker/CategoryPickerModal.tsx | 33 +- src/components/CenteredModalLayout/index.tsx | 30 +- .../BarChart/VerticalBarChartContent.tsx | 8 +- src/components/Charts/BarChart/types.ts | 3 + .../Charts/LineChart/LineChartContent.tsx | 4 +- .../Charts/PieChart/PieChartContent.tsx | 2 +- .../Charts/hooks/useChartInteractions.ts | 12 +- ...CollapsibleHeaderOnKeyboardGroupContext.ts | 23 + .../CollapsibleHeaderView.tsx | 26 + .../Group/index.native.tsx | 93 +++ .../Group/index.tsx | 11 + .../GroupMember/index.native.tsx | 61 ++ .../GroupMember/index.tsx | 11 + .../CollapsibleHeaderOnKeyboard/constants.ts | 51 ++ .../index.native.tsx | 187 +---- .../CollapsibleHeaderOnKeyboard/index.tsx | 2 +- .../CollapsibleHeaderOnKeyboard/types.ts | 24 +- .../useCollapsibleHeader.ts | 136 ++++ .../useCollapsibleScreenState.ts | 36 + .../useKeyboardCollapseMetrics.ts | 43 ++ .../Composer/implementation/index.native.tsx | 5 +- .../DatePicker/CalendarPicker/index.tsx | 143 ++-- src/components/DatePicker/index.tsx | 17 +- src/components/DatePicker/types.ts | 9 +- src/components/DismissibleBackdrop.tsx | 43 ++ src/components/FullscreenLoadingIndicator.tsx | 4 +- .../HTMLRenderers/VideoRenderer.tsx | 4 +- .../Icon/chunks/illustrations.chunk.ts | 2 - src/components/InitialURLContextProvider.tsx | 31 +- .../SubmitPrimaryAction.tsx | 3 + .../ConfirmationListLayout.tsx | 3 + .../MoneyRequestHeaderSecondaryActions.tsx | 38 +- .../MoneyRequestReportActionsList.tsx | 77 ++- .../MoneyRequestReportEmptyStateView.tsx | 3 +- .../MoneyRequestViewReportFields.tsx | 154 ++++- .../ReportFieldInlineInput.tsx | 303 +++++++++ .../useMoneyRequestReportVisibleActions.ts | 11 +- src/components/OnboardingHeader.tsx | 12 +- .../OnboardingMergingAccountBlockedView.tsx | 12 +- src/components/Onfido/BaseOnfidoWeb.tsx | 15 +- .../ReportActionItem/MoneyReportView.tsx | 87 +-- .../MoneyRequestReceiptView.tsx | 6 +- .../SubmitActionButton.tsx | 3 + .../usePreviewMessageAnimation.ts | 2 +- .../ReportActionItem/MoneyRequestView.tsx | 8 +- src/components/ReportActionItem/TaskView.tsx | 5 +- .../FilterDropdowns/FilterPopupButton.tsx | 6 +- .../Search/SearchBulkActionsButton.tsx | 11 +- src/components/Search/SearchChartView.tsx | 57 +- .../ListItem/ExpenseReportListItem.tsx | 4 + .../SearchList/ListItem/GroupHeader.tsx | 67 +- .../ListItem/ReportListItemHeader.tsx | 3 + .../ListItem/TransactionGroupListItem.tsx | 22 +- .../ListItem/TransactionListItem/index.tsx | 3 + .../ListItem/WithdrawalIDListItemHeader.tsx | 14 +- .../Search/SearchList/ListItem/types.ts | 3 + .../SearchPageHeaderNarrow.tsx | 13 +- .../Search/SearchSelectionFooter.tsx | 10 +- src/components/Search/index.tsx | 4 +- src/components/Table/TableBody.tsx | 10 + .../Table/TableFilterBar/TableSearchBar.tsx | 12 +- .../Table/dismissKeyboardOnDrag.native.ts | 7 + src/components/Table/dismissKeyboardOnDrag.ts | 5 + .../Tables/WorkspaceTagsTable/index.tsx | 10 +- src/components/TestToolsModalPage.tsx | 11 +- .../ValidateCodeActionContent.tsx | 28 +- .../ValidateCodeActionModal/type.ts | 3 - .../VideoPlayer/BaseVideoPlayer.tsx | 2 + .../VideoPlayer/VideoPlayerControls/index.tsx | 10 +- src/components/VideoPlayer/types.ts | 6 + .../VideoPopoverMenuContext.tsx | 12 +- src/components/VideoPlayerContexts/types.ts | 3 +- src/components/VideoPlayerPreview/index.tsx | 1 + src/hooks/useClearedAddCardDraftCurrency.ts | 20 + src/hooks/useContactImport.ts | 15 + src/hooks/useDeleteTransactions.ts | 2 + src/hooks/useExpenseActions.ts | 34 +- .../useGPSTripStateChecker/index.native.ts | 21 +- src/hooks/useLifecycleActions.tsx | 3 + src/hooks/useMarkAsRead.ts | 43 +- .../index.native.tsx | 3 + .../index.tsx | 44 ++ src/hooks/useOnboardingFlow.ts | 1 - src/hooks/useOnboardingIntent.ts | 24 +- src/hooks/useReportActionsScroll.ts | 14 +- src/hooks/useReportsParentHierarchy.ts | 21 + src/hooks/useReturnToOriginReport.ts | 34 - src/hooks/useSaveReportField.ts | 79 +++ src/hooks/useSearchBulkActions.ts | 25 +- src/hooks/useSearchSelector/base.ts | 3 + src/hooks/useSearchSelector/index.native.ts | 5 +- src/hooks/useUnreadMarker.ts | 8 +- src/languages/TranslationTypes.ts | 5 - src/languages/de.ts | 91 +-- src/languages/el.ts | 87 +-- src/languages/en.ts | 70 +- src/languages/es.ts | 89 +-- src/languages/fr.ts | 83 +-- src/languages/it.ts | 83 +-- src/languages/ja.ts | 89 +-- src/languages/nl.ts | 89 +-- src/languages/pl.ts | 87 +-- src/languages/pt-BR.ts | 81 +-- src/languages/zh-hans.ts | 80 +-- src/libs/API/index.ts | 4 +- src/libs/API/parameters/AddWorkEmailParams.ts | 3 - .../ConnectPolicyToNetSuiteOAuthParams.ts | 1 + ...ateJoinWorkspaceOnboardingContentParams.ts | 6 - src/libs/API/parameters/GenerateRuleParams.ts | 10 + .../parameters/JoinAccessiblePolicyParams.ts | 3 - .../MergeIntoAccountAndLogInParams.ts | 1 - .../SignInWithShortLivedAuthTokenParams.ts | 5 - .../UpdateDualEntryFxExpenseAccountParams.ts | 8 + .../UpdatePolicyMemberDisplayNameParams.ts | 8 + .../UpdateZohoBooksOrganizationParams.ts | 6 - .../UpdateZohoBooksSubsidiaryParams.ts | 6 + ...idateUserAndGetAccessiblePoliciesParams.ts | 3 - src/libs/API/parameters/index.ts | 6 +- src/libs/API/types.ts | 16 +- .../internationalFields.json | 6 - src/libs/BankAccountFields/localFields.json | 6 - src/libs/ContactImport/index.android.tsx | 13 + src/libs/ContactPermission/index.android.ts | 13 +- .../useContactPermissions.android.ts | 5 + src/libs/ContactPicker/index.android.ts | 18 + src/libs/ContactPicker/index.ts | 10 + src/libs/ExpensifyCardStatementUtils.ts | 26 +- src/libs/ExportOnyxState/masking.ts | 3 + src/libs/MerchantTypeRulesUtils.ts | 19 +- src/libs/MergeTransactionUtils.ts | 6 +- src/libs/Middleware/SaveResponseInOnyx.ts | 2 +- src/libs/ModifiedExpenseMessage.ts | 2 +- .../Navigation/AppNavigator/AuthScreens.tsx | 20 +- .../AppNavigator/AuthScreensInitHandler.tsx | 6 +- .../AppNavigator/DelegatorConnectGate.tsx | 3 +- .../FreezeWrapper/getIsScreenBlurred/index.ts | 4 +- .../NewChatHandler.tsx | 4 +- .../ModalStackNavigators/index.tsx | 5 + .../useCustomState/index.ts | 6 +- .../getCustomSplitNavigatorState.ts | 31 + .../createSplitNavigator/index.tsx | 22 +- src/libs/Navigation/Navigation.ts | 15 +- src/libs/Navigation/NavigationRoot.tsx | 6 - .../index.tsx | 4 +- ...apDescriptorsWithNonTopScreensBehavior.tsx | 20 +- src/libs/Navigation/guards/OnboardingGuard.ts | 41 +- .../OnboardingNavigationUtils/index.native.ts | 8 +- .../OnboardingNavigationUtils/index.ts | 34 +- .../helpers/getAdaptedStateFromPath.ts | 2 +- .../helpers/isJoinWorkspaceTaskPath.ts | 25 - .../helpers/wideTabPreMountRouteKey.ts | 24 + .../RELATIONS/WORKSPACE_TO_RHP.ts | 1 + src/libs/Navigation/linkingConfig/config.ts | 4 + src/libs/Navigation/types.ts | 25 +- src/libs/PersonalDetailsUtils.ts | 10 - src/libs/PolicyUtils/connections.ts | 144 ++++ src/libs/PolicyUtils/index.ts | 304 ++------- src/libs/PolicyUtils/sageIntacct.ts | 78 +++ src/libs/PolicyUtils/xero.ts | 53 ++ src/libs/ReportActionsUtils.ts | 495 ++++++++++++-- src/libs/ReportFieldOptionsListUtils.ts | 14 +- src/libs/ReportNameUtils.ts | 72 +- src/libs/ReportUtils.ts | 193 +++--- src/libs/SearchUIUtils.ts | 33 +- src/libs/SessionUtils.ts | 57 +- src/libs/SidebarUtils.ts | 44 +- .../getFormattedPostedDate.ts | 4 +- src/libs/TransactionUtils/index.ts | 26 +- src/libs/TransactionUtils/tax.ts | 21 +- src/libs/ValidationUtils.ts | 32 + src/libs/actions/Attachment/index.native.ts | 5 +- src/libs/actions/ClearReportActionErrors.ts | 52 +- src/libs/actions/IOU/DeleteMoneyRequest.ts | 4 +- src/libs/actions/IOU/PayMoneyRequest.ts | 9 +- src/libs/actions/IOU/ReportWorkflow.ts | 7 +- src/libs/actions/IOU/SearchUpdate.ts | 11 +- src/libs/actions/IOU/SplitExpenseItems.ts | 26 +- .../actions/IOU/SplitTransactionUpdate.ts | 44 +- src/libs/actions/IOU/TrackExpense.ts | 2 +- src/libs/actions/Link.ts | 10 +- .../OnyxDerived/configs/reportAttributes.ts | 34 +- src/libs/actions/PaymentMethods.ts | 164 +++-- src/libs/actions/PersonalDetails.ts | 3 + src/libs/actions/Policy/Member.ts | 84 ++- src/libs/actions/Policy/Policy.ts | 55 +- src/libs/actions/Policy/Rules.ts | 68 ++ src/libs/actions/Report/index.ts | 87 +-- src/libs/actions/Search.ts | 10 +- src/libs/actions/Session/index.ts | 67 +- src/libs/actions/Subscription.ts | 14 +- src/libs/actions/Task.ts | 89 +-- src/libs/actions/VacationDelegate.ts | 10 +- src/libs/actions/Welcome/OnboardingFlow.ts | 54 +- src/libs/actions/Welcome/index.ts | 83 +-- src/libs/actions/connections/DualEntry.ts | 13 +- .../actions/connections/NetSuiteCommands.ts | 11 +- src/libs/actions/connections/ZohoBooks.ts | 14 +- src/libs/fileDownload/FileUtils.ts | 46 ++ src/libs/getBottomSheetHeight.ts | 44 ++ .../getMimeTypeFromHeaderBytes.ts | 70 ++ .../index.android.ts | 29 + .../getMimeTypeFromFileHeader/index.ios.ts | 40 ++ src/libs/getMimeTypeFromFileHeader/index.ts | 24 + src/libs/getOnboardingStepCounter.ts | 1 - src/libs/is2dArray.ts | 7 - src/libs/migrateOnyx.ts | 2 - .../migrations/ConvertGpsPointsTo2DArray.ts | 64 -- .../MoveFilesOutOfDocuments/index.native.ts | 10 +- src/libs/navigateAfterOnboarding.ts | 34 +- src/libs/sanitizeLogParams.ts | 1 - src/libs/snapshotPickedFile/index.native.ts | 8 +- src/libs/snapshotPickedFile/index.ts | 10 +- .../integrations/classCallCheckNoiseFilter.ts | 9 +- .../googleTranslateRecursionNoiseFilter.ts | 62 ++ src/libs/telemetry/integrations/index.ts | 4 + src/libs/telemetry/integrations/index.web.ts | 8 +- src/libs/validateAttachmentFile.ts | 36 +- src/pages/AvatarCropPage.tsx | 2 +- src/pages/DynamicEditReportFieldPage.tsx | 43 +- .../DynamicReportChangeWorkspacePage.tsx | 3 + .../ReportDetailsTitleSection.tsx | 4 +- src/pages/EditReportFieldDropdown.tsx | 51 +- .../AdditionalDetailsStep.tsx | 6 +- .../Pay/Onfido/OnfidoPrivacy.tsx | 26 +- .../EnablePayments/Pay/Onfido/OnfidoStep.tsx | 26 +- .../EnablePayments/Pay/Terms/TermsStep.tsx | 3 + .../Wallet/VerifyIdentity/VerifyIdentity.tsx | 5 +- .../shared/WalletConfirmValidateCodePage.tsx | 28 +- .../shared/useWalletPhoneValidateCode.ts | 8 +- .../Insights/charts/InsightsChartWidget.tsx | 1 + .../LogInWithShortLivedAuthTokenPage.tsx | 9 +- src/pages/LogOutPreviousUserPage.tsx | 145 +--- src/pages/NewChatPage/index.tsx | 16 +- .../BaseOnboardingPersonalDetails.tsx | 41 +- .../BaseOnboardingPrivateDomain.tsx | 215 +----- .../BaseOnboardingPurpose.tsx | 49 +- .../BaseOnboardingWorkEmail.tsx | 217 +----- .../BaseOnboardingWorkEmailValidation.tsx | 185 +---- .../BaseOnboardingWorkspaces.tsx | 304 +-------- src/pages/PreMountBufferPage.tsx | 1 - src/pages/ProfilePage.tsx | 23 +- .../EnterSignerInfo/index.tsx | 2 +- .../ReimbursementAccountPage.tsx | 2 +- src/pages/ReportReassignApproverPage.tsx | 4 +- src/pages/ReportSubmitToContent.tsx | 23 +- src/pages/Search/SearchChangeApproverPage.tsx | 2 +- src/pages/Search/SearchColumnsPage.tsx | 2 +- src/pages/Search/SearchPageNarrow/index.tsx | 76 ++- .../Search/SearchReportsMergeReports.tsx | 3 +- src/pages/ShareCodePage.tsx | 2 +- .../DynamicConfirmationPage.tsx | 2 +- src/pages/UnlinkLoginPage.tsx | 3 +- src/pages/ValidateLoginPage/index.tsx | 3 +- src/pages/ValidateLoginPage/index.web.tsx | 6 +- .../domain/DomainAccessRestrictedPage.tsx | 2 +- src/pages/domain/DomainAlreadyExistsPage.tsx | 2 +- .../domain/DomainNameOrNotFoundWrapper.tsx | 2 +- .../domain/DomainNotFoundPageWrapper.tsx | 4 +- src/pages/domain/DomainSamlPage.tsx | 2 +- .../InsightsSectionContent.tsx | 31 +- src/pages/inbox/HeaderView.tsx | 10 +- .../inbox/report/CollapsedSystemMessages.tsx | 86 +++ .../inbox/report/FloatingMessageCounter.tsx | 27 +- src/pages/inbox/report/ReportActionItem.tsx | 27 +- src/pages/inbox/report/ReportActionsList.tsx | 106 ++- src/pages/inbox/report/SearchActionHeader.tsx | 4 +- .../actionContents/ActionContentRouter.tsx | 10 +- .../actionContents/ApprovalFlowContent.tsx | 11 - .../actionContents/PolicyChangeLogContent.tsx | 5 +- .../actionContents/SimpleMessageContent.tsx | 26 - .../shouldDisplayNewMarkerOnReportAction.ts | 5 +- .../report/useFollowActionBadgeTarget.ts | 8 +- .../report/useReportActionsPresentation.ts | 45 ++ .../inbox/report/withReportOrNotFound.tsx | 2 +- src/pages/iou/DynamicSplitExpensePage.tsx | 9 +- src/pages/iou/request/IOURequestStartPage.tsx | 191 +++--- .../ImportContactButton/index.native.tsx | 7 +- .../iou/request/ImportContactButton/index.tsx | 2 + .../iou/request/ParticipantSearchResults.tsx | 6 +- .../step/IOURequestStepConfirmation.tsx | 2 +- .../NavigateGlobalCreateContext.tsx | 11 +- .../step/withFullTransactionOrNotFound.tsx | 2 +- src/pages/settings/Agents/AddAgentPage.tsx | 2 +- .../settings/DynamicVerifyAccountPage.tsx | 47 +- .../DynamicContactMethodDetailsPage.tsx | 2 +- .../Profile/CustomStatus/SetDatePage.tsx | 2 +- .../index.tsx | 5 +- .../settings/Profile/DisplayNamePage.tsx | 32 +- .../PrivatePersonalDetailsPage.tsx | 2 +- .../CardAuthenticationModal/index.native.tsx | 1 - .../CardAuthenticationModal/index.tsx | 119 ---- .../Subscription/CardSection/CardSection.tsx | 24 +- .../DynamicCardAuthenticationPage.tsx | 73 ++ .../ChangeBillingCurrency/index.tsx | 24 +- ...DynamicPaymentCardCurrencySelectorPage.tsx | 51 +- .../Subscription/PaymentCard/index.tsx | 30 +- .../Subscription/SubscriptionSettingsPage.tsx | 7 +- src/pages/settings/VerifyAccountPageBase.tsx | 6 +- .../Wallet/AddCardToDigitalWalletPage.tsx | 2 +- .../Wallet/BankAccountPurposePage/index.tsx | 18 +- .../Wallet/ChooseTransferAccountPage.tsx | 2 +- .../Wallet/CollectDepositAccount/index.tsx | 42 +- .../settings/Wallet/CountrySelectionList.tsx | 13 +- .../InternationalDepositAccountContent.tsx | 20 +- .../subPages/AccountHolderInformation.tsx | 5 +- .../subPages/AccountType.tsx | 9 +- .../subPages/BankAccountDetails.tsx | 5 +- .../subPages/BankInformation.tsx | 5 +- .../subPages/Confirmation.tsx | 7 +- .../settings/Wallet/PaymentMethodList.tsx | 2 +- .../Wallet/PersonalCards/AddNewCardPage.tsx | 2 +- .../signin/SAMLSignInPage/index.native.tsx | 7 +- .../tasks/DynamicNewTaskDescriptionPage.tsx | 2 +- src/pages/tasks/DynamicNewTaskTitlePage.tsx | 2 +- .../workspace/AccessOrNotFoundWrapper.tsx | 5 +- .../workspace/DynamicWorkspaceInvitePage.tsx | 3 +- ...WorkspaceConfirmationOwnerSelectorPage.tsx | 3 +- .../advanced/DualEntryAdvancedPage.tsx | 16 + .../DualEntryFxExpenseAccountPage.tsx | 107 +++ .../NetSuiteSetupPage/index.native.tsx | 3 +- .../NetSuiteTokenInputPage.tsx | 15 +- .../subPages/NetSuiteTokenInputForm.tsx | 11 +- .../connectToNetSuiteOAuthSetup.native.ts | 5 +- .../subPages/connectToNetSuiteOAuthSetup.ts | 4 +- .../NetSuiteCustomListSelectorPage.tsx | 1 - .../import/NetSuiteImportCustomFieldPage.tsx | 49 +- .../workspace/accounting/netsuite/types.ts | 3 + .../workspace/accounting/netsuite/utils.ts | 10 +- src/pages/workspace/accounting/utils.tsx | 1 + ...kspaceCompanyCardsSettingsFeedNamePage.tsx | 11 +- .../companyCards/assignCard/AssigneeStep.tsx | 3 + .../companyCards/assignCard/CardNameStep.tsx | 19 +- .../PolicyCommuterExclusionsPage.tsx | 48 +- .../downgrade/DynamicPayAndDowngradePage.tsx | 2 +- .../WorkspaceExpensifyCardPage.tsx | 4 +- ...DynamicWorkspaceOwnerChangeWrapperPage.tsx | 8 +- .../members/WorkspaceMemberDetailsPage.tsx | 2 +- .../WorkspaceMemberDisplayNamePage.tsx | 126 ++++ .../members/WorkspaceOwnerPaymentCardForm.tsx | 6 +- .../WorkspaceReceiptPartnersPage.tsx | 2 +- .../workspace/rules/DescribeRuleButton.tsx | 68 ++ .../workspace/rules/DescribeRuleModal.tsx | 183 +++++ .../FlagForReviewRulePageBase.tsx | 15 + .../rules/MerchantRules/AddTagPage.tsx | 5 + .../rules/MerchantRules/AddVendorPage.tsx | 2 +- .../MerchantRules/MerchantRulePageBase.tsx | 114 ++-- .../RequireFieldsRulePageBase.tsx | 37 +- .../rules/SpendRules/SpendRuleCardPage.tsx | 2 +- .../rules/SpendRules/SpendRulePageBase.tsx | 21 + .../rules/tabs/RulesExpenseDefaultsTab.tsx | 2 + .../rules/tabs/useRulesTableBulkActions.ts | 2 + .../withPolicyAndFullscreenLoading.tsx | 4 +- src/pages/workspace/withPolicyConnections.tsx | 4 +- src/selectors/Report.ts | 15 + .../index.native.ts | 4 +- src/setup/telemetry/setupSentry.ts | 11 +- src/styles/index.ts | 41 ++ src/styles/variables.ts | 1 + src/types/form/NewRulePromptForm.ts | 19 + src/types/form/OnboardingWorkEmailForm.ts | 1 - src/types/form/index.ts | 1 + src/types/onyx/GeneratedRule.ts | 28 + src/types/onyx/IntroSelected.ts | 24 +- src/types/onyx/Policy.ts | 7 +- src/types/onyx/SearchResults.ts | 6 + .../ValidateUserAndGetAccessiblePolicies.ts | 3 - src/types/onyx/index.ts | 2 + tests/actions/AttachmentTest.ts | 27 +- tests/actions/IOU/RequestMoneyTest.ts | 2 +- tests/actions/IOU/SearchUpdateTest.ts | 113 +++- tests/actions/IOU/SplitExpenseItemsTest.ts | 101 ++- tests/actions/IOU/SplitReportTotalsTest.ts | 1 + tests/actions/IOUTest/DuplicateTest.ts | 67 ++ tests/actions/IOUTest/HoldTest.ts | 98 ++- tests/actions/IOUTest/PayMoneyRequestTest.ts | 58 -- tests/actions/IOUTest/ReportWorkflowTest.ts | 161 +++-- tests/actions/IOUTest/SplitSelfDMTest.ts | 164 +++++ tests/actions/IOUTest/SplitTest.ts | 37 + tests/actions/IOUTest/TrackExpenseTest.ts | 8 +- tests/actions/PolicyMemberTest.ts | 31 + tests/actions/PolicyTest.ts | 18 +- tests/actions/ReportTest.ts | 117 +--- tests/actions/SessionTest.ts | 43 +- tests/actions/SubscriptionTest.ts | 38 +- tests/actions/TransactionTest.ts | 4 + tests/actions/WelcomeTest.ts | 46 -- tests/actions/connections/NetSuite.ts | 75 ++- tests/navigation/NavigationTests.tsx | 48 +- .../cardAuthenticationDynamicRouteTests.ts | 86 +++ ...entsConfirmValidateCodeDynamicRouteTest.ts | 34 + tests/navigation/onboardingBackgroundTests.ts | 16 - .../workspaceConfirmationEntryScreensTest.ts | 40 +- tests/perf-test/PolicyUtils.perf-test.ts | 16 +- tests/ui/ApproverSelectionListTest.tsx | 1 + tests/ui/AuthScreensInitHandlerTest.tsx | 18 +- tests/ui/ChangeBillingCurrencyPageTest.tsx | 127 ++++ tests/ui/ClearReportActionErrorsUITest.tsx | 8 +- tests/ui/CountrySelectionListTest.tsx | 1 + ...micPaymentCardCurrencySelectorPageTest.tsx | 91 ++- tests/ui/EditReportFieldDropdownTest.tsx | 131 ++++ tests/ui/FullScreenLoadingIndicatorTest.tsx | 136 ++++ tests/ui/GroupHeaderTest.tsx | 76 ++- tests/ui/LoadingIndicatorGoBackOptOutTest.tsx | 265 ++++++++ .../LogInWithShortLivedAuthTokenPageTest.tsx | 12 +- tests/ui/MoneyReportViewTest.tsx | 51 +- ...estReportActionsListSystemMessagesTest.tsx | 334 +++++++++ tests/ui/MoneyRequestViewReportFieldsTest.tsx | 612 +++++++++++++++++ tests/ui/MoneyRequestViewTest.tsx | 29 - .../ui/NetSuiteCustomListSelectorPageTest.tsx | 17 + .../ui/NetSuiteImportCustomFieldPageTest.tsx | 183 +++++ tests/ui/OnboardingPurpose.tsx | 64 +- tests/ui/PaymentMethodListTest.tsx | 107 +++ tests/ui/PersonalDetailsOnboarding.tsx | 77 +-- tests/ui/ReportActionsListTest.tsx | 175 ++++- tests/ui/SAMLSignInPageTest.tsx | 6 +- tests/ui/SessionTest.tsx | 115 +--- .../ui/SubscriptionCardSectionBannerTest.tsx | 11 + tests/ui/TableTest.tsx | 297 ++++++-- tests/ui/ValidateLoginPageTest.tsx | 36 + tests/ui/WithdrawalIDListItemHeaderTest.tsx | 238 +++++++ tests/ui/WorkEmailOnboarding.tsx | 608 +---------------- tests/ui/WorkEmailOnboardingNavigation.tsx | 51 -- ...WorkspaceInviteMessageApproverPageTest.tsx | 3 +- tests/ui/WorkspaceMembersTest.tsx | 3 +- tests/ui/WorkspaceOnboarding.tsx | 613 +---------------- tests/ui/WorkspaceTagsTest.tsx | 119 ++++ tests/ui/WorkspaceWorkflowsPayerRowTest.tsx | 3 +- tests/unit/AddWorkEmailTest.ts | 23 - tests/unit/CategoryTagPickerAutoFocusTest.tsx | 4 + tests/unit/ClearReportActionErrorsTest.ts | 215 ++++-- tests/unit/CollapsedSystemMessagesTest.tsx | 65 ++ tests/unit/DismissKeyboardOnDragTest.ts | 36 + tests/unit/ExpensifyCardStatementUtilsTest.ts | 112 +++ tests/unit/FileUtilsTest.ts | 69 ++ tests/unit/FloatingMessageCounterTest.tsx | 89 +++ tests/unit/LinkTest.ts | 82 +-- tests/unit/MerchantRuleTagSummaryTest.ts | 122 ++++ tests/unit/MergeTransactionUtilsTest.ts | 46 ++ tests/unit/MiddlewareTest.ts | 4 +- ...neyRequestReportActionsPresentationTest.ts | 87 +++ tests/unit/MoveFilesOutOfDocumentsTest.ts | 20 + .../unit/NavigateGlobalCreateProviderTest.tsx | 95 ++- .../Navigation/createMemoryHistoryTest.ts | 21 +- .../getCustomSplitNavigatorStateTest.ts | 69 ++ .../Navigation/getIsScreenBlurredWebTest.ts | 108 +++ .../Navigation/guards/OnboardingGuard.test.ts | 84 +-- ...scriptorsWithNonTopScreensBehaviorTest.tsx | 51 +- .../searchFullscreenCustomStateTest.ts | 69 ++ tests/unit/NewChatHandlerTest.tsx | 70 ++ tests/unit/OnboardingFlowTest.ts | 31 +- tests/unit/OnyxDerivedTest.tsx | 38 ++ tests/unit/PaymentMethodsTest.ts | 54 ++ tests/unit/PolicyUtilsTest.ts | 637 +++++++++++------- tests/unit/ReportActionsUtilsTest.ts | 239 +++---- .../ReportAddApproverPageTranslateTest.tsx | 17 +- tests/unit/ReportFieldOptionsListUtilsTest.ts | 73 +- tests/unit/ReportNameUtilsTest.ts | 31 +- tests/unit/ReportReassignApproverPageTest.tsx | 17 +- tests/unit/ReportUtilsTest.ts | 634 ++++++++++------- .../Search/SearchBulkActionsButtonTest.tsx | 76 ++- .../unit/Search/SearchSelectionFooterTest.tsx | 135 +++- tests/unit/Search/SearchUIUtilsTest.ts | 287 +++++++- .../Search/handleActionButtonPressTest.ts | 4 + tests/unit/SearchApproverPageTest.tsx | 21 +- tests/unit/SessionUtilsTest.ts | 49 +- tests/unit/SystemMessageDisplayStateTest.ts | 379 +++++++++++ tests/unit/TransactionGroupListItemTest.tsx | 107 +++ tests/unit/TransactionUtilsTest.ts | 132 +++- tests/unit/ValidateAttachmentFileTest.ts | 161 +++++ tests/unit/calculateSuperWideRHPWidthTest.ts | 2 +- tests/unit/classCallCheckNoiseFilterTest.ts | 26 +- .../components/CategoryPickerModalTest.tsx | 95 +++ .../dismissOnboardingModalBeforeExitTest.ts | 16 - tests/unit/getBottomSheetHeightTest.ts | 110 +++ tests/unit/getFormattedPostedDateTest.ts | 11 + tests/unit/getMimeTypeFromFileHeaderTest.ts | 173 +++++ ...googleTranslateRecursionNoiseFilterTest.ts | 136 ++++ .../hooks/useGPSTripStateChecker.test.tsx | 26 + tests/unit/hooks/useOnboardingIntent.test.ts | 28 +- tests/unit/hooks/useSearchBulkActionsTest.ts | 37 + tests/unit/isJoinWorkspaceTaskPathTest.ts | 30 - .../unit/libs/navigateAfterOnboarding.test.ts | 2 +- tests/unit/navigateAfterOnboardingTest.ts | 26 +- .../settings/DynamicVerifyAccountPageTest.tsx | 127 ---- .../netsuite/NetSuiteSetupPageTest.tsx | 17 +- .../netsuite/NetSuiteTokenInputFormTest.tsx | 16 +- .../netsuite/NetSuiteTokenInputPageTest.tsx | 58 +- .../connectToNetSuiteOAuthSetupTest.ts | 9 + .../connectToNetSuiteOAuthSetupWebTest.ts | 11 +- tests/unit/reportAttributesTest.ts | 19 +- tests/unit/resetOnboardingStackToRootTest.ts | 115 ++++ tests/unit/setupSentryIntegrationOrderTest.ts | 16 +- tests/unit/useFollowActionBadgeTargetTest.ts | 66 ++ tests/unit/useMarkAsReadTest.ts | 113 +++- ...seMoneyRequestReportVisibleActionsTest.tsx | 50 ++ tests/unit/useReportActionsScrollTest.tsx | 14 +- tests/unit/useWalletPhoneMagicCodeTest.tsx | 19 + tests/utils/ReportTestUtils.ts | 12 +- tests/utils/SentryEventTestUtils.ts | 17 + 591 files changed, 17164 insertions(+), 9415 deletions(-) delete mode 100644 assets/images/simple-illustrations/simple-illustration__briefcase-handshake.svg create mode 100644 jest/resolver.ts rename patches/@expensify/react-native-live-markdown/{@expensify+react-native-live-markdown+0.1.342+001+register-parser-worklet-in-insertion-effect.patch => @expensify+react-native-live-markdown+0.1.343+001+register-parser-worklet-in-insertion-effect.patch} (99%) rename patches/expo-modules-core/{expo-modules-core+57.0.7+001+disableViewRecycling.patch => expo-modules-core+57.0.15+001+disableViewRecycling.patch} (83%) delete mode 100644 patches/react-native-reanimated/details.md delete mode 100644 patches/react-native-reanimated/react-native-reanimated+4.5.5+001+catch-all-exceptions-on-stoi.patch create mode 100644 patches/react-native-screens/details.md create mode 100644 patches/react-native-screens/react-native-screens+4.28.0+001+fix-fragment-already-added-in-screenstack-onupdate.patch rename patches/react-native-tab-view/{react-native-tab-view+4.3.0+001+fix-tab-animation.patch => react-native-tab-view+4.3.2+001+fix-tab-animation.patch} (84%) rename patches/react-native-tab-view/{react-native-tab-view+4.3.0+002+fix-glitching-on-initial-load.patch => react-native-tab-view+4.3.2+002+fix-glitching-on-initial-load.patch} (90%) rename patches/react-native-tab-view/{react-native-tab-view+4.3.0+003+fix-web-onTabSelect-on-mount.patch => react-native-tab-view+4.3.2+003+fix-web-onTabSelect-on-mount.patch} (88%) rename patches/react-native-tab-view/{react-native-tab-view+4.3.0+004+fix-native-onTabSelect-on-mount.patch => react-native-tab-view+4.3.2+004+fix-native-onTabSelect-on-mount.patch} (97%) rename patches/react-native-tab-view/{react-native-tab-view+4.3.0+005+fix-pager-scrollleft-91610.patch => react-native-tab-view+4.3.2+005+fix-pager-scrollleft-91610.patch} (92%) rename patches/react-navigation/{@react-navigation+bottom-tabs+7.15.5+001+fix-failing-jest-by-disabling-esmodule.patch => @react-navigation+bottom-tabs+7.19.2+001+fix-failing-jest-by-disabling-esmodule.patch} (100%) rename patches/react-navigation/{@react-navigation+core+7.21.12+001+fix-failing-jest-by-disabling-esmodule.patch => @react-navigation+core+7.22.1+001+fix-failing-jest-by-disabling-esmodule.patch} (100%) rename patches/react-navigation/{@react-navigation+core+7.21.12+002+platform-navigation-stack-types.patch => @react-navigation+core+7.22.1+002+platform-navigation-stack-types.patch} (99%) create mode 100644 patches/react-navigation/@react-navigation+core+7.22.1+003+root-state-not-undefined.patch rename patches/react-navigation/{@react-navigation+elements+2.9.14+001+fix-failing-jest-by-disabling-esmodule.patch => @react-navigation+elements+2.9.43+001+fix-failing-jest-by-disabling-esmodule.patch} (100%) rename patches/react-navigation/{@react-navigation+material-top-tabs+7.4.19+001+fix-failing-jest-by-disabling-esmodule.patch => @react-navigation+material-top-tabs+7.7.2+001+fix-failing-jest-by-disabling-esmodule.patch} (100%) delete mode 100644 patches/react-navigation/@react-navigation+native+7.1.33+003+increase-history-go-popstate-fallback-timeout.patch rename patches/react-navigation/{@react-navigation+native+7.1.33+001+initial.patch => @react-navigation+native+7.4.1+001+initial.patch} (76%) rename patches/react-navigation/{@react-navigation+native+7.1.33+002+fix-failing-jest-by-disabling-esmodule.patch => @react-navigation+native+7.4.1+002+fix-failing-jest-by-disabling-esmodule.patch} (100%) rename patches/react-navigation/{@react-navigation+native-stack+7.14.5+002+fix-failing-jest-by-disabling-esmodule.patch => @react-navigation+native-stack+7.19.2+002+fix-failing-jest-by-disabling-esmodule.patch} (100%) rename patches/react-navigation/{@react-navigation+stack+7.8.5+001+edge-drag-gesture.patch => @react-navigation+stack+7.11.2+001+edge-drag-gesture.patch} (94%) rename patches/react-navigation/{@react-navigation+stack+7.8.5+002+dontDetachScreen.patch => @react-navigation+stack+7.11.2+002+dontDetachScreen.patch} (81%) rename patches/react-navigation/{@react-navigation+stack+7.8.5+004+fix-failing-jest-by-disabling-esmodule.patch => @react-navigation+stack+7.11.2+004+fix-failing-jest-by-disabling-esmodule.patch} (100%) delete mode 100644 patches/react-navigation/@react-navigation+stack+7.8.5+005+clip-inactive-card-inside-card.patch rename patches/sentry-browser/{@sentry+browser+10.73.0.patch => @sentry+browser+10.75.0.patch} (100%) rename patches/sentry-core/{@sentry+core+10.73.0+001+initial.patch => @sentry+core+10.75.0+001+initial.patch} (100%) rename patches/sentry-react-native/{@sentry+react-native+8.25.0.patch => @sentry+react-native+8.27.0.patch} (98%) delete mode 100644 patches/whatwg-fetch/details.md delete mode 100644 patches/whatwg-fetch/whatwg-fetch+3.6.2+001+use-microtask-queue-for-xhr-events.patch create mode 100644 src/components/CardAuthenticationView/index.native.tsx create mode 100644 src/components/CardAuthenticationView/index.tsx create mode 100644 src/components/CollapsibleHeaderOnKeyboard/CollapsibleHeaderOnKeyboardGroupContext.ts create mode 100644 src/components/CollapsibleHeaderOnKeyboard/CollapsibleHeaderView.tsx create mode 100644 src/components/CollapsibleHeaderOnKeyboard/Group/index.native.tsx create mode 100644 src/components/CollapsibleHeaderOnKeyboard/Group/index.tsx create mode 100644 src/components/CollapsibleHeaderOnKeyboard/GroupMember/index.native.tsx create mode 100644 src/components/CollapsibleHeaderOnKeyboard/GroupMember/index.tsx create mode 100644 src/components/CollapsibleHeaderOnKeyboard/constants.ts create mode 100644 src/components/CollapsibleHeaderOnKeyboard/useCollapsibleHeader.ts create mode 100644 src/components/CollapsibleHeaderOnKeyboard/useCollapsibleScreenState.ts create mode 100644 src/components/CollapsibleHeaderOnKeyboard/useKeyboardCollapseMetrics.ts create mode 100644 src/components/DismissibleBackdrop.tsx create mode 100644 src/components/MoneyRequestReportView/ReportFieldInlineInput.tsx create mode 100644 src/components/Table/dismissKeyboardOnDrag.native.ts create mode 100644 src/components/Table/dismissKeyboardOnDrag.ts create mode 100644 src/hooks/useClearedAddCardDraftCurrency.ts create mode 100644 src/hooks/useNavigateToCardAuthenticationOnLink/index.native.tsx create mode 100644 src/hooks/useNavigateToCardAuthenticationOnLink/index.tsx create mode 100644 src/hooks/useReportsParentHierarchy.ts delete mode 100644 src/hooks/useReturnToOriginReport.ts create mode 100644 src/hooks/useSaveReportField.ts delete mode 100644 src/libs/API/parameters/CreateJoinWorkspaceOnboardingContentParams.ts create mode 100644 src/libs/API/parameters/GenerateRuleParams.ts create mode 100644 src/libs/API/parameters/UpdateDualEntryFxExpenseAccountParams.ts create mode 100644 src/libs/API/parameters/UpdatePolicyMemberDisplayNameParams.ts delete mode 100644 src/libs/API/parameters/UpdateZohoBooksOrganizationParams.ts create mode 100644 src/libs/API/parameters/UpdateZohoBooksSubsidiaryParams.ts create mode 100644 src/libs/ContactImport/index.android.tsx create mode 100644 src/libs/ContactPermission/useContactPermissions.android.ts create mode 100644 src/libs/ContactPicker/index.android.ts create mode 100644 src/libs/ContactPicker/index.ts create mode 100644 src/libs/Navigation/AppNavigator/createSplitNavigator/getCustomSplitNavigatorState.ts delete mode 100644 src/libs/Navigation/helpers/isJoinWorkspaceTaskPath.ts create mode 100644 src/libs/Navigation/helpers/wideTabPreMountRouteKey.ts create mode 100644 src/libs/PolicyUtils/connections.ts create mode 100644 src/libs/PolicyUtils/sageIntacct.ts create mode 100644 src/libs/PolicyUtils/xero.ts create mode 100644 src/libs/getBottomSheetHeight.ts create mode 100644 src/libs/getMimeTypeFromFileHeader/getMimeTypeFromHeaderBytes.ts create mode 100644 src/libs/getMimeTypeFromFileHeader/index.android.ts create mode 100644 src/libs/getMimeTypeFromFileHeader/index.ios.ts create mode 100644 src/libs/getMimeTypeFromFileHeader/index.ts delete mode 100644 src/libs/is2dArray.ts delete mode 100644 src/libs/migrations/ConvertGpsPointsTo2DArray.ts create mode 100644 src/libs/telemetry/integrations/googleTranslateRecursionNoiseFilter.ts create mode 100644 src/pages/inbox/report/CollapsedSystemMessages.tsx create mode 100644 src/pages/inbox/report/useReportActionsPresentation.ts delete mode 100644 src/pages/settings/Subscription/CardAuthenticationModal/index.native.tsx delete mode 100644 src/pages/settings/Subscription/CardAuthenticationModal/index.tsx create mode 100644 src/pages/settings/Subscription/DynamicCardAuthenticationPage.tsx create mode 100644 src/pages/workspace/accounting/dualentry/advanced/DualEntryFxExpenseAccountPage.tsx create mode 100644 src/pages/workspace/members/WorkspaceMemberDisplayNamePage.tsx create mode 100644 src/pages/workspace/rules/DescribeRuleButton.tsx create mode 100644 src/pages/workspace/rules/DescribeRuleModal.tsx create mode 100644 src/types/form/NewRulePromptForm.ts create mode 100644 src/types/onyx/GeneratedRule.ts delete mode 100644 tests/actions/WelcomeTest.ts create mode 100644 tests/navigation/cardAuthenticationDynamicRouteTests.ts create mode 100644 tests/navigation/enablePaymentsConfirmValidateCodeDynamicRouteTest.ts delete mode 100644 tests/navigation/onboardingBackgroundTests.ts create mode 100644 tests/ui/ChangeBillingCurrencyPageTest.tsx create mode 100644 tests/ui/EditReportFieldDropdownTest.tsx create mode 100644 tests/ui/FullScreenLoadingIndicatorTest.tsx create mode 100644 tests/ui/LoadingIndicatorGoBackOptOutTest.tsx create mode 100644 tests/ui/MoneyRequestReportActionsListSystemMessagesTest.tsx create mode 100644 tests/ui/MoneyRequestViewReportFieldsTest.tsx create mode 100644 tests/ui/NetSuiteImportCustomFieldPageTest.tsx create mode 100644 tests/ui/PaymentMethodListTest.tsx create mode 100644 tests/ui/WithdrawalIDListItemHeaderTest.tsx create mode 100644 tests/unit/CollapsedSystemMessagesTest.tsx create mode 100644 tests/unit/DismissKeyboardOnDragTest.ts create mode 100644 tests/unit/MerchantRuleTagSummaryTest.ts create mode 100644 tests/unit/MoneyRequestReportActionsPresentationTest.ts create mode 100644 tests/unit/Navigation/getCustomSplitNavigatorStateTest.ts create mode 100644 tests/unit/Navigation/getIsScreenBlurredWebTest.ts create mode 100644 tests/unit/Navigation/searchFullscreenCustomStateTest.ts create mode 100644 tests/unit/NewChatHandlerTest.tsx create mode 100644 tests/unit/PaymentMethodsTest.ts create mode 100644 tests/unit/SystemMessageDisplayStateTest.ts create mode 100644 tests/unit/components/CategoryPickerModalTest.tsx delete mode 100644 tests/unit/dismissOnboardingModalBeforeExitTest.ts create mode 100644 tests/unit/getBottomSheetHeightTest.ts create mode 100644 tests/unit/getMimeTypeFromFileHeaderTest.ts create mode 100644 tests/unit/googleTranslateRecursionNoiseFilterTest.ts delete mode 100644 tests/unit/isJoinWorkspaceTaskPathTest.ts delete mode 100644 tests/unit/pages/settings/DynamicVerifyAccountPageTest.tsx create mode 100644 tests/unit/resetOnboardingStackToRootTest.ts create mode 100644 tests/unit/useFollowActionBadgeTargetTest.ts create mode 100644 tests/utils/SentryEventTestUtils.ts diff --git a/.github/actions/javascript/isDeployChecklistLocked/index.js b/.github/actions/javascript/isDeployChecklistLocked/index.js index 9321db05e6d7..cfe1ac778241 100644 --- a/.github/actions/javascript/isDeployChecklistLocked/index.js +++ b/.github/actions/javascript/isDeployChecklistLocked/index.js @@ -3432,6 +3432,7 @@ var require_CONST = __commonJS({ REPLACE_CARD: "replace_card", SHIP_CARD: "ship_card", REPORT_CARD_FRAUD: "report_card_fraud", + APPROVE_DIGITAL_WALLET: "approve_digital_wallet", ISSUE_CARD: "issue_card", UPDATE_CARD: "update_card", UPDATE_PERSONAL_DETAILS: "update_personal_details", @@ -22075,6 +22076,117 @@ var require_str = __commonJS({ } return parameter; } + function compare(strA, strB) { + if (strA < strB) { + return -1; + } + if (strA > strB) { + return 1; + } + return 0; + } + function caseInsensitiveCompare(strA, strB) { + const lowerCaseStrA = strA.toLocaleLowerCase(); + const lowerCaseStrB = strB.toLocaleLowerCase(); + return compare(lowerCaseStrA, lowerCaseStrB); + } + function contains(haystack, needle) { + return haystack.indexOf(needle) !== -1; + } + function cutAfter(str, substr) { + const index = str.indexOf(substr); + if (index !== -1) { + return str.substring(0, index); + } + return str; + } + function cutBefore(str, substr) { + const index = str.indexOf(substr); + if (index !== -1) { + return str.substring(index + substr.length); + } + return str; + } + function extractEmailDomain(email) { + return cutBefore(email, "@"); + } + function getExtension(url) { + var _a, _b; + if (typeof url !== "string") { + Log_1.default.warn("Str.getExtension: url is not a string", { url }); + return void 0; + } + return (_b = (_a = url.split(".").pop()) === null || _a === void 0 ? void 0 : _a.split("?")[0]) === null || _b === void 0 ? void 0 : _b.toLowerCase(); + } + function getRawByteSize(inputChar) { + const onlyChar = String(inputChar); + const c = onlyChar.charCodeAt(0); + if (c < 1 << 7) { + return 1; + } + if (c < 1 << 11) { + return 2; + } + if (c < 1 << 16) { + return 3; + } + if (c < 1 << 21) { + return 4; + } + if (c < 1 << 26) { + return 5; + } + if (c < 1 << 31) { + return 6; + } + return Number.NaN; + } + function htmlDecode(s) { + return HtmlEntities.decode(s); + } + function htmlEncode(s) { + return HtmlEntities.encode(s); + } + function isTypeOf(obj, type) { + return Object.prototype.toString.call(obj) === `[object ${type}]`; + } + function isNumber(obj) { + return isTypeOf(obj, "Number"); + } + function isString(obj) { + return isTypeOf(obj, "String"); + } + function isUndefined(obj) { + return obj === void 0; + } + function isValidE164Phone(phone) { + return Constants.CONST.SMS.E164_REGEX.test(phone); + } + function isValidEmail(str) { + if (!str || typeof str !== "string") { + return false; + } + const unicodeVersion = punycode_1.default.toUnicode(str); + if (String(unicodeVersion).match(Constants.CONST.REG_EXP.EMOJI_RULE)) { + return false; + } + return !!String(str).match(Constants.CONST.REG_EXP.EMAIL); + } + function maskFirstNCharacters(str, num, mask) { + if (!isString(str) || !isString(mask) || str.length === 0 || !isNumber(num)) { + return str; + } + return str.substring(0, num).replaceAll(/./g, mask) + str.substring(num); + } + function removeSMSDomain(text) { + return text.replaceAll(REMOVE_SMS_DOMAIN_PATTERN, ""); + } + function removeTrailingComma(str) { + return str.trim().replaceAll(/(,$)/g, ""); + } + function startsWith(haystack, needle) { + return isString(haystack) && isString(needle) && haystack.substring(0, needle.length) === needle; + } var Str2 = { /** * Return true if the string is ending with the provided suffix @@ -22132,18 +22244,14 @@ var require_str = __commonJS({ * @param s The string to decode. * @returns The decoded string. */ - htmlDecode(s) { - return HtmlEntities.decode(s); - }, + htmlDecode, /** * HTML encodes the given string. * * @param s The string to encode. * @return string @p s HTML encoded. */ - htmlEncode(s) { - return HtmlEntities.encode(s); - }, + htmlEncode, /** * Decodes the given HTML encoded string. * @@ -22185,7 +22293,7 @@ var require_str = __commonJS({ * @returns True when first === second, ignoring HTML encoding */ htmlEncodingInsensitiveEquals(first, second) { - return first === second || this.htmlDecode(first) === second || this.htmlEncode(first) === second; + return first === second || htmlDecode(first) === second || htmlEncode(first) === second; }, /** * Creates an ID that can be used as an HTML attribute from @p str. @@ -22268,36 +22376,14 @@ var require_str = __commonJS({ * one. * @returns Byte size of the character */ - getRawByteSize(inputChar) { - const onlyChar = String(inputChar); - const c = onlyChar.charCodeAt(0); - if (c < 1 << 7) { - return 1; - } - if (c < 1 << 11) { - return 2; - } - if (c < 1 << 16) { - return 3; - } - if (c < 1 << 21) { - return 4; - } - if (c < 1 << 26) { - return 5; - } - if (c < 1 << 31) { - return 6; - } - return Number.NaN; - }, + getRawByteSize, /** * Gets the length of a string in bytes, including non-ASCII characters * @returns The number of bytes used by string */ getByteLength(input) { const stringInput = String(input); - const byteLength = Array.from(stringInput).reduce((acc, char) => acc + this.getRawByteSize(char), 0); + const byteLength = Array.from(stringInput).reduce((acc, char) => acc + getRawByteSize(char), 0); return byteLength; }, /** @@ -22309,7 +22395,7 @@ var require_str = __commonJS({ const stringInput = String(input); let totalByteLength = 0; for (let i = 0; i < stringInput.length; i++) { - const charByteSize = this.getRawByteSize(stringInput[i]); + const charByteSize = getRawByteSize(stringInput[i]); if (charByteSize + totalByteLength > maxSize) { return `${stringInput.substr(0, i - 3)}...`; } @@ -22324,9 +22410,7 @@ var require_str = __commonJS({ * @param needle The case-sensitive string to search for * @returns True if the haystack starts with the needle. */ - startsWith(haystack, needle) { - return this.isString(haystack) && this.isString(needle) && haystack.substring(0, needle.length) === needle; - }, + startsWith, /** * Gets the textual value of the given string. * @@ -22334,7 +22418,7 @@ var require_str = __commonJS({ * @returns The text from within the HTML string. */ stripHTML(str) { - if (!this.isString(str)) { + if (!isString(str)) { return ""; } return str.replaceAll(/<[^>]*>?/gm, ""); @@ -22357,13 +22441,7 @@ var require_str = __commonJS({ * @param substr The substring to search for. * @returns The cut/trimmed string. */ - cutAfter(str, substr) { - const index = str.indexOf(substr); - if (index !== -1) { - return str.substring(0, index); - } - return str; - }, + cutAfter, /** * Returns a string containing all the characters str from after the first * occurrence of substr to the end of the string. @@ -22373,13 +22451,7 @@ var require_str = __commonJS({ * @param substr The substring to search for. * @returns The cut/trimmed string. */ - cutBefore(str, substr) { - const index = str.indexOf(substr); - if (index !== -1) { - return str.substring(index + substr.length); - } - return str; - }, + cutBefore, /** * Checks that the string is a domain name (e.g. example.com) * @@ -22406,16 +22478,7 @@ var require_str = __commonJS({ * * @returns True if the string is an email */ - isValidEmail(str) { - if (!str || typeof str !== "string") { - return false; - } - const unicodeVersion = punycode_1.default.toUnicode(str); - if (String(unicodeVersion).match(Constants.CONST.REG_EXP.EMOJI_RULE)) { - return false; - } - return !!String(str).match(Constants.CONST.REG_EXP.EMAIL); - }, + isValidEmail, /** * Checks if the string is an valid email address formed during comment markdown formation. * @@ -22433,9 +22496,7 @@ var require_str = __commonJS({ * * @returns string with the trailing comma removed */ - removeTrailingComma(str) { - return str.trim().replaceAll(/(,$)/g, ""); - }, + removeTrailingComma, /** * Checks that the string is a list of coma separated email addresss. * @@ -22444,12 +22505,12 @@ var require_str = __commonJS({ * @returns True if all emails are valid or if input is empty */ areValidEmails(str) { - const string = this.removeTrailingComma(str); + const string = removeTrailingComma(str); if (string === "") { return true; } const emails = string.split(","); - const result = emails.every((email) => this.isValidEmail(email.trim())); + const result = emails.every((email) => isValidEmail(email.trim())); return result; }, /** @@ -22466,9 +22527,7 @@ var require_str = __commonJS({ * * @returns The domain name in the email address. */ - extractEmailDomain(email) { - return this.cutBefore(email, "@"); - }, + extractEmailDomain, /** * Tries to extract the company name from the given email address * (e.g. "yelp" for "joe@yelp.co.uk"). @@ -22478,7 +22537,7 @@ var require_str = __commonJS({ * @returns The company name in the email address or null. */ extractCompanyNameFromEmailDomain(email) { - const domain = this.extractEmailDomain(email); + const domain = extractEmailDomain(email); if (!domain) { return null; } @@ -22497,7 +22556,7 @@ var require_str = __commonJS({ * @returns The local part in the email address. */ extractEmailLocalPart(email) { - return this.cutAfter(email, "@"); + return cutAfter(email, "@"); }, /** * Sanitize phone number to return only numbers. Return null if non valid phone number. @@ -22555,13 +22614,13 @@ var require_str = __commonJS({ * @returns true if the length is in the range, false otherwise */ isOfLength(str, minimumLength, maximumLength) { - if (!this.isString(str)) { + if (!isString(str)) { return false; } if (str.length < minimumLength) { return false; } - if (!this.isUndefined(maximumLength) && str.length > maximumLength) { + if (!isUndefined(maximumLength) && str.length > maximumLength) { return false; } return true; @@ -22614,9 +22673,7 @@ var require_str = __commonJS({ * * @returns Returns true if the haystack contains the needle */ - contains(haystack, needle) { - return haystack.indexOf(needle) !== -1; - }, + contains, /** * Returns true if the haystack contains the needle, ignoring case * @@ -22626,7 +22683,7 @@ var require_str = __commonJS({ * @returns Returns true if the haystack contains the needle, ignoring case */ caseInsensitiveContains(haystack, needle) { - return this.contains(haystack.toLowerCase(), needle.toLowerCase()); + return contains(haystack.toLowerCase(), needle.toLowerCase()); }, /** * Case insensitive compare function @@ -22638,11 +22695,7 @@ var require_str = __commonJS({ * 1 if first string > second string * 0 if first string = second string */ - caseInsensitiveCompare(strA, strB) { - const lowerCaseStrA = strA.toLocaleLowerCase(); - const lowerCaseStrB = strB.toLocaleLowerCase(); - return this.compare(lowerCaseStrA, lowerCaseStrB); - }, + caseInsensitiveCompare, /** * Case insensitive equals * @@ -22651,7 +22704,7 @@ var require_str = __commonJS({ * @returns true when first == second except for case */ caseInsensitiveEquals(strA, strB) { - return this.caseInsensitiveCompare(strA, strB) === 0; + return caseInsensitiveCompare(strA, strB) === 0; }, /** * Compare function @@ -22663,15 +22716,7 @@ var require_str = __commonJS({ * 1 if first string > second string * 0 if first string = second string */ - compare(strA, strB) { - if (strA < strB) { - return -1; - } - if (strA > strB) { - return 1; - } - return 0; - }, + compare, /** * Check if a file extension is supported by SmartReports */ @@ -22690,45 +22735,37 @@ var require_str = __commonJS({ const accountNumber = String(num); const len = accountNumber.length; if (len < 6 || len > 20) { - return this.maskFirstNCharacters(accountNumber, len, "X"); + return maskFirstNCharacters(accountNumber, len, "X"); } if (len < 14) { - return this.maskFirstNCharacters(accountNumber, len - 4, "X"); + return maskFirstNCharacters(accountNumber, len - 4, "X"); } const first = accountNumber.substr(0, 6); const last = accountNumber.substr(7); - const masked = this.maskFirstNCharacters(last, len - 11, "X"); + const masked = maskFirstNCharacters(last, len - 11, "X"); return `${first}${masked}`; }, /** * Checks if something is a string * Stolen from underscore */ - isString(obj) { - return this.isTypeOf(obj, "String"); - }, + isString, /** * Checks if something is a number * Stolen from underscore * @param obj */ - isNumber(obj) { - return this.isTypeOf(obj, "Number"); - }, + isNumber, /** * Checks if something is a certain type * Stolen from underscore */ - isTypeOf(obj, type) { - return Object.prototype.toString.call(obj) === `[object ${type}]`; - }, + isTypeOf, /** * Checks to see if something is undefined * Stolen from underscore */ - isUndefined(obj) { - return obj === void 0; - }, + isUndefined, /** * Replace first N characters of the string with maskChar * eg: maskFirstNCharacters( '1234567890', 6, 'X' ) yields XXXXXX7890 @@ -22737,12 +22774,7 @@ var require_str = __commonJS({ * @param mask String we want replace the first N chars with * @returns Masked string */ - maskFirstNCharacters(str, num, mask) { - if (!this.isString(str) || !this.isString(mask) || str.length === 0 || !this.isNumber(num)) { - return str; - } - return str.substring(0, num).replaceAll(/./g, mask) + str.substring(num); - }, + maskFirstNCharacters, /** * Trim a string */ @@ -22754,7 +22786,7 @@ var require_str = __commonJS({ * @param percentageString The percentage as a string */ percentageStringToNumber(percentageString) { - return Number(this.cutAfter(percentageString, "%")); + return Number(cutAfter(percentageString, "%")); }, /** * Remove all the spaces from a string @@ -22798,7 +22830,7 @@ var require_str = __commonJS({ * Converts a value to boolean, case-insensitive. */ toBool(value) { - if (this.isString(value)) { + if (isString(value)) { return value.toLowerCase() === "true"; } return !!value; @@ -22848,9 +22880,7 @@ var require_str = __commonJS({ /** * Check for whether a phone number is valid according to E.164 standard. */ - isValidE164Phone(phone) { - return Constants.CONST.SMS.E164_REGEX.test(phone); - }, + isValidE164Phone, /** * Check for whether a phone number is valid in different formats/standards. For example: * significant: 4404589784 @@ -22876,14 +22906,12 @@ var require_str = __commonJS({ /** * Returns text without our SMS domain */ - removeSMSDomain(text) { - return text.replaceAll(REMOVE_SMS_DOMAIN_PATTERN, ""); - }, + removeSMSDomain, /** * Returns true if the text is a valid E.164 phone number with our SMS domain removed */ isSMSLogin(text) { - return this.isValidE164Phone(this.removeSMSDomain(text)); + return isValidE164Phone(removeSMSDomain(text)); }, /** * This method will return all matches of a single regex like preg_match_all() in PHP. This is not a common part of @@ -22936,7 +22964,7 @@ var require_str = __commonJS({ return url; } const website = match[3] ? match[2] : `${defaultScheme}://${match[2]}`; - return website.toLowerCase() + this.cutBefore(match[1], match[2]); + return website.toLowerCase() + cutBefore(match[1], match[2]); }, /** * Checks if parameter is a string or function @@ -22948,14 +22976,7 @@ var require_str = __commonJS({ * Get file extension for a given url with or * without query parameters */ - getExtension(url) { - var _a, _b; - if (typeof url !== "string") { - Log_1.default.warn("Str.getExtension: url is not a string", { url }); - return void 0; - } - return (_b = (_a = url.split(".").pop()) === null || _a === void 0 ? void 0 : _a.split("?")[0]) === null || _b === void 0 ? void 0 : _b.toLowerCase(); - }, + getExtension, /** * Takes in a URL and checks if the file extension is PDF * @@ -22963,7 +22984,7 @@ var require_str = __commonJS({ * @returns Whether file path is PDF or not */ isPDF(url) { - return this.getExtension(url) === "pdf"; + return getExtension(url) === "pdf"; }, /** * Takes in a URL and checks if the file extension is an image @@ -22974,7 +22995,7 @@ var require_str = __commonJS({ * https://reactnative.dev/docs/image#source */ isImage(url) { - const extension = this.getExtension(url); + const extension = getExtension(url); if (!extension) { return false; } @@ -22990,7 +23011,7 @@ var require_str = __commonJS({ * https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Video_codecs */ isVideo(url) { - const extension = this.getExtension(url); + const extension = getExtension(url); if (!extension) { return false; } @@ -23003,7 +23024,7 @@ var require_str = __commonJS({ * @returns True if is a domain account email, otherwise false. */ isDomainEmail(email) { - return this.startsWith(email, "+@"); + return startsWith(email, "+@"); }, /** * Find the minimum indentation of any line in the string, @@ -24141,13 +24162,17 @@ ${g2} }, { name: "anchor", - regex: /<(a)[^><]*href\s*=\s*(['"])(.*?)\2(?:".*?"|'.*?'|[^'"><])*>([\s\S]*?)<\/\1>(?![^<]*(<\/pre>|<\/code>))/gi, - replacement: (_extras, _match, _g1, _g2, g3, g4) => { - const email = g3.startsWith("mailto:") ? g3.slice(7) : ""; - if (email === g4) { - return email; + regex: /(\]\()?<(a)[^><]*href\s*=\s*(['"])(.*?)\3(?:".*?"|'.*?'|[^'"><])*>([\s\S]*?)<\/\2>(?![^<]*(<\/pre>|<\/code>))/gi, + replacement: (_extras, _match, g1, _g2, _g3, g4, g5) => { + const incompleteLinkStart = g1 !== null && g1 !== void 0 ? g1 : ""; + const email = g4.startsWith("mailto:") ? g4.slice(7) : ""; + if (email === g5) { + return `${incompleteLinkStart}${email}`; + } + if (incompleteLinkStart && (g4 === g5 || g4 === `https://${g5}`)) { + return `${incompleteLinkStart}${g5}`; } - return `[${g4}](${email || g3})`; + return `${incompleteLinkStart}[${g5}](${email || g4})`; } }, { diff --git a/.github/actions/javascript/proposalPoliceComment/index.js b/.github/actions/javascript/proposalPoliceComment/index.js index beb367dd3ebd..58a12c5fb27a 100644 --- a/.github/actions/javascript/proposalPoliceComment/index.js +++ b/.github/actions/javascript/proposalPoliceComment/index.js @@ -24691,6 +24691,7 @@ var require_CONST = __commonJS({ REPLACE_CARD: "replace_card", SHIP_CARD: "ship_card", REPORT_CARD_FRAUD: "report_card_fraud", + APPROVE_DIGITAL_WALLET: "approve_digital_wallet", ISSUE_CARD: "issue_card", UPDATE_CARD: "update_card", UPDATE_PERSONAL_DETAILS: "update_personal_details", @@ -43334,6 +43335,117 @@ var require_str = __commonJS({ } return parameter; } + function compare(strA, strB) { + if (strA < strB) { + return -1; + } + if (strA > strB) { + return 1; + } + return 0; + } + function caseInsensitiveCompare(strA, strB) { + const lowerCaseStrA = strA.toLocaleLowerCase(); + const lowerCaseStrB = strB.toLocaleLowerCase(); + return compare(lowerCaseStrA, lowerCaseStrB); + } + function contains(haystack, needle) { + return haystack.indexOf(needle) !== -1; + } + function cutAfter(str2, substr) { + const index = str2.indexOf(substr); + if (index !== -1) { + return str2.substring(0, index); + } + return str2; + } + function cutBefore(str2, substr) { + const index = str2.indexOf(substr); + if (index !== -1) { + return str2.substring(index + substr.length); + } + return str2; + } + function extractEmailDomain(email) { + return cutBefore(email, "@"); + } + function getExtension(url) { + var _a3, _b; + if (typeof url !== "string") { + Log_1.default.warn("Str.getExtension: url is not a string", { url }); + return void 0; + } + return (_b = (_a3 = url.split(".").pop()) === null || _a3 === void 0 ? void 0 : _a3.split("?")[0]) === null || _b === void 0 ? void 0 : _b.toLowerCase(); + } + function getRawByteSize(inputChar) { + const onlyChar = String(inputChar); + const c = onlyChar.charCodeAt(0); + if (c < 1 << 7) { + return 1; + } + if (c < 1 << 11) { + return 2; + } + if (c < 1 << 16) { + return 3; + } + if (c < 1 << 21) { + return 4; + } + if (c < 1 << 26) { + return 5; + } + if (c < 1 << 31) { + return 6; + } + return Number.NaN; + } + function htmlDecode(s) { + return HtmlEntities.decode(s); + } + function htmlEncode(s) { + return HtmlEntities.encode(s); + } + function isTypeOf(obj, type) { + return Object.prototype.toString.call(obj) === `[object ${type}]`; + } + function isNumber(obj) { + return isTypeOf(obj, "Number"); + } + function isString(obj) { + return isTypeOf(obj, "String"); + } + function isUndefined(obj) { + return obj === void 0; + } + function isValidE164Phone(phone) { + return Constants.CONST.SMS.E164_REGEX.test(phone); + } + function isValidEmail(str2) { + if (!str2 || typeof str2 !== "string") { + return false; + } + const unicodeVersion = punycode_1.default.toUnicode(str2); + if (String(unicodeVersion).match(Constants.CONST.REG_EXP.EMOJI_RULE)) { + return false; + } + return !!String(str2).match(Constants.CONST.REG_EXP.EMAIL); + } + function maskFirstNCharacters(str2, num, mask) { + if (!isString(str2) || !isString(mask) || str2.length === 0 || !isNumber(num)) { + return str2; + } + return str2.substring(0, num).replaceAll(/./g, mask) + str2.substring(num); + } + function removeSMSDomain(text) { + return text.replaceAll(REMOVE_SMS_DOMAIN_PATTERN, ""); + } + function removeTrailingComma(str2) { + return str2.trim().replaceAll(/(,$)/g, ""); + } + function startsWith(haystack, needle) { + return isString(haystack) && isString(needle) && haystack.substring(0, needle.length) === needle; + } var Str6 = { /** * Return true if the string is ending with the provided suffix @@ -43391,18 +43503,14 @@ var require_str = __commonJS({ * @param s The string to decode. * @returns The decoded string. */ - htmlDecode(s) { - return HtmlEntities.decode(s); - }, + htmlDecode, /** * HTML encodes the given string. * * @param s The string to encode. * @return string @p s HTML encoded. */ - htmlEncode(s) { - return HtmlEntities.encode(s); - }, + htmlEncode, /** * Decodes the given HTML encoded string. * @@ -43444,7 +43552,7 @@ var require_str = __commonJS({ * @returns True when first === second, ignoring HTML encoding */ htmlEncodingInsensitiveEquals(first, second) { - return first === second || this.htmlDecode(first) === second || this.htmlEncode(first) === second; + return first === second || htmlDecode(first) === second || htmlEncode(first) === second; }, /** * Creates an ID that can be used as an HTML attribute from @p str. @@ -43527,36 +43635,14 @@ var require_str = __commonJS({ * one. * @returns Byte size of the character */ - getRawByteSize(inputChar) { - const onlyChar = String(inputChar); - const c = onlyChar.charCodeAt(0); - if (c < 1 << 7) { - return 1; - } - if (c < 1 << 11) { - return 2; - } - if (c < 1 << 16) { - return 3; - } - if (c < 1 << 21) { - return 4; - } - if (c < 1 << 26) { - return 5; - } - if (c < 1 << 31) { - return 6; - } - return Number.NaN; - }, + getRawByteSize, /** * Gets the length of a string in bytes, including non-ASCII characters * @returns The number of bytes used by string */ getByteLength(input) { const stringInput = String(input); - const byteLength = Array.from(stringInput).reduce((acc, char) => acc + this.getRawByteSize(char), 0); + const byteLength = Array.from(stringInput).reduce((acc, char) => acc + getRawByteSize(char), 0); return byteLength; }, /** @@ -43568,7 +43654,7 @@ var require_str = __commonJS({ const stringInput = String(input); let totalByteLength = 0; for (let i = 0; i < stringInput.length; i++) { - const charByteSize = this.getRawByteSize(stringInput[i]); + const charByteSize = getRawByteSize(stringInput[i]); if (charByteSize + totalByteLength > maxSize) { return `${stringInput.substr(0, i - 3)}...`; } @@ -43583,9 +43669,7 @@ var require_str = __commonJS({ * @param needle The case-sensitive string to search for * @returns True if the haystack starts with the needle. */ - startsWith(haystack, needle) { - return this.isString(haystack) && this.isString(needle) && haystack.substring(0, needle.length) === needle; - }, + startsWith, /** * Gets the textual value of the given string. * @@ -43593,7 +43677,7 @@ var require_str = __commonJS({ * @returns The text from within the HTML string. */ stripHTML(str2) { - if (!this.isString(str2)) { + if (!isString(str2)) { return ""; } return str2.replaceAll(/<[^>]*>?/gm, ""); @@ -43616,13 +43700,7 @@ var require_str = __commonJS({ * @param substr The substring to search for. * @returns The cut/trimmed string. */ - cutAfter(str2, substr) { - const index = str2.indexOf(substr); - if (index !== -1) { - return str2.substring(0, index); - } - return str2; - }, + cutAfter, /** * Returns a string containing all the characters str from after the first * occurrence of substr to the end of the string. @@ -43632,13 +43710,7 @@ var require_str = __commonJS({ * @param substr The substring to search for. * @returns The cut/trimmed string. */ - cutBefore(str2, substr) { - const index = str2.indexOf(substr); - if (index !== -1) { - return str2.substring(index + substr.length); - } - return str2; - }, + cutBefore, /** * Checks that the string is a domain name (e.g. example.com) * @@ -43665,16 +43737,7 @@ var require_str = __commonJS({ * * @returns True if the string is an email */ - isValidEmail(str2) { - if (!str2 || typeof str2 !== "string") { - return false; - } - const unicodeVersion = punycode_1.default.toUnicode(str2); - if (String(unicodeVersion).match(Constants.CONST.REG_EXP.EMOJI_RULE)) { - return false; - } - return !!String(str2).match(Constants.CONST.REG_EXP.EMAIL); - }, + isValidEmail, /** * Checks if the string is an valid email address formed during comment markdown formation. * @@ -43692,9 +43755,7 @@ var require_str = __commonJS({ * * @returns string with the trailing comma removed */ - removeTrailingComma(str2) { - return str2.trim().replaceAll(/(,$)/g, ""); - }, + removeTrailingComma, /** * Checks that the string is a list of coma separated email addresss. * @@ -43703,12 +43764,12 @@ var require_str = __commonJS({ * @returns True if all emails are valid or if input is empty */ areValidEmails(str2) { - const string = this.removeTrailingComma(str2); + const string = removeTrailingComma(str2); if (string === "") { return true; } const emails = string.split(","); - const result = emails.every((email) => this.isValidEmail(email.trim())); + const result = emails.every((email) => isValidEmail(email.trim())); return result; }, /** @@ -43725,9 +43786,7 @@ var require_str = __commonJS({ * * @returns The domain name in the email address. */ - extractEmailDomain(email) { - return this.cutBefore(email, "@"); - }, + extractEmailDomain, /** * Tries to extract the company name from the given email address * (e.g. "yelp" for "joe@yelp.co.uk"). @@ -43737,7 +43796,7 @@ var require_str = __commonJS({ * @returns The company name in the email address or null. */ extractCompanyNameFromEmailDomain(email) { - const domain = this.extractEmailDomain(email); + const domain = extractEmailDomain(email); if (!domain) { return null; } @@ -43756,7 +43815,7 @@ var require_str = __commonJS({ * @returns The local part in the email address. */ extractEmailLocalPart(email) { - return this.cutAfter(email, "@"); + return cutAfter(email, "@"); }, /** * Sanitize phone number to return only numbers. Return null if non valid phone number. @@ -43814,13 +43873,13 @@ var require_str = __commonJS({ * @returns true if the length is in the range, false otherwise */ isOfLength(str2, minimumLength, maximumLength) { - if (!this.isString(str2)) { + if (!isString(str2)) { return false; } if (str2.length < minimumLength) { return false; } - if (!this.isUndefined(maximumLength) && str2.length > maximumLength) { + if (!isUndefined(maximumLength) && str2.length > maximumLength) { return false; } return true; @@ -43873,9 +43932,7 @@ var require_str = __commonJS({ * * @returns Returns true if the haystack contains the needle */ - contains(haystack, needle) { - return haystack.indexOf(needle) !== -1; - }, + contains, /** * Returns true if the haystack contains the needle, ignoring case * @@ -43885,7 +43942,7 @@ var require_str = __commonJS({ * @returns Returns true if the haystack contains the needle, ignoring case */ caseInsensitiveContains(haystack, needle) { - return this.contains(haystack.toLowerCase(), needle.toLowerCase()); + return contains(haystack.toLowerCase(), needle.toLowerCase()); }, /** * Case insensitive compare function @@ -43897,11 +43954,7 @@ var require_str = __commonJS({ * 1 if first string > second string * 0 if first string = second string */ - caseInsensitiveCompare(strA, strB) { - const lowerCaseStrA = strA.toLocaleLowerCase(); - const lowerCaseStrB = strB.toLocaleLowerCase(); - return this.compare(lowerCaseStrA, lowerCaseStrB); - }, + caseInsensitiveCompare, /** * Case insensitive equals * @@ -43910,7 +43963,7 @@ var require_str = __commonJS({ * @returns true when first == second except for case */ caseInsensitiveEquals(strA, strB) { - return this.caseInsensitiveCompare(strA, strB) === 0; + return caseInsensitiveCompare(strA, strB) === 0; }, /** * Compare function @@ -43922,15 +43975,7 @@ var require_str = __commonJS({ * 1 if first string > second string * 0 if first string = second string */ - compare(strA, strB) { - if (strA < strB) { - return -1; - } - if (strA > strB) { - return 1; - } - return 0; - }, + compare, /** * Check if a file extension is supported by SmartReports */ @@ -43949,45 +43994,37 @@ var require_str = __commonJS({ const accountNumber = String(num); const len = accountNumber.length; if (len < 6 || len > 20) { - return this.maskFirstNCharacters(accountNumber, len, "X"); + return maskFirstNCharacters(accountNumber, len, "X"); } if (len < 14) { - return this.maskFirstNCharacters(accountNumber, len - 4, "X"); + return maskFirstNCharacters(accountNumber, len - 4, "X"); } const first = accountNumber.substr(0, 6); const last = accountNumber.substr(7); - const masked = this.maskFirstNCharacters(last, len - 11, "X"); + const masked = maskFirstNCharacters(last, len - 11, "X"); return `${first}${masked}`; }, /** * Checks if something is a string * Stolen from underscore */ - isString(obj) { - return this.isTypeOf(obj, "String"); - }, + isString, /** * Checks if something is a number * Stolen from underscore * @param obj */ - isNumber(obj) { - return this.isTypeOf(obj, "Number"); - }, + isNumber, /** * Checks if something is a certain type * Stolen from underscore */ - isTypeOf(obj, type) { - return Object.prototype.toString.call(obj) === `[object ${type}]`; - }, + isTypeOf, /** * Checks to see if something is undefined * Stolen from underscore */ - isUndefined(obj) { - return obj === void 0; - }, + isUndefined, /** * Replace first N characters of the string with maskChar * eg: maskFirstNCharacters( '1234567890', 6, 'X' ) yields XXXXXX7890 @@ -43996,12 +44033,7 @@ var require_str = __commonJS({ * @param mask String we want replace the first N chars with * @returns Masked string */ - maskFirstNCharacters(str2, num, mask) { - if (!this.isString(str2) || !this.isString(mask) || str2.length === 0 || !this.isNumber(num)) { - return str2; - } - return str2.substring(0, num).replaceAll(/./g, mask) + str2.substring(num); - }, + maskFirstNCharacters, /** * Trim a string */ @@ -44013,7 +44045,7 @@ var require_str = __commonJS({ * @param percentageString The percentage as a string */ percentageStringToNumber(percentageString) { - return Number(this.cutAfter(percentageString, "%")); + return Number(cutAfter(percentageString, "%")); }, /** * Remove all the spaces from a string @@ -44057,7 +44089,7 @@ var require_str = __commonJS({ * Converts a value to boolean, case-insensitive. */ toBool(value) { - if (this.isString(value)) { + if (isString(value)) { return value.toLowerCase() === "true"; } return !!value; @@ -44107,9 +44139,7 @@ var require_str = __commonJS({ /** * Check for whether a phone number is valid according to E.164 standard. */ - isValidE164Phone(phone) { - return Constants.CONST.SMS.E164_REGEX.test(phone); - }, + isValidE164Phone, /** * Check for whether a phone number is valid in different formats/standards. For example: * significant: 4404589784 @@ -44135,14 +44165,12 @@ var require_str = __commonJS({ /** * Returns text without our SMS domain */ - removeSMSDomain(text) { - return text.replaceAll(REMOVE_SMS_DOMAIN_PATTERN, ""); - }, + removeSMSDomain, /** * Returns true if the text is a valid E.164 phone number with our SMS domain removed */ isSMSLogin(text) { - return this.isValidE164Phone(this.removeSMSDomain(text)); + return isValidE164Phone(removeSMSDomain(text)); }, /** * This method will return all matches of a single regex like preg_match_all() in PHP. This is not a common part of @@ -44195,7 +44223,7 @@ var require_str = __commonJS({ return url; } const website = match2[3] ? match2[2] : `${defaultScheme}://${match2[2]}`; - return website.toLowerCase() + this.cutBefore(match2[1], match2[2]); + return website.toLowerCase() + cutBefore(match2[1], match2[2]); }, /** * Checks if parameter is a string or function @@ -44207,14 +44235,7 @@ var require_str = __commonJS({ * Get file extension for a given url with or * without query parameters */ - getExtension(url) { - var _a3, _b; - if (typeof url !== "string") { - Log_1.default.warn("Str.getExtension: url is not a string", { url }); - return void 0; - } - return (_b = (_a3 = url.split(".").pop()) === null || _a3 === void 0 ? void 0 : _a3.split("?")[0]) === null || _b === void 0 ? void 0 : _b.toLowerCase(); - }, + getExtension, /** * Takes in a URL and checks if the file extension is PDF * @@ -44222,7 +44243,7 @@ var require_str = __commonJS({ * @returns Whether file path is PDF or not */ isPDF(url) { - return this.getExtension(url) === "pdf"; + return getExtension(url) === "pdf"; }, /** * Takes in a URL and checks if the file extension is an image @@ -44233,7 +44254,7 @@ var require_str = __commonJS({ * https://reactnative.dev/docs/image#source */ isImage(url) { - const extension = this.getExtension(url); + const extension = getExtension(url); if (!extension) { return false; } @@ -44249,7 +44270,7 @@ var require_str = __commonJS({ * https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Video_codecs */ isVideo(url) { - const extension = this.getExtension(url); + const extension = getExtension(url); if (!extension) { return false; } @@ -44262,7 +44283,7 @@ var require_str = __commonJS({ * @returns True if is a domain account email, otherwise false. */ isDomainEmail(email) { - return this.startsWith(email, "+@"); + return startsWith(email, "+@"); }, /** * Find the minimum indentation of any line in the string, @@ -45400,13 +45421,17 @@ ${g2} }, { name: "anchor", - regex: /<(a)[^><]*href\s*=\s*(['"])(.*?)\2(?:".*?"|'.*?'|[^'"><])*>([\s\S]*?)<\/\1>(?![^<]*(<\/pre>|<\/code>))/gi, - replacement: (_extras, _match, _g1, _g2, g3, g4) => { - const email = g3.startsWith("mailto:") ? g3.slice(7) : ""; - if (email === g4) { - return email; + regex: /(\]\()?<(a)[^><]*href\s*=\s*(['"])(.*?)\3(?:".*?"|'.*?'|[^'"><])*>([\s\S]*?)<\/\2>(?![^<]*(<\/pre>|<\/code>))/gi, + replacement: (_extras, _match, g1, _g2, _g3, g4, g5) => { + const incompleteLinkStart = g1 !== null && g1 !== void 0 ? g1 : ""; + const email = g4.startsWith("mailto:") ? g4.slice(7) : ""; + if (email === g5) { + return `${incompleteLinkStart}${email}`; + } + if (incompleteLinkStart && (g4 === g5 || g4 === `https://${g5}`)) { + return `${incompleteLinkStart}${g5}`; } - return `[${g4}](${email || g3})`; + return `${incompleteLinkStart}[${g5}](${email || g4})`; } }, { diff --git a/Gemfile b/Gemfile index ae5ba068cb20..4460851e72f6 100644 --- a/Gemfile +++ b/Gemfile @@ -6,7 +6,7 @@ ruby ">= 3.4.1" gem "cocoapods", "= 1.16.2" gem 'activesupport', '>= 7.2.3.1' gem 'xcodeproj', '~> 1.27' -gem "fastlane", "~> 2", ">= 2.229.0" +gem "fastlane", "~> 2", ">= 2.240.1" gem "xcpretty", "~> 0" gem "openssl", ">= 3.3.1" diff --git a/Gemfile.lock b/Gemfile.lock index d773f0588c03..73235590be0f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -20,13 +20,13 @@ GEM algoliasearch (1.27.5) httpclient (~> 2.8, >= 2.8.3) json (>= 1.5.1) - apktools (0.7.5) - rubyzip (~> 2.0) + apktools (0.7.6) + rubyzip (>= 2.0, < 4.0) artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.4.0) - aws-partitions (1.1198.0) - aws-sdk-core (3.240.0) + aws-partitions (1.1292.0) + aws-sdk-core (3.257.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -34,11 +34,12 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.118.0) - aws-sdk-core (~> 3, >= 3.239.1) + rexml (~> 3.4, >= 3.4.2) + aws-sdk-kms (1.132.0) + aws-sdk-core (~> 3, >= 3.256.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.208.0) - aws-sdk-core (~> 3, >= 3.234.0) + aws-sdk-s3 (1.233.1) + aws-sdk-core (~> 3, >= 3.256.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) @@ -46,7 +47,8 @@ GEM babosa (1.0.4) base64 (0.3.0) benchmark (0.5.0) - bigdecimal (4.1.1) + bigdecimal (4.1.3) + cgi (0.5.2) claide (1.1.0) cocoapods (1.16.2) addressable (~> 2.8) @@ -91,86 +93,84 @@ GEM highline (~> 2.0.0) concurrent-ruby (1.3.7) connection_pool (3.0.2) - csv (3.3.5) + csv (3.3.6) declarative (0.0.20) digest-crc (0.7.0) rake (>= 12.0.0, < 14.0.0) - domain_name (0.6.20240107) + domain_name (0.6.20260921) dotenv (2.8.1) drb (2.2.3) emoji_regex (3.2.3) + erb (6.0.7) escape (0.0.4) ethon (0.15.0) ffi (>= 1.15.0) - excon (0.112.0) - faraday (1.10.6) - faraday-em_http (~> 1.0) - faraday-em_synchrony (~> 1.0) - faraday-excon (~> 1.1) - faraday-httpclient (~> 1.0) - faraday-multipart (~> 1.0) - faraday-net_http (~> 1.0) - faraday-net_http_persistent (~> 1.0) - faraday-patron (~> 1.0) - faraday-rack (~> 1.0) - faraday-retry (~> 1.0) - ruby2_keywords (>= 0.0.4) + excon (1.7.2) + logger + faraday (2.14.4) + faraday-net_http (>= 2.0, < 3.5) + json + logger faraday-cookie_jar (0.0.8) faraday (>= 0.8.0) http-cookie (>= 1.0.0) - faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.1) - faraday-excon (1.1.0) - faraday-httpclient (1.0.1) - faraday-multipart (1.1.1) + faraday-follow_redirects (0.5.0) + faraday (>= 1, < 3) + faraday-multipart (1.2.0) multipart-post (~> 2.0) - faraday-net_http (1.0.2) - faraday-net_http_persistent (1.2.0) - faraday-patron (1.0.0) - faraday-rack (1.0.0) - faraday-retry (1.0.3) - faraday_middleware (1.2.1) - faraday (~> 1.0) - fastimage (2.4.0) - fastlane (2.229.0) - CFPropertyList (>= 2.3, < 4.0.0) - abbrev (~> 0.1.2) - addressable (>= 2.8, < 3.0.0) + faraday-net_http (3.4.4) + net-http (~> 0.5) + faraday-retry (2.4.0) + faraday (~> 2.0) + fastimage (2.4.1) + fastlane (2.240.1) + CFPropertyList (>= 2.3, < 5.0.0) + abbrev (~> 0.1) + addressable (>= 2.9.0, < 3.0.0) artifactory (~> 3.0) - aws-sdk-s3 (~> 1.0) + aws-sdk-s3 (~> 1.197) babosa (>= 1.0.3, < 2.0.0) - bundler (>= 1.12.0, < 3.0.0) + base64 (~> 0.2) + benchmark (>= 0.1.0) + bundler (>= 2.4.0, < 5.0.0) + cgi (~> 0.4) colored (~> 1.2) commander (~> 4.6) csv (~> 3.3) dotenv (>= 2.1.1, < 3.0.0) emoji_regex (>= 0.1, < 4.0) - excon (>= 0.71.0, < 1.0.0) - faraday (~> 1.0) - faraday-cookie_jar (~> 0.0.6) - faraday_middleware (~> 1.0) + excon (>= 0.71.0, < 2.0.0) + faraday (~> 2.7) + faraday-cookie_jar (~> 0.0.8) + faraday-follow_redirects (~> 0.3) + faraday-multipart (~> 1.0) + faraday-retry (~> 2.0) fastimage (>= 2.1.0, < 3.0.0) - fastlane-sirp (>= 1.0.0) + fastlane-sirp (>= 1.1.0) gh_inspector (>= 1.1.2, < 2.0.0) - google-apis-androidpublisher_v3 (~> 0.3) + google-apis-androidpublisher_v3 (~> 0.99) google-apis-playcustomapp_v1 (~> 0.1) - google-cloud-env (>= 1.6.0, < 2.0.0) + google-cloud-env (>= 1.6.0, < 2.4.0) google-cloud-storage (~> 1.31) highline (~> 2.0) - http-cookie (~> 1.0.5) + irb (>= 1.8) json (< 3.0.0) - jwt (>= 2.1.0, < 3) + jwt (>= 2.10.3, < 4) + logger (>= 1.6, < 2.0) mini_magick (>= 4.9.4, < 5.0.0) + multi_json (~> 1.12) multipart-post (>= 2.0.0, < 3.0.0) - mutex_m (~> 0.3.0) + mutex_m (~> 0.3) naturally (~> 2.2) + nkf (~> 0.2) optparse (>= 0.1.1, < 1.0.0) + ostruct (>= 0.1.0) plist (>= 3.1.0, < 4.0.0) - rubyzip (>= 2.0.0, < 3.0.0) - security (= 0.1.5) + rubyzip (>= 3.4.0, < 4.0.0) + security (~> 0.3) simctl (~> 1.6.3) terminal-notifier (>= 2.0.0, < 3.0.0) - terminal-table (~> 3) + terminal-table (~> 4) tty-screen (>= 0.6.3, < 1.0.0) tty-spinner (>= 0.8.0, < 1.0.0) word_wrap (~> 1.0.0) @@ -184,8 +184,7 @@ GEM fastlane-plugin-firebase_app_distribution (0.10.1) google-apis-firebaseappdistribution_v1 (~> 0.3.0) google-apis-firebaseappdistribution_v1alpha (~> 0.2.0) - fastlane-sirp (1.0.0) - sysrandom (~> 1.0) + fastlane-sirp (1.1.0) ffi (1.17.2) ffi (1.17.2-arm64-darwin) ffi (1.17.2-x86_64-darwin) @@ -193,56 +192,68 @@ GEM fourflusher (2.3.1) fuzzy_match (2.0.4) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.54.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-core (0.11.3) - addressable (~> 2.5, >= 2.5.1) - googleauth (>= 0.16.2, < 2.a) - httpclient (>= 2.8.1, < 3.a) - mini_mime (~> 1.0) + google-apis-androidpublisher_v3 (0.109.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-core (1.2.5) + addressable (~> 2.9) + faraday (~> 2.13) + faraday-follow_redirects (~> 0.3) + googleauth (~> 1.14) + mini_mime (~> 1.1) + multi_json (~> 1.11) representable (~> 3.0) - retriable (>= 2.0, < 4.a) - rexml + retriable (>= 3.1, < 5.0) google-apis-firebaseappdistribution_v1 (0.3.0) google-apis-core (>= 0.11.0, < 2.a) google-apis-firebaseappdistribution_v1alpha (0.2.0) google-apis-core (>= 0.11.0, < 2.a) - google-apis-iamcredentials_v1 (0.17.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-playcustomapp_v1 (0.13.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-storage_v1 (0.31.0) - google-apis-core (>= 0.11.0, < 2.a) - google-cloud-core (1.8.0) + google-apis-iamcredentials_v1 (0.28.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-playcustomapp_v1 (0.18.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-storage_v1 (0.68.0) + google-apis-core (>= 0.15.0, < 2.a) + google-cloud-core (1.9.0) google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) - google-cloud-env (1.6.0) - faraday (>= 0.17.3, < 3.0) - google-cloud-errors (1.5.0) - google-cloud-storage (1.47.0) + google-cloud-env (2.3.1) + base64 (~> 0.2) + faraday (>= 1.0, < 3.a) + google-cloud-errors (1.7.0) + google-cloud-storage (1.62.1) addressable (~> 2.8) digest-crc (~> 0.4) - google-apis-iamcredentials_v1 (~> 0.1) - google-apis-storage_v1 (~> 0.31.0) + google-apis-core (>= 0.18, < 2) + google-apis-iamcredentials_v1 (~> 0.18) + google-apis-storage_v1 (>= 0.42) google-cloud-core (~> 1.6) - googleauth (>= 0.16.2, < 2.a) + googleauth (~> 1.9) mini_mime (~> 1.0) - googleauth (1.8.1) - faraday (>= 0.17.3, < 3.a) - jwt (>= 1.4, < 3.0) - multi_json (~> 1.11) + google-logging-utils (0.2.0) + googleauth (1.17.4) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.2) + google-logging-utils (~> 0.1) + jwt (>= 1.4, < 4.0) os (>= 0.9, < 2.0) + pstore (~> 0.1) signet (>= 0.16, < 2.a) highline (2.0.3) - http-cookie (1.0.8) + http-cookie (1.1.6) domain_name (~> 0.5) httpclient (2.9.0) mutex_m i18n (1.14.8) concurrent-ruby (~> 1.0) + io-console (0.9.4) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) jmespath (1.6.2) - json (2.19.9) - jwt (2.10.3) + json (2.21.2) + jwt (3.3.0) base64 logger (1.7.0) mime-types (3.7.0) @@ -253,45 +264,62 @@ GEM mini_mime (1.1.5) minitest (5.27.0) molinillo (0.8.0) - multi_json (1.18.0) + multi_json (1.21.2) multipart-post (2.4.1) mutex_m (0.3.0) nanaimo (0.4.0) nap (1.1.0) naturally (2.3.0) + net-http (0.9.1) + uri (>= 0.11.1) netrc (0.11.0) nkf (0.2.0) openssl (3.3.1) optparse (0.8.1) os (1.1.4) + ostruct (0.6.3) plist (3.7.2) + pp (0.6.4) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + pstore (0.2.1) public_suffix (4.0.7) - rake (13.3.1) - representable (3.2.0) + rake (13.4.2) + rbs (4.2.0) + logger + prism (>= 1.6.0) + tsort + rdoc (8.1.0) + erb + prism (>= 1.6.0) + rbs (>= 4.0.0) + tsort + reline (0.7.0) + io-console (~> 0.5) + representable (3.3.1) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) - retriable (3.1.2) + retriable (4.2.0) rexml (3.4.4) rouge (3.28.0) ruby-macho (2.5.1) - ruby2_keywords (0.0.5) - rubyzip (2.4.1) + rubyzip (3.7.0) securerandom (0.4.1) - security (0.1.5) - signet (0.21.0) + security (0.3.0) + signet (0.22.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) jwt (>= 1.5, < 4.0) - multi_json (~> 1.10) simctl (1.6.10) CFPropertyList naturally - sysrandom (1.0.5) terminal-notifier (2.0.0) - terminal-table (3.0.2) - unicode-display_width (>= 1.1.1, < 3) + terminal-table (4.0.0) + unicode-display_width (>= 1.1.1, < 4) trailblazer-option (0.1.2) + tsort (0.2.0) tty-cursor (0.7.1) tty-screen (0.8.2) tty-spinner (0.9.3) @@ -301,14 +329,19 @@ GEM tzinfo (2.0.6) concurrent-ruby (~> 1.0) uber (0.1.0) - unicode-display_width (2.6.0) + unicode-display_width (3.3.0) + unicode-emoji (~> 4.3) + unicode-emoji (4.3.0) + uri (1.1.1) word_wrap (1.0.0) - xcodeproj (1.27.0) + xcodeproj (1.28.1) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) + base64 claide (>= 1.0.2, < 2.0) colored2 (~> 3.1) nanaimo (~> 0.4.0) + nkf rexml (>= 3.3.6, < 4.0) xcpretty (0.4.1) rouge (~> 3.28.0) @@ -327,7 +360,7 @@ DEPENDENCIES benchmark bigdecimal cocoapods (= 1.16.2) - fastlane (~> 2, >= 2.229.0) + fastlane (~> 2, >= 2.240.1) fastlane-plugin-aws_s3 fastlane-plugin-firebase_app_distribution logger diff --git a/Mobile-Expensify b/Mobile-Expensify index 28f074cff201..c15d05c78653 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 28f074cff20109660c332c6633a9aa8840094bc4 +Subproject commit c15d05c78653f81d3f0844988bfdbf104aee5034 diff --git a/android/app/build.gradle b/android/app/build.gradle index 0adc5e20bc5a..feb6d34244c4 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -114,8 +114,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009050605 - versionName "9.5.6-5" + versionCode 1009050703 + versionName "9.5.7-3" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1097f2dedffc..c0d4ff50a0c2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,7 +5,9 @@ - + + diff --git a/android/build.gradle b/android/build.gradle index 7ab81991995f..f5cbdba9210d 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -3,7 +3,7 @@ buildscript { ext { buildToolsVersion = "36.0.0" minSdkVersion = 24 - compileSdkVersion = 36 + compileSdkVersion = 37 targetSdkVersion = 36 ndkVersion = "27.1.12297006" diff --git a/assets/images/simple-illustrations/simple-illustration__briefcase-handshake.svg b/assets/images/simple-illustrations/simple-illustration__briefcase-handshake.svg deleted file mode 100644 index 0baff58595de..000000000000 --- a/assets/images/simple-illustrations/simple-illustration__briefcase-handshake.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 5fec781e7e52..1972f322da7f 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -301,7 +301,7 @@ "../../src/components/ReimbursementAccountLoadingIndicator.tsx" "no-restricted-imports" 1 "../../src/components/ReportActionAvatars/useReportPreviewSenderID.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/ReportActionItem/IssueCardMessage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/components/ReportActionItem/MoneyReportView.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/components/ReportActionItem/MoneyReportView.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/ReportActionItem/MoneyRequestReceiptView.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/ReportActionItem/MoneyRequestReceiptView.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/ReportActionItem/receiptHoverUtils/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 @@ -835,6 +835,8 @@ "../../src/libs/Navigation/linkingConfig/RELATIONS/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/libs/Navigation/Navigation.ts" "@typescript-eslint/no-unsafe-type-assertion" 10 "../../src/libs/Navigation/OnyxTabNavigator.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx" "@typescript-eslint/no-deprecated/NavigationContent" 1 +"../../src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx" "@typescript-eslint/no-deprecated/NavigationContent" 1 "../../src/libs/Navigation/PlatformStackNavigation/navigationOptions/animation/withAnimation.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 "../../src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/NetSuiteUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -858,7 +860,8 @@ "../../src/libs/PaymentUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/PersonalDetailsUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/PhoneNumber.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/libs/PolicyUtils/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 +"../../src/libs/PolicyUtils/connections.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 +"../../src/libs/PolicyUtils/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/prepareRequestPayload/index.native.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 "../../src/libs/prepareRequestPayload/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Pusher/index.native.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 @@ -958,7 +961,6 @@ "../../src/pages/domain/Members/DomainMembersPage.tsx" "no-restricted-imports" 1 "../../src/pages/domain/Members/MoveUserBetweenGroupsPage.tsx" "no-restricted-imports" 1 "../../src/pages/domain/Members/MoveUsersBetweenGroupsPage.tsx" "no-restricted-imports" 1 -"../../src/pages/DynamicEditReportFieldPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/DynamicEditReportFieldPage.tsx" "no-restricted-imports" 1 "../../src/pages/DynamicFlagCommentPage.tsx" "no-restricted-imports" 1 "../../src/pages/DynamicNewReportWorkspaceSelectionPage.tsx" "no-restricted-imports" 1 @@ -1261,8 +1263,6 @@ "../../src/pages/settings/Security/SecuritySettingsPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthWrapper.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Subscription/CancelSubscriptionPage/index.tsx" "no-restricted-imports" 1 -"../../src/pages/settings/Subscription/CardAuthenticationModal/index.tsx" "no-restricted-imports" 1 -"../../src/pages/settings/Subscription/CardAuthenticationModal/index.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/settings/Subscription/DisableAutoRenewSurveyPage/index.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Subscription/ExpensifyCodePage/index.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Subscription/PaymentCard/ChangeBillingCurrency/index.tsx" "no-restricted-imports" 1 @@ -1858,7 +1858,6 @@ "../../tests/unit/SearchAutocompleteListTest.tsx" "@typescript-eslint/no-restricted-imports" 1 "../../tests/unit/SidebarUtilsTest.ts" "no-restricted-imports" 1 "../../tests/unit/TransactionTest.ts" "@typescript-eslint/no-restricted-imports" 1 -"../../tests/unit/useMarkAsReadTest.ts" "no-restricted-imports" 1 "../../tests/unit/WorkspacesSettingsUtilsTest.ts" "@typescript-eslint/no-restricted-imports" 1 "../../tests/utils/createMock.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../rsbuild/CustomVersionFilePlugin.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 diff --git a/cspell.json b/cspell.json index ab05306535c1..2b91aeb1b9e5 100644 --- a/cspell.json +++ b/cspell.json @@ -677,6 +677,7 @@ "expensifynewkansas", "expensifyreactnative", "exportedto", + "extensionless", "fabs", "falso", "favicons", @@ -949,6 +950,7 @@ "QBWC", "qrcode", "quasis", + "quicktime", "quotepath", "rach", "reactnative", diff --git a/docs/HELPSITE_NAMING_CONVENTIONS.md b/docs/HELPSITE_NAMING_CONVENTIONS.md index c7dc9e8a91ae..54d6fb68a546 100644 --- a/docs/HELPSITE_NAMING_CONVENTIONS.md +++ b/docs/HELPSITE_NAMING_CONVENTIONS.md @@ -2,15 +2,17 @@ ## Purpose -This document defines mandatory rules for referencing UI elements, buttons, tabs, icons, menus, and navigation patterns in Expensify HelpDot articles. +This document defines mandatory rules for referencing UI elements, buttons, tabs, icons, menus, colors, and navigation patterns in Expensify HelpDot articles. + +**Source of truth:** `src/languages/en.ts` and the production UI. All HelpDot articles must comply. -This document governs UI language conventions. It does not define article structure or Markdown formatting rules. Those are defined in: +This document governs UI language and capitalization conventions. It does not define article structure or Markdown formatting rules. Those are defined in: docs/HELP_AUTHORING_GUIDELINES.md -Note: All article headings (# and ##) must follow the task-based heading rules in HELP_AUTHORING_GUIDELINES.md Section 2, except for `# FAQ` which is exempt. This includes section headings that reference UI features — they must still be task-based, not just feature labels. +Note: All article headings (# and ##) must follow the task-based heading rules in HELP_AUTHORING_GUIDELINES.md Section 2, except for `# FAQ` which is exempt. This includes section headings that reference UI features — they must still be task-based, not just feature labels. Heading capitalization is defined in [Article Titles and Headings](#article-titles-and-headings) below. --- @@ -27,153 +29,328 @@ When referencing UI elements: If the UI changes, this document must be updated. ---- +## Actions + +### Select or choose + +Use the verb that matches the action, not the device. + +| Verb | Use when | Example | +| --- | --- | --- | +| **select** | Default for interacting with any UI element. Works across web, mobile, touch, mouse, and keyboard. | Select **Save**. | +| **choose** | The member is making a decision between multiple options, not activating a UI element. | Choose **Vendor bill** or **Journal entry**. | + +> **Examples:** +> +> Correct: +> +> - Select **Workspaces**. +> - Select **Next**. +> - Choose a default currency. +> +> Incorrect: +> +> - Click **Save**. +> - Tap **Next**. +> - Choose **Save**. (use **select** — this activates a button, not a decision) +> - Hit, press, or push a button. -# Button Naming Standards +## Button Naming Standards -## Formatting Requirements +### Bolded and matches the UI exactly - Bold all button names using **bold formatting**. -- Use Sentence case (match the UI exactly). +- Use sentence case (match the UI exactly). - Do not wrap button names in quotation marks. - Do not alter capitalization. - Do not substitute synonyms. -Correct: -- Click **Save**. -- Select **Confirm**. -- Tap **Next**. - -Incorrect: -- Click Save. -- Click “Save”. -- Click **SAVE**. -- Select confirm. -- Click **Continue** (when the button is **Next**). +> **Examples:** +> +> Correct: +> +> - Select **Save**. +> - Select **Confirm**. +> - Select **Next**. +> +> Incorrect: +> +> - Select Save. +> - Select “Save”. +> - Select **SAVE**. +> - Confirm your selection --- -# Tab Naming Standards +## Navigation Tabs + +### The LHN is referred to as "the navigation tabs" + +The primary navigation (**Home**, **Inbox**, **Spend**, **Workspaces**, **Account**) is called the **navigation tabs**. -- Bold all tab names using **bold formatting**. -- Use the exact tab name shown in the UI. +- Use "in the navigation tabs" only when you need to reference the primary navigation directly. +- Bold each tab name and use the exact name shown in the UI. - Do not wrap tab names in quotation marks. - Do not paraphrase tab names. +- Only use these position descriptions. Do not use any other position wording. + - **(on the left on web, on the bottom on mobile)**: Use when members may need help finding the tabs on both platforms. + - **In the navigation tabs on the left**: Use in web-only instructions. + - **In the navigation tabs on the bottom**: Use in mobile-only instructions. + +> **Examples:** +> +> Correct: +> +> - In the navigation tabs (on the left on web, on the bottom on mobile), select **Workspaces**. +> - [if web-only] In the navigation tabs on the left, select **Workspaces**. +> - [if mobile-only] In the navigation tabs on the bottom, select **Workspaces**. +> +> Incorrect: +> +> - On the navigation tabs +> - Open Workspace (when the tab is **Workspaces**). +> - Tap **Spend** from the navigation tabs on the bottom +> - Select **Spend** in the left sidebar. -Correct: -- Select **Chat**. -- Open **Workspaces**. -- Go to **Settings**. +--- -Incorrect: -- Select “Chat”. -- Go to Chat. -- Open Workspace (when the tab is **Workspaces**). +## Navigation Paths + +### Workspaces > [workspace name] > Members + +Use a navigation path instead of naming the navigation menu. + +- Start the path with the navigation tab. +- Separate each level with `>`. +- Bold the entire path. +- Capitalize each segment exactly as it appears in the UI. +- Include a lowercase bracketed placeholder for variable names: **[workspace name]**, **[domain name]**. + +> **Examples:** +> +> Correct: +> +> - Select **Workspaces > [workspace name] > Members**. +> - Select **Spend > Expenses**. +> - Select **Account > Settings**. +> - Navigate to **Spend > Reports**. +> +> Incorrect: +> +> - Select **Workspaces > Members**. (include the placeholder) +> - Select **Workspaces > [Workspace Name] > Members**. (lowercase the placeholder) +> - In the workspace menu, select **Members**. +> - In the Spend sidebar, select **Expenses**. +> - Open the settings menu and select **Preferences**. +> - Select **Workspaces**, then **[workspace name]**, then **Members**. (use a path) + +## Navigation Menu + +### The menu nested under the LHN is the "navigation menu" + +The **navigation menu** is the secondary menu inside a navigation tab, such as the list of **Members**, **Workflows**, and **Categories** inside a workspace. Use this name on all platforms. + +- Prefer a navigation path. Only reference the navigation menu directly if continuing previous steps where a navigation path was already provided. +- Do not call it the sidebar, workspace, account menu, etc. + +> **Examples:** +> +> Correct: +> +> - In the navigation menu, select **Rules**. +> - In the navigation menu, select **Subscription**. +> +> Incorrect: +> +> - Inside your workspace, select **Rules**. +> - Go to Subscription under **Account**. +> - In the workspace sidebar, select **Workflows**. --- -# Three Dots Menu Rule - -When referencing the three dots menu, always write: +## Three Dots Menu -Select the three dots **(⋮)**. +### Select **More (⋮)** -Formatting requirements: +When referencing the three dots menu, always write: Select **More (⋮)** - Always include the visual symbol inside parentheses: **(⋮)** - Always bold the symbol. -- Always include the words “three dots” before the symbol. +- Always include the word “**More**” before the symbol. - Do not wrap in quotation marks. -Correct: -- Select the three dots **(⋮)**. -- Click the three dots **(⋮)** next to the expense. - -Incorrect: -- Tap the three dots -- Open the three dots menu -- Click ⋮ +> **Examples:** +> +> Correct: +> +> - Select **More (⋮)**. +> - Select **More (⋮)** next to the workspace. +> +> Incorrect: +> +> - Tap the three dots (⋮) +> - Open the three dots menu +> - Click ⋮ --- -# Navigation Instruction Standards - -Follow the platform decision hierarchy below. +## Platform Instructions + +### If web and mobile navigation are the same, combine instructions + +Write one set of instructions. Do not duplicate steps. + +> **Example:** +> +> - In the navigation tabs (on the left on web, on the bottom on mobile), select **Workspaces > [workspace name] > Members**. + +### If web and mobile navigation differ, provide separate labeled instructions + +> **Example:** +> +> On web: +> +> 1. In the navigation tabs on the left, select **Spend > Insights > Top spenders**. +> +> On mobile: +> +> 1. In the navigation tabs on the bottom, select **Spend**. +> 2. Select the navigation menu to open it. +> 3. Select **Insights**. + +**If a feature isn't available on a platform**, name the platform where it is available, not the device or channel. Use **web** and **mobile**, not “Web,” “website,” “desktop,” or "app" on their own. + +> **Examples:** +> +> Correct: +> +> - This feature is only available on web. It isn't available on mobile. +> - This feature is only available on mobile. It isn’t available on web. +> +> Incorrect: +> +> - This feature is only available on the web. +> - Use the website to do this. +> - This isn't supported on desktop. +> - Manage this from web. --- -## If Web and Mobile Navigation Differ +## Toggle References -Provide separate instructions. +### Use the exact UI label and say "enable" or "disable" when adjusting -Web: -In the navigation tabs on the left. - -Mobile: -In the navigation tabs on the bottom. - -Do not merge these into one sentence. +- Refer to a toggle using its exact label shown in the UI. +- Bold toggle names if they appear as clickable UI elements. +- Do not reword toggle labels. +- Toggle actions are limited to "enable" or "disable." + +> **Examples:** +> +> Correct: +> +> - Enable **Per diem**. +> - Disable **Company cards**. +> +> Incorrect: +> +> - Turn on **Per Diem** +> - Disable the Company Cards toggle --- -## If Web and Mobile Navigation Are Structurally the Same - -Use a unified sentence: - -In the navigation tabs (on the left on web, on the bottom on mobile). +## Section References -Do not duplicate instructions unnecessarily. +- Use the exact section header text shown in the UI. +- Do not generalize section names. +- Do not paraphrase. --- -## Hamburger Menu Rule (Mobile Only) +## Name UI Elements by Function -If access requires the hamburger menu, always write: +### Name UI elements by what they do, not what they look like or where they happen to be -On mobile, tap the hamburger menu in the top-left corner, then select **[Tab name]**. +| Write | Not | +| --- | --- | +| Search icon | magnifying glass | +| Delete icon | trash can | +| selection bar | the bar at the bottom, the bulk selection bar | +| navigation menu | hamburger menu, three lines, sidebar | -Rules: +--- -- Do not paraphrase this sentence. -- Always specify “top-left corner”. -- Always bold the tab name. -- Do not use quotation marks. +## Fields + +### Use **field** for any text-entry control, including search boxes, text boxes, and inputs + +- Bold the field's label and follow it with "field." +- Use the exact label shown in the UI. +- For the search control, write "search field" (lowercase, not bold) unless it has a visible label. + +> **Examples:** +> +> Correct: +> +> - Enter a comment in the **Description** field. +> - Enter the merchant name in the **Merchant** field. +> - Type the member's name in the search field. +> +> Incorrect: +> +> - Type in the Description box. +> - Fill in the Merchant input. +> - Use the search bar. +> - Enter a value in the text box. --- -## If a Feature Is Not Available on Mobile +## Bulk Actions Menu -State clearly: +### The bulk actions menu is called the "selection bar" -This feature is not available on mobile. +- Call it the selection bar (lowercase). +- Do not bold the menu name. +- Do not describe its position or color. -Do not soften or imply this limitation. +> **Examples:** +> +> Correct: +> +> - Select the reports, then select **Export** from the selection bar. +> - Select the expenses, then select **Delete** from the selection bar. +> +> Incorrect: +> +> - Select the reports, then select **Export** from the bar at the bottom. +> - Use the bulk actions menu to export. +> - Select the checkbox, then select the green button. --- -# Section and Toggle References - -## Sections +## Color References -- Use the exact section header text shown in the UI. -- Do not generalize section names. -- Do not paraphrase. - ---- - -## Toggles +### Only reference color when required as a secondary descriptor -- Use the exact toggle label shown in the UI. -- Bold toggle names if they appear as clickable UI elements. -- Do not reword toggle labels. +Don't use color as the only way to identify a UI element. When color communicates meaningful status, include it as a secondary descriptor alongside the element name. -Example: -- Enable **Reimbursements**. -- Turn on **Company Cards**. +> **Examples:** +> +> Correct: +> +> - Select the company card with the red dot. +> - A red dot means there’s a connection error. +> - When a request is pending, a green dot appears next to **Domain admins**. +> +> Incorrect: +> +> - Click the green receipt button +> - Group participants are listed with a green checkmark. +> - Look for the green button at the top. --- -# Cross-Platform Clarity Rule +## Cross-Platform Clarity Rule When platform differences impact behavior: @@ -187,11 +364,11 @@ If UI text differs between platforms: If layout differs but labels are identical: -- Follow the Navigation Instruction Standards. +- Follow the [Platform Instructions](#platform-instructions) rules. --- -# Prohibited Language +## Prohibited Language Do not use vague navigation phrases: @@ -202,7 +379,154 @@ Do not use vague navigation phrases: - “Find the setting” - “The options button” -Always reference the exact UI label or icon. +Always reference the exact UI label, icon, or navigation path. + +--- + +# Writing and Capitalization + +## Core Capitalization Rules + +- Use title case for article titles. +- Use sentence case everywhere else. +- When referring to UI elements, match the UI casing exactly. +- Use all caps for acronyms. +- When quoting the UI, match its capitalization exactly. +- Capitalize branded names as defined in [src/languages/en.ts](https://github.com/Expensify/App/blob/main/src/languages/en.ts). +- Capitalize proper nouns. + +> **Examples:** +> +> Correct: +> +> - BYOC +> - Select **Add approval workflow** +> +> Incorrect: +> +> - Byoc +> - Select **Add Approval Workflow** + +--- + +## Article Titles and Headings + +- Use title case for the article title: the metadata `title` and the `#` title. +- Use sentence case for every `##` heading. +- Capitalize proper nouns and product names inside headings. +- `# FAQ` is always written in all caps as an acronym. + +> **Examples:** +> +> Correct: +> +> - `# Configure NetSuite Settings in Expensify` +> - `## Who can configure NetSuite settings` +> +> Incorrect: +> +> - `# Configure NetSuite settings in Expensify` +> - `## Who Can Configure NetSuite Settings` + +--- + +## Roles + +### Capitalize roles only when quoting a UI label + +- Use lowercase roles in running text. +- Capitalize a role only when quoting a UI label. +- Do not capitalize a role to signal importance. + +**Role names:** + +- workspace admin +- workspace owner +- domain admin +- account manager +- account executive +- partner manager +- member + +> **Examples:** +> +> Correct: +> +> - Only the workspace owner can manage the payment card. +> - Ask your domain admin to add the domain. +> - Chat with your account manager. +> - Choose **Workspace admin** from the list of roles. +> +> Incorrect: +> +> - Only the Workspace Owner can manage the payment card. +> - Only the Workspace owner can manage the payment card. +> - Ask your Domain Admin to add the domain. + +--- + +## Generic Terms + +### Always lowercase, except when referencing a UI label directly + +Lowercase these in running text, even when the matching tab is capitalized: + +- workspace / workspaces +- report / reports +- expense / expenses +- expense report +- invoice / invoices +- company card / company cards +- per diem / per diem rates +- workflow / approval workflow +- subscription +- plan +- cash back +- realtime +- expense management +- spend management +- bill pay + +> **Examples:** +> +> Correct: +> +> - Each workspace has one workspace owner. +> - You can configure how company card purchases export. +> - Set per diem rates to control daily employee spend. +> +> Incorrect: +> +> - Every Workspace has one Workspace Owner. +> - You can configure Company Card purchases export. +> - Set Per Diem rates. +> - Use Bill Pay to pay vendors. + +--- + +## Plans and Subscriptions + +### Capitalize plan names + +- Capitalize the plan name. Lowercase the word "plan." +- Lowercase subscription types in running text. +- Match the UI when quoting subscription options. + +> **Examples:** +> +> Correct: +> +> - Upgrade to the Control plan. +> - Your workspace is on the Collect plan. +> - Sign up for an annual subscription. +> - Select **Annual** or **Pay-per-use**. +> +> Incorrect: +> +> - Upgrade to the Control Plan. +> - Your workspace is on the control plan. +> - Sign up for an Annual Subscription. +> - Select annual or pay-per-use. --- diff --git a/docs/HELP_AUTHORING_GUIDELINES.md b/docs/HELP_AUTHORING_GUIDELINES.md index 97a273ea11c5..e7f54a4dad05 100644 --- a/docs/HELP_AUTHORING_GUIDELINES.md +++ b/docs/HELP_AUTHORING_GUIDELINES.md @@ -15,6 +15,7 @@ Every article must: - Solve one primary workflow only - Match real user search intent - Use exact UI terminology +- Follow the naming, navigation, and capitalization rules in docs/HELPSITE_NAMING_CONVENTIONS.md - Follow strict heading rules - Be optimized for semantic retrieval - Be concise and actionable @@ -46,10 +47,19 @@ If multiple workflows are detected → split into multiple articles. - Explicit - Feature-specific +## Capitalization + +- Use title case for the article title (the metadata `title` and the `#` title). +- Use sentence case for every `##` heading. +- Capitalize proper nouns, product names, plan names, and acronyms. Write `# FAQ` in all caps. +- Lowercase roles and generic terms (for example, workspace admin, workspace, expense report) unless quoting a UI label. + +Full capitalization rules are in docs/HELPSITE_NAMING_CONVENTIONS.md. + **Valid Examples** -# Who can connect a business bank account in Expensify -## Where to enable ACH reimbursements in a Workspace +# Who Can Connect a Business Bank Account in Expensify +## Where to enable ACH reimbursements in a workspace ## How to troubleshoot bank connection errors in Expensify ## How to enable Expensify Card notifications on iPhone @@ -69,7 +79,7 @@ Every article must begin with: ```yaml --- -title: Clear task-based title using feature name +title: Clear Task-Based Title Using Feature Name (title case) description: 1–2 sentence summary of outcome keywords: [primary task phrase, feature name, relevant roles] internalScope: Audience is [target role(s), covers [included workflow], does not cover [explicit exclusions] @@ -106,6 +116,11 @@ Step instructions must be: - Action-oriented - Aligned with actual UI flow +Step instructions must also follow docs/HELPSITE_NAMING_CONVENTIONS.md: +- Use **select** for interacting with UI elements and **choose** only for decisions between options. Do not use click, tap, hit, press, or push. +- Start navigation in the navigation tabs and use a bolded navigation path, such as **Workspaces > [workspace name] > Members**. +- Combine web and mobile instructions when navigation is the same. Use separate "On web:" and "On mobile:" instructions when it differs. + --- # 6. AI Retrieval Optimization Rules @@ -277,6 +292,16 @@ Before outputting an article, confirm: - No noun-only, topic-only, or platform-only headings - No generic headings (Overview, Introduction, Notes, Setup, Options, Step 1) - Feature names match UI + - Article title uses title case; every ## heading uses sentence case + - UI references follow docs/HELPSITE_NAMING_CONVENTIONS.md, including: + - **select** (or **choose** for decisions) instead of click or tap + - Navigation tabs, bolded navigation paths with lowercase placeholders, and the navigation menu + - **More (⋮)** for the three dots menu + - **field** for text-entry controls and "selection bar" for the bulk actions menu + - "Enable" or "disable" for toggles + - Color used only as a secondary descriptor + - Lowercase roles and generic terms; capitalized plan names with lowercase "plan" + - "This feature is only available on web/mobile" for platform limitations - Metadata aligns with search intent - Navigation included (if applicable) - Single workflow only diff --git a/docs/HELP_AUTHORING_GUIDELINES_TASK.md b/docs/HELP_AUTHORING_GUIDELINES_TASK.md index bbfa90bb58ff..bc629dfc5c11 100644 --- a/docs/HELP_AUTHORING_GUIDELINES_TASK.md +++ b/docs/HELP_AUTHORING_GUIDELINES_TASK.md @@ -7,7 +7,7 @@ Task articles explain how to complete a specific workflow in Expensify. Task articles answer questions such as: * How do I submit an expense? -* How do I change my Workspace plan? +* How do I change my workspace plan? * How do I connect NetSuite? * Why can't I complete a specific action? @@ -22,7 +22,7 @@ Every task article must: * Solve one primary workflow only * Match a specific customer goal * Use exact UI terminology -* Follow the official naming conventions +* Follow the official naming conventions in docs/HELPSITE_NAMING_CONVENTIONS.md * Be optimized for semantic retrieval * Be concise, actionable, and easy to scan @@ -89,7 +89,7 @@ Every task article must include: ```yaml --- -title: Clear task-based title using the feature name +title: Clear Task-Based Title Using the Feature Name (title case) description: Short summary of the workflow and outcome keywords: [primary task phrase, feature name, related search terms] internalScope: Audience is [target role]. Covers [workflow]. Does not cover [excluded workflows]. @@ -110,6 +110,7 @@ Task article headings must: * Be searchable * Be explicit * Describe what the reader will do or learn +* Use title case for the article title and sentence case for every `##` heading Use headings that begin with: @@ -122,7 +123,7 @@ Use headings that begin with: Examples: -* How to change your Workspace plan +* How to change your workspace plan * Where to find billing receipts * Why you can't submit an expense report * What happens after you approve an expense @@ -146,6 +147,9 @@ Procedural sections must: * Follow the actual UI flow * Use exact UI labels * Present actions in sequence +* Use **select** for UI interactions and **choose** only for decisions between options +* Start navigation in the navigation tabs and use a bolded navigation path, such as **Workspaces > [workspace name] > Members** +* Combine web and mobile instructions when navigation is the same, and use separate "On web:" and "On mobile:" instructions when it differs Instructions should: @@ -262,6 +266,8 @@ Before publishing, confirm: * One primary workflow is covered * Headings are task-based * UI terminology matches the product +* The article title uses title case and every `##` heading uses sentence case +* UI references, navigation, and capitalization follow docs/HELPSITE_NAMING_CONVENTIONS.md * Navigation instructions are accurate * Steps follow the actual workflow * Metadata reflects search intent diff --git a/docs/HELP_AUTHORING_GUIDELINES_TOPIC.md b/docs/HELP_AUTHORING_GUIDELINES_TOPIC.md index 08a5d461f66d..1bad4f0d6809 100644 --- a/docs/HELP_AUTHORING_GUIDELINES_TOPIC.md +++ b/docs/HELP_AUTHORING_GUIDELINES_TOPIC.md @@ -9,7 +9,7 @@ They help customers understand concepts before they perform related tasks. Topic articles answer questions such as: * How does billing work? -* What is a Workspace? +* What is a workspace? * How do approvals work? * How do reimbursements work? * How do subscription plans work? @@ -25,7 +25,7 @@ Every Topic article must: * Explain one product concept or product area. * Match a specific customer question. * Use exact product terminology. -* Follow the official naming conventions. +* Follow the official naming conventions in docs/HELPSITE_NAMING_CONVENTIONS.md. * Be optimized for semantic retrieval. * Be concise, organized, and easy to scan. @@ -94,7 +94,7 @@ Every Topic article must include: ```yaml --- -title: Clear concept-based title using the feature name +title: Clear Concept-Based Title Using the Feature Name (title case) description: Short summary of the concept keywords: [primary concept, feature name, related search terms] internalScope: Audience is [target role]. Covers [concept]. Does not cover [excluded workflows or concepts]. @@ -111,6 +111,8 @@ Metadata should reflect realistic customer search behavior. Topic headings should answer customer questions about the concept. +Use title case for the article title and sentence case for every `##` heading. + Prefer headings beginning with: * How @@ -267,6 +269,8 @@ Before publishing, confirm: * Common scenarios receive the most attention. * Important terminology is used consistently. * UI terminology matches the product. +* The article title uses title case and every `##` heading uses sentence case. +* UI references, navigation, and capitalization follow docs/HELPSITE_NAMING_CONVENTIONS.md. * Metadata reflects customer search intent. * Cross-links are minimal and purposeful. * Every suggested screenshot has a clear purpose that prevents or resolves a specific member confusion. diff --git a/docs/TEMPLATE-task.md b/docs/TEMPLATE-task.md index cadafe8f0e48..ddb60449438d 100644 --- a/docs/TEMPLATE-task.md +++ b/docs/TEMPLATE-task.md @@ -3,14 +3,12 @@ Task articles should describe things the user intentionally does. Outcomes, consequences, and eligibility rules belong inside those task articles or in related topic articles. --- -title: Clear task-based title using the feature name +title: Clear Task-Based Title Using the Feature Name (title case) description: A concise summary of the article for search results. keywords: [primary search phrases] internalScope: Audience is [who this article is for]. Covers [single workflow]. Does not cover [closely related workflows]. contentType: task platform: [platform value] -contentType: topic -platform: [platform] --- @@ -50,7 +50,7 @@ Avoid marketing language, implementation details, and unnecessary UI narration. --- -## Who can use [Feature Name] +## Who can use [feature name] State only the information the reader needs: @@ -61,13 +61,13 @@ State only the information the reader needs: If another workflow must be completed first, link to it using a relative link. -If unavailable on mobile, state: +If unavailable on a platform, name the platform where it is available: -This feature is not available on mobile. +This feature is only available on web. It isn't available on mobile. --- -## How to [Complete Primary Task] +## How to [complete primary task] Keep the steps focused. @@ -75,7 +75,14 @@ Describe only the actions necessary to complete the task. Do not document every click if it doesn't help the reader. -If web and mobile differ, separate the instructions. +Use **select** for UI interactions and **choose** only for decisions between options. + +Start navigation in the navigation tabs with a bolded navigation path: + +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Workspaces > [workspace name] > [Section name]**. +2. Select **[Exact button name, matching the UI]**. + +If web and mobile differ, separate the instructions under "On web:" and "On mobile:". If they're identical, combine them. @@ -93,7 +100,7 @@ Include this section only when the workflow changes behavior or has important co --> -## What happens after you [Complete Task] +## What happens after you [complete task] Explain what changes after completing the workflow. diff --git a/docs/TEMPLATE-topic.md b/docs/TEMPLATE-topic.md index 5b31501873eb..3f23e8c1425f 100644 --- a/docs/TEMPLATE-topic.md +++ b/docs/TEMPLATE-topic.md @@ -3,7 +3,7 @@ Topic articles explain a single product concept, feature, or behavior. They provide the context members need to understand related tasks by explaining the permissions, prerequisites, rules, and concepts that apply across those tasks, while leaving step-by-step instructions to individual Task articles. --- -title: [Natural, searchable topic title] +title: [Natural, Searchable Topic Title in Title Case] description: Briefly summarize the concept and why it matters. keywords: [[primary topic], [feature name], [related search phrases]] internalScope: Audience is [target audience]. Covers [single concept or feature]. Does not cover [related tasks or adjacent concepts]. @@ -48,12 +48,16 @@ TITLE GUIDELINES Choose a natural, searchable title that clearly describes the concept using the product's terminology. +Use title case for the title. Use sentence case for every ## heading. + +Follow docs/HELPSITE_NAMING_CONVENTIONS.md for UI references, navigation, and capitalization. + Examples: -- Billing and subscriptions -- Annual subscriptions -- Workspace roles -- Approval workflows +- Billing and Subscriptions +- Annual Subscriptions +- Workspace Roles +- Approval Workflows Avoid: @@ -68,11 +72,11 @@ Titles may: Examples: -- Billing and subscriptions -- Billing issues -- Annual subscriptions -- Workspace roles -- Understand approval workflows +- Billing and Subscriptions +- Billing Issues +- Annual Subscriptions +- Workspace Roles +- Understand Approval Workflows Avoid: diff --git a/docs/TEMPLATE.md b/docs/TEMPLATE.md index b7e0dfb0d4ba..14ea86841c60 100644 --- a/docs/TEMPLATE.md +++ b/docs/TEMPLATE.md @@ -1,5 +1,5 @@ --- -title: Clear task-based title using the feature name +title: Clear Task-Based Title Using the Feature Name (title case) description: A short description of the page's content. This is used for SEO purposes. keywords: [how to primary task, feature name, related task phrases] internalScope: Audience is [who this doc is for]. Covers [single clearly defined workflow], does not cover [closely related workflows intentionally excluded]. @@ -7,6 +7,7 @@ internalScope: Audience is [who this doc is for]. Covers [single clearly defined -1. Go to Settings > Workspaces > Workspace Name > [Section Name]. -2. Click **[Exact button name in Sentence case]**. +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Workspaces > [workspace name] > [Section name]**. +2. Select **[Exact button name, matching the UI]**. 3. Configure the required settings. -4. Click **Save** or final action button (for example, **Submit**, **Confirm**). +4. Select **Save** or the final action button (for example, **Submit**, **Confirm**). **ADD A SCREENSHOT HERE.** Suggestion: Show the full page with the relevant section highlighted. @@ -114,9 +118,9 @@ Clear direct answer. ## Why can’t I see [Feature Name]? Explain possible causes: -- You’re not a Workspace Admin or Workspace Owner +- You’re not a workspace admin or workspace owner - The feature is not included in your plan -- The Workspace setting is disabled +- The workspace setting is disabled - Domain-level restrictions apply ## Does [Feature Name] work with [Integration or Related Feature]? diff --git a/docs/articles/expensify-classic/connections/netsuite/Connect-To-NetSuite.md b/docs/articles/expensify-classic/connections/netsuite/Connect-To-NetSuite.md index 9e4170dcd76b..2b631a88007a 100644 --- a/docs/articles/expensify-classic/connections/netsuite/Connect-To-NetSuite.md +++ b/docs/articles/expensify-classic/connections/netsuite/Connect-To-NetSuite.md @@ -12,7 +12,7 @@ Expensify integrates directly with NetSuite to automate report exports, customiz # Before You Connect NetSuite to Expensify -- **OAuth 2.0** is how new connections are set up. You set it up in New Expensify, and you need two-factor authentication enabled on your Expensify account. +- **OAuth 2.0** is how new connections are set up, which you can set up in New Expensify. - You must have **administrator access** in NetSuite to initiate the connection. - **Control Plan** in Expensify is required for this integration. - Employees do not need NetSuite access or a license to submit expense reports; the integration is managed by the Workspace Admin. diff --git a/docs/articles/new-expensify/connections/campfire/Manage-Vendor-Matching-for-Campfire.md b/docs/articles/new-expensify/connections/campfire/Manage-Vendor-Matching-for-Campfire.md index f0519463d3a3..c2223045c825 100644 --- a/docs/articles/new-expensify/connections/campfire/Manage-Vendor-Matching-for-Campfire.md +++ b/docs/articles/new-expensify/connections/campfire/Manage-Vendor-Matching-for-Campfire.md @@ -1,8 +1,8 @@ --- title: Manage Vendor Matching for Campfire description: Learn how Campfire vendor matching assigns vendors to non-reimbursable company card expenses, including automatic matching, manual selection, and which Campfire vendors are eligible. -keywords: [Campfire, vendor matching, vendor, company card expenses, credit cards, no vendors found, vendor no longer valid] -internalScope: Audience is workspace admins using the Campfire connection for company card expenses. Covers imported Campfire vendors, automatic and manual vendor assignment, which Campfire vendor records are eligible, and vendors that are no longer valid. Does not cover Campfire connection setup, other Campfire configuration settings, or vendor matching for other accounting connections. +keywords: [Campfire, vendor matching, vendor, company card expenses, credit cards, default vendor for all company cards, Credit Card Misc, no vendors found, vendor no longer valid] +internalScope: Audience is workspace admins using the Campfire connection for company card expenses. Covers imported Campfire vendors, automatic and manual vendor assignment, which Campfire vendor records are eligible, default vendor behavior, the Credit Card Misc export fallback, and vendors that are no longer valid. Does not cover Campfire connection setup, other Campfire configuration settings, or vendor matching for other accounting connections. order: 4 --- @@ -53,6 +53,26 @@ Once a vendor is selected manually, Expensify preserves that selection and won't Vendors are managed in Campfire, so the list is read-only in Expensify and refreshes when the connection syncs. +## How to set a default vendor for all company cards in Campfire + +1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Workspaces > [workspace name]**. +2. Select **Accounting**. +3. On the Campfire connection, select **Export**. +4. Select **Default vendor for all company cards**. +5. Select a vendor from your imported Campfire vendor list. + +The default vendor is used only when an expense doesn't already have a vendor assigned. + +## How vendors export to Campfire + +When company card expenses are exported, Expensify assigns vendors in the following order: + +1. The vendor selected on the expense. +2. The **Default vendor for all company cards** configured in the Campfire export settings. +3. A vendor named **Credit Card Misc**, if neither of the above is available. + +If no active **Credit Card Misc** vendor exists in Campfire, Expensify reactivates an inactive one or creates a new one on the first export that needs it, and reuses it afterward. Expensify does not set it as your **Default vendor for all company cards**, so that setting stays empty until you choose a vendor yourself. + ## How vendors that are no longer valid affect expenses If a vendor assigned to an expense is removed or deactivated in Campfire, the expense displays a **Vendor no longer valid** error, similar to category, tag, and tax violations. @@ -63,7 +83,7 @@ Select an active vendor on the expense to clear the error. ## Do I have to set a vendor? -No. The **Vendor** field is optional. Expensify automatically attempts to match a vendor using your imported Campfire vendor list, and the field can remain blank if no match is found. +No. The **Vendor** field is optional. Expensify automatically attempts to match a vendor using your imported Campfire vendor list. If no match is found, the field can remain blank, and the expense exports using the **Default vendor for all company cards** configured in your Campfire export settings, or **Credit Card Misc** if no default is set. ## Does manually assigning a vendor stop automatic matching? diff --git a/docs/articles/new-expensify/connections/netsuite/Connect-To-NetSuite.md b/docs/articles/new-expensify/connections/netsuite/Connect-To-NetSuite.md index 36afe0f0a109..d841ec715e4b 100644 --- a/docs/articles/new-expensify/connections/netsuite/Connect-To-NetSuite.md +++ b/docs/articles/new-expensify/connections/netsuite/Connect-To-NetSuite.md @@ -18,7 +18,6 @@ This guide walks you through preparing NetSuite, connecting Expensify, and confi - A NetSuite account you can log into as an administrator - The **Control** plan in Expensify -- Two-factor authentication enabled on your Expensify account, which NetSuite requires to authorize an OAuth 2.0 connection - One Expensify **Workspace** per NetSuite **subsidiary** - A Workspace currency that matches the NetSuite subsidiary's default currency @@ -247,10 +246,6 @@ If you're connecting to a Sandbox environment, capitalize the "s" and "b" in the # FAQ -## Why do I need two-factor authentication to connect NetSuite? - -NetSuite requires two-factor authentication to authorize an OAuth 2.0 connection. If two-factor authentication isn't enabled on your Expensify account, Expensify prompts you to enable it before you can continue. Token-based connections don't require it. - ## Do I still need to create access tokens in NetSuite? Not if you connect with OAuth 2.0. You approve access on a NetSuite consent screen instead, so there are no token IDs or token secrets to create, copy, or store. You only create access tokens if you connect with token-based authentication, which we're deprecating. diff --git a/docs/articles/new-expensify/workspaces/Add-Approvals.md b/docs/articles/new-expensify/workspaces/Add-Approvals.md index 5f9dbd621ad7..31a831d2be5d 100644 --- a/docs/articles/new-expensify/workspaces/Add-Approvals.md +++ b/docs/articles/new-expensify/workspaces/Add-Approvals.md @@ -1,9 +1,8 @@ --- title: Add Approvals description: Require report approvals in your Expensify workspace, including setting approval thresholds and over-limit approvers. -keywords: [New Expensify, approvals, report approvals, approving reports, approval workflow, workspace workflows, approver, final approver, approval chain, approval threshold, approval limit, over-limit approval, report amount limit, dollar limit approver, reassign approver, automatic approver reassignment, change approver workflow, automatic approval, approval audit, category approver, tag approver, category rules, tag rules, invite member to approval workflow, invite new user approval workflow, import workflows, import approval workflows, bulk approval workflows, spreadsheet import, multiple approval paths, multiple approvers, shared approver, audit team approver, forward to different approver, approval path per submitter, edit approvals in Expensify Classic] +keywords: [New Expensify, approvals, report approvals, approving reports, approval workflow, workspace workflows, approver, final approver, approval chain, approval threshold, approval limit, over-limit approval, report amount limit, dollar limit approver, remove limit, remove approval limit, reassign approver, automatic approver reassignment, change approver workflow, approval workflow change message, admins room system message, automatic approval, approval audit, category approver, tag approver, category rules, tag rules, invite member to approval workflow, invite new user approval workflow, import workflows, import approval workflows, bulk approval workflows, spreadsheet import, approval limit format, multiple approval paths, multiple approvers, shared approver, audit team approver, forward to different approver, approval path per submitter, edit approvals in Expensify Classic] internalScope: Audience is Workspace Admins and People Admins, covers enabling approvals and building default, custom, and multi-path approval workflows in New Expensify, does not cover Category and Tag approver setup or connecting a bank account. -keywords: [New Expensify, approvals, report approvals, approving reports, approval workflow, workspace workflows, approver, final approver, approval chain, approval threshold, approval limit, over-limit approval, report amount limit, dollar limit approver, remove limit, remove approval limit, reassign approver, automatic approver reassignment, change approver workflow, approval workflow change message, admins room system message, automatic approval, approval audit, category approver, tag approver, category rules, tag rules, invite member to approval workflow, invite new user approval workflow, import workflows, import approval workflows, bulk approval workflows, spreadsheet import] --- Each Expensify workspace can be configured to require additional approvals for reports before payments are authorized. When approvals are enabled, admins can set a default approval workflow for all members or create custom workflows for individual members. @@ -101,11 +100,13 @@ Instead of adding workflows one member at a time, you can set up multiple member 1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Workspaces > [workspace name]**. 2. Select **Workflows**. -4. Select **More**, then choose **Import workflows**. -5. Drag and drop your file or click **Upload File** to browse. -6. Map each column in your file to a member field, then complete the import. +3. Select **More**, then choose **Import workflows**. +4. Drag and drop your file or click **Upload File** to browse. +5. Map each column in your file to a member field, then complete the import. -To route each member's expenses through the right approvers, map the **Submit to**, **Forward to**,**Approval limit** and **Over limit forward to** columns. These fields set up each member's approval workflow, which you can review under **Workflows** after the import completes. +To route each member's expenses through the right approvers, map the **Submit to**, **Forward to**, **Approval limit**, and **Over limit forward to** columns. These fields set up each member's approval workflow, which you can review under **Workflows** after the import completes. + +**Approval limit format:** Enter the **Approval limit** as an amount in your workspace currency, not in cents. For example, `500` sets a $500 limit. Currency symbols, currency codes, commas, and spaces are allowed, so `$1,234.56` also works. If any value isn't a valid amount, the import stops and shows the error "Approval limits must be valid amounts for the workspace currency." **Note:** The **Submit to**, **Forward to**, **Over limit forward to**, and **Approval limit** fields are only available on the Control plan. If your workspace isn't on the Control plan, you'll be prompted to upgrade before the import can finish. diff --git a/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md b/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md index aa9a38957be4..7179adb64976 100644 --- a/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md +++ b/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md @@ -138,6 +138,8 @@ When mapping columns, you can assign each one to any of the following member fie Mapping the **Submit to**, **Forward to**, **Over limit forward to**, and **Approval limit** fields sets up each member's approval workflow, which you can review under **Workflows** after the import completes. +**Approval limit format:** Enter the **Approval limit** as an amount in your workspace currency, not in cents. For example, `500` sets a $500 limit. Currency symbols, currency codes, commas, and spaces are allowed, so `$1,234.56` also works. If any value isn't a valid amount, the import stops and shows the error "Approval limits must be valid amounts for the workspace currency." + **Note:** Use this **[spreadsheet import template](https://docs.google.com/spreadsheets/d/19fjknN-KOS74RjXDccXZGUNTm-utdV7Gvveo5EyrJLE/edit?gid=0#gid=0)** to ensure proper formatting. **Control plan required:** The **Submit to**, **Forward to**, **Over limit forward to**, **Approval limit**, **Custom field 1**, and **Custom field 2** fields — along with specialized roles like **Auditor** and **Card admin** — are only available on the Control plan. If your workspace isn't on the Control plan, you'll be prompted to upgrade before the import can finish. @@ -170,6 +172,10 @@ Yes. Use the search tool during invite or import members using a spreadsheet (se Your file maps an advanced field (**Submit to**, **Forward to**, **Over limit forward to**, **Approval limit**, **Custom field 1**, or **Custom field 2**) or assigns a specialized role like **Auditor** or **Card admin**. These are only available on the Control plan, so you'll need to upgrade your workspace to the Control plan before the import can finish. +## Why do I see "Approval limits must be valid amounts for the workspace currency" when importing members? + +At least one value in your **Approval limit** column isn't a valid amount. Enter each limit as an amount in your workspace currency, such as `500` or `$1,234.56`, and remove any text that isn't part of the amount. Then upload the file again. + ## Why can't I download a list of workspace members to a CSV file? Only Workspace admins, People admins, and Editors on Submit workspaces can download the list of workspace members to a CSV file. If you are a Member, Auditor, Card admin, or Payments admin, you will not be able to download the members list. diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 00c5fda6c401..eacb395ff725 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -23,7 +23,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.5.6 + 9.5.7 CFBundleSignature ???? CFBundleURLTypes @@ -44,7 +44,7 @@ CFBundleVersion - 9.5.6.5 + 9.5.7.3 FullStory OrgId diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 7940db52fd0a..1eac51c5cf44 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.5.6 + 9.5.7 CFBundleVersion - 9.5.6.5 + 9.5.7.3 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 63acb97ed248..b1f52d4f86b6 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -146,7 +146,7 @@ PODS: - ExpoModulesCore - ExpoLogBox (57.0.1): - React-Core - - ExpoModulesCore (57.0.7): + - ExpoModulesCore (57.0.15): - ExpoModulesJSI - hermes-engine - RCTRequired @@ -170,10 +170,10 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - ExpoModulesJSI (57.0.4): + - ExpoModulesJSI (57.0.8): - React-Core - ReactCommon - - ExpoModulesWorklets (57.0.7): + - ExpoModulesWorklets (57.0.15): - ExpoModulesCore - ExpoModulesJSI - ExpoStoreReview (57.0.1): @@ -2955,7 +2955,7 @@ PODS: - RNGoogleSignin (10.0.1): - GoogleSignIn (~> 7.0) - React-Core - - RNLiveMarkdown (0.1.342): + - RNLiveMarkdown (0.1.343): - hermes-engine - RCTRequired - RCTTypeSafety @@ -3101,7 +3101,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - RNReanimated (4.5.5): + - RNReanimated (4.7.0): - hermes-engine - RCTRequired - RCTTypeSafety @@ -3123,12 +3123,12 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - ReactNativeDependencies - - RNReanimated/apple (= 4.5.5) - - RNReanimated/common (= 4.5.5) - - RNReanimated/view (= 4.5.5) + - RNReanimated/apple (= 4.7.0) + - RNReanimated/common (= 4.7.0) + - RNReanimated/view (= 4.7.0) - RNWorklets - Yoga - - RNReanimated/apple (4.5.5): + - RNReanimated/apple (4.7.0): - hermes-engine - RCTRequired - RCTTypeSafety @@ -3152,7 +3152,7 @@ PODS: - ReactNativeDependencies - RNWorklets - Yoga - - RNReanimated/common (4.5.5): + - RNReanimated/common (4.7.0): - hermes-engine - RCTRequired - RCTTypeSafety @@ -3176,7 +3176,7 @@ PODS: - ReactNativeDependencies - RNWorklets - Yoga - - RNReanimated/view (4.5.5): + - RNReanimated/view (4.7.0): - hermes-engine - RCTRequired - RCTTypeSafety @@ -3247,7 +3247,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - RNSentry (8.25.0): + - RNSentry (8.27.0): - hermes-engine - RCTRequired - RCTTypeSafety @@ -3337,7 +3337,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - RNWorklets (0.11.1): + - RNWorklets (0.13.0): - hermes-engine - RCTRequired - RCTTypeSafety @@ -3359,10 +3359,10 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - ReactNativeDependencies - - RNWorklets/apple (= 0.11.1) - - RNWorklets/common (= 0.11.1) + - RNWorklets/apple (= 0.13.0) + - RNWorklets/common (= 0.13.0) - Yoga - - RNWorklets/apple (0.11.1): + - RNWorklets/apple (0.13.0): - hermes-engine - RCTRequired - RCTTypeSafety @@ -3385,7 +3385,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - RNWorklets/common (0.11.1): + - RNWorklets/common (0.13.0): - hermes-engine - RCTRequired - RCTTypeSafety @@ -3961,9 +3961,9 @@ SPEC CHECKSUMS: ExpoImageManipulator: d3c962dabd86b331980f8ec6794c6073b3cb541a ExpoLocation: 187949911b868f72e34a8a6f8f487207ba86388a ExpoLogBox: 8a3cfa2897e088083cd49fc1ff7fdb2de4385547 - ExpoModulesCore: 5b2895ff83c42d320aaaa713c44051d84b0de698 - ExpoModulesJSI: 3accd404d715d63e7b04015d20c857e903e3bc73 - ExpoModulesWorklets: b21b8f233b5fd71ca74327217601fa4ead9ddaaf + ExpoModulesCore: 9c02efc618119023146b9b8db9c0930e43ac977d + ExpoModulesJSI: 4f849c58e99b57c2438b21ed6bc2fcb6f984a0d1 + ExpoModulesWorklets: 5e70b46d570c5b1b702c18bc8ac8d1af0171450e ExpoStoreReview: 2a0f6b8e04112aea2f6e3e1cb84109d78f4d041f ExpoTaskManager: f68326227852a30148700965c37c058c1473c6a2 ExpoVideo: b1d9a325ddcd4f3ed8ccb374c01b688e98bc6a04 @@ -4109,18 +4109,18 @@ SPEC CHECKSUMS: RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8 RNGestureHandler: 85e32defcf23dd3b023356355c5c1dd1d9d51bde RNGoogleSignin: 89877c73f0fbf6af2038fbdb7b73b5a25b8330cc - RNLiveMarkdown: f4c383d846d5d756f42bdf6006e85d5baf0ebfc8 + RNLiveMarkdown: b6894bb864d517f51ea80c98facd26571f2cd0fe RNLocalize: 82e7aa2857ce5dcd9e2a0c04f8c34c2ed50821df rnmapbox-maps: 54eb40428a4fd89f484a492fc7186bc1c1eea9ce RNNitroSQLite: c986393b7cb0e5e4587d0c9f7108e56ee8db9627 RNPermissions: 32bd3b977f28442af0900b102f4458f0f9f9b77c RNReactNativeHapticFeedback: 6c8677db48f62a635e6a2065f5d2bd02498d7a73 - RNReanimated: f093c30aa11e45a8044dcc19417f44cffa1c4e1d + RNReanimated: e75c988a6527ec651ea2957b93891ddb64d530d4 RNScreens: 8708989f700bdc3dc5abc3c77aa3b5f3e73ce2b2 - RNSentry: c878a3c462c1097f63a81615c784d44641ae92e4 + RNSentry: 3dcc26572ed12d8ee7edbe80d198dda70ac10d4c RNShare: eb378c57de30a83b0253eed74762ee99249f47d7 RNSVG: 394cfd0518613b144c65ffe69045f827b78a7a7c - RNWorklets: f0734080219ea54d479c179da498c9b24611faed + RNWorklets: 3da8f468c848547646f4e6b12fb46ad93a2cdee2 SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57 SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist index 3aa80878bfca..7d2e1fad24f0 100644 --- a/ios/ShareViewController/Info.plist +++ b/ios/ShareViewController/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.5.6 + 9.5.7 CFBundleVersion - 9.5.6.5 + 9.5.7.3 NSExtension NSExtensionAttributes diff --git a/jest.config.js b/jest.config.js index 58b96c5f43b7..61fe030d49f2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -16,6 +16,7 @@ const emitsUntestedFiles = !shardMatch || shardMatch[1] === '1'; module.exports = { preset: 'jest-expo', + resolver: '/jest/resolver.ts', collectCoverageFrom: emitsUntestedFiles ? ['/src/**/*.{ts,tsx,js,jsx}', '!/src/**/__mocks__/**', '!/src/**/tests/**', '!**/*.d.ts'] : undefined, // Keeps the instrumented set identical to `collectCoverageFrom` above on the shards that skip it. coveragePathIgnorePatterns: ['/node_modules/', '/(?!src/)', '/__mocks__/', '/src/.*/tests/', '\\.d\\.ts$'], diff --git a/jest/resolver.ts b/jest/resolver.ts new file mode 100644 index 000000000000..b0093552708c --- /dev/null +++ b/jest/resolver.ts @@ -0,0 +1,28 @@ +// Since Reanimated 4.6 its shared files are split into `.native`/`.web` variants and Jest resolves the `.native` ones, +// which call native-only APIs at import time (e.g. `setCSSEventHandler`) and crash every suite that imports Reanimated. +// Reanimated's resolver maps those files to their web variants (and chains the Worklets resolver), but replacing the +// jest-expo preset's React Native resolver would break mocking `react-native/*` subpaths, so this chains both. +// +// Jest `require`s this file untransformed, so Node strips the types and loads it as an ES module: Jest reads the `sync` +// export, and both resolvers are untyped CommonJS loaded through `createRequire`. +import {createRequire} from 'node:module'; + +type ResolverOptions = { + defaultResolver: SyncResolver; +}; + +/** The subset of Jest's `SyncResolver` (from `jest-resolve`) that this file relies on. */ +type SyncResolver = (path: string, options: ResolverOptions) => string; + +const requireResolver: (id: string) => SyncResolver = createRequire(import.meta.url); +const reactNativeResolver = requireResolver('@react-native/jest-preset/jest/resolver'); +const reanimatedResolver = requireResolver('react-native-reanimated/jest/resolver'); + +const resolver: SyncResolver = (request, options) => + reanimatedResolver(request, { + ...options, + defaultResolver: (path, nextOptions) => reactNativeResolver(path, {...nextOptions, defaultResolver: options.defaultResolver}), + }); + +export default resolver; +export {resolver as sync}; diff --git a/jest/setup.ts b/jest/setup.ts index 9afaa8f219a1..d7e3eec8321e 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -152,11 +152,6 @@ jest.mock('react-native-reanimated', () => ({ // react-native-reanimated/mock leaves dispatchCommand out (see its own "ADD ME IF NEEDED" comment). forceClearInput // (src/libs/ComponentUtils) dispatches it from a UI-thread worklet, so any test exercising that path needs it mocked. dispatchCommand: jest.fn(), - // react-native-reanimated/mock also leaves out useComposedEventHandler, useHandler and isSharedValue, which Gesture Handler 3 - // calls from its detectors (including the ones behind its ScrollView / FlatList wrappers). - useComposedEventHandler: jest.fn(() => () => {}), - useHandler: jest.fn(() => ({context: {}, doDependenciesDiffer: false})), - isSharedValue: jest.fn((value: unknown) => typeof value === 'object' && value !== null && 'value' in value && 'get' in value && typeof value.get === 'function'), useReducedMotion: jest.fn, useScrollViewOffset: jest.fn(() => 0), useAnimatedRef: jest.fn(() => jest.fn()), diff --git a/modules/ExpensifyNitroUtils/android/gradle.properties b/modules/ExpensifyNitroUtils/android/gradle.properties index 9ab188257e16..261b7472e9f9 100644 --- a/modules/ExpensifyNitroUtils/android/gradle.properties +++ b/modules/ExpensifyNitroUtils/android/gradle.properties @@ -1,5 +1,5 @@ ContactsModule_kotlinVersion=2.0.21 ContactsModule_minSdkVersion=24 ContactsModule_targetSdkVersion=36 -ContactsModule_compileSdkVersion=36 +ContactsModule_compileSdkVersion=37 ContactsModule_ndkVersion=27.1.12297006 diff --git a/modules/ExpensifyNitroUtils/android/src/main/java/com/margelo/nitro/utils/contacts/HybridContactsModule.kt b/modules/ExpensifyNitroUtils/android/src/main/java/com/margelo/nitro/utils/contacts/HybridContactsModule.kt index 7004252a804f..434e0e419b4b 100644 --- a/modules/ExpensifyNitroUtils/android/src/main/java/com/margelo/nitro/utils/contacts/HybridContactsModule.kt +++ b/modules/ExpensifyNitroUtils/android/src/main/java/com/margelo/nitro/utils/contacts/HybridContactsModule.kt @@ -1,159 +1,227 @@ package com.margelo.nitro.utils -import android.Manifest -import android.content.pm.PackageManager +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import android.os.Build import android.provider.ContactsContract -import androidx.core.app.ActivityCompat -import androidx.core.content.ContextCompat -import com.facebook.react.bridge.ReactApplicationContext -import com.facebook.react.uimanager.ThemedReactContext +import android.provider.ContactsContract.CommonDataKinds.Email +import android.provider.ContactsContract.CommonDataKinds.Phone +import android.provider.ContactsContract.CommonDataKinds.StructuredName +import android.provider.ContactsPickerSessionContract +import android.util.Log +import com.facebook.react.bridge.ActivityEventListener import com.margelo.nitro.NitroModules import com.margelo.nitro.core.Promise +import kotlin.concurrent.thread +/** + * Android only gives access to contacts the user explicitly picks, so the app doesn't need (and must not declare) READ_CONTACTS. + * See Google Play's Contacts Permission policy. + */ class HybridContactsModule : HybridContactsModuleSpec() { - @Volatile - private var estimatedMemorySize: Long = 0 private val context = NitroModules.applicationContext!! - override val memorySize: Long - get() = estimatedMemorySize + @Volatile + private var pendingPickPromise: Promise>? = null + + private val activityEventListener = object : ActivityEventListener { + override fun onActivityResult(activity: Activity, requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode != PICK_CONTACT_REQUEST_CODE) { + return + } + val promise = pendingPickPromise ?: return + pendingPickPromise = null + + val resultUri = data?.data + if (resultCode != Activity.RESULT_OK || resultUri == null) { + promise.resolve(emptyArray()) + return + } - private fun requestContactPermission(): Boolean { - val currentActivity = context.currentActivity - if (currentActivity == null) { - return false + // The picker only grants temporary read access to the returned URI, so read it right away, off the main thread + thread { + try { + promise.resolve(readPickedContacts(resultUri)) + } catch (e: Throwable) { + promise.reject(e) + } + } } - // Request permissions - ActivityCompat.requestPermissions( - currentActivity, - REQUIRED_PERMISSIONS, - PERMISSION_REQUEST_CODE - ) - return true + override fun onNewIntent(intent: Intent) {} } - private fun hasPhoneContactsPermission(): Boolean { - return ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED + init { + context.addActivityEventListener(activityEventListener) } + override val memorySize: Long + get() = 0 + + // Reading the whole address book requires READ_CONTACTS, which isn't declared on Android. Use pick() instead. override fun getAll(keys: Array): Promise> { - return Promise.parallel { - val contacts = mutableListOf() - if (!hasPhoneContactsPermission()) { - requestContactPermission() - return@parallel emptyArray() + return Promise.resolved(emptyArray()) + } + + override fun pick(keys: Array): Promise> { + val promise = Promise>() + val activity = context.currentActivity + if (activity == null) { + promise.resolve(emptyArray()) + return promise + } + + // Only one picker can be on screen, so settle any previous request that never got a result + pendingPickPromise?.resolve(emptyArray()) + pendingPickPromise = promise + + activity.runOnUiThread { + try { + activity.startActivityForResult(createPickIntent(keys), PICK_CONTACT_REQUEST_CODE) + } catch (e: ActivityNotFoundException) { + Log.w(TAG, "No activity found to pick a contact", e) + if (pendingPickPromise === promise) { + pendingPickPromise = null + } + promise.resolve(emptyArray()) } + } + return promise + } - context.contentResolver?.let { resolver -> - resolver.query( - ContactsContract.Data.CONTENT_URI, - CONTACT_PROJECTION, - CONTACT_SELECTION, - CONTACT_SELECTION_ARGS, - CONTACT_SORT_ORDER - )?.use { cursor -> - val mimeTypeIndex = cursor.getColumnIndex(ContactsContract.Data.MIMETYPE) - val contactIdIndex = cursor.getColumnIndex(ContactsContract.Data.CONTACT_ID) - val photoUriIndex = cursor.getColumnIndex(ContactsContract.Contacts.PHOTO_URI) - val data1Index = cursor.getColumnIndex(ContactsContract.Data.DATA1) - val givenNameIndex = - cursor.getColumnIndex(ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME) - val familyNameIndex = - cursor.getColumnIndex(ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME) - - var currentContact: Contact? = null - var currentContactId: String? = null - val currentPhoneNumbers = mutableListOf() - val currentEmailAddresses = mutableListOf() - - while (cursor.moveToNext()) { - val contactId = cursor.getString(contactIdIndex) - val mimeType = cursor.getString(mimeTypeIndex) - - if (contactId != currentContactId) { - currentContact?.let { contact -> - contacts.add( - contact.copy( - phoneNumbers = currentPhoneNumbers.toTypedArray(), - emailAddresses = currentEmailAddresses.toTypedArray() - ) - ) - } - currentPhoneNumbers.clear() - currentEmailAddresses.clear() - currentContact = Contact( - firstName = "", - lastName = "", - phoneNumbers = emptyArray(), - emailAddresses = emptyArray(), - imageData = cursor.getString(photoUriIndex) ?: "" - ) - currentContactId = contactId - } - - when (mimeType) { - ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE -> { - currentContact = currentContact?.copy( - firstName = cursor.getString(givenNameIndex) ?: "", - lastName = cursor.getString(familyNameIndex) ?: "" - ) - } - - ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE -> { - cursor.getString(data1Index)?.let { phone -> - currentPhoneNumbers.add(StringHolder(phone)) - } - } - - ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE -> { - cursor.getString(data1Index)?.let { email -> - currentEmailAddresses.add(StringHolder(email)) - } - } - } - } + private fun createPickIntent(keys: Array): Intent { + if (Build.VERSION.SDK_INT >= CONTACTS_PICKER_MIN_SDK) { + val requestedDataFields = arrayListOf() + if (keys.contains(ContactFields.FIRST_NAME) || keys.contains(ContactFields.LAST_NAME)) { + requestedDataFields.add(StructuredName.CONTENT_ITEM_TYPE) + } + if (keys.contains(ContactFields.EMAIL_ADDRESSES)) { + requestedDataFields.add(Email.CONTENT_ITEM_TYPE) + } + if (keys.contains(ContactFields.PHONE_NUMBERS)) { + requestedDataFields.add(Phone.CONTENT_ITEM_TYPE) + } + return Intent(ContactsPickerSessionContract.ACTION_PICK_CONTACTS).apply { + // Below targetSdk 37 the picker forwards to a contacts app unless we opt in, and none handle this action + putExtra(Intent.EXTRA_USE_SYSTEM_CONTACTS_PICKER, true) + putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) + putStringArrayListExtra(ContactsPickerSessionContract.EXTRA_PICK_CONTACTS_REQUESTED_DATA_FIELDS, requestedDataFields) + } + } + + // Before Android 17 the picker that needs no permission can only return one kind of data, so prefer email over phone + val contentType = if (keys.contains(ContactFields.EMAIL_ADDRESSES)) Email.CONTENT_TYPE else Phone.CONTENT_TYPE + return Intent(Intent.ACTION_PICK).setType(contentType) + } + + private fun readPickedContacts(resultUri: Uri): Array { + // Match the intent createPickIntent launched. The session URI carries a user prefix (0@...), so its authority never equals AUTHORITY + if (Build.VERSION.SDK_INT >= CONTACTS_PICKER_MIN_SDK) { + return readContactsPickerSession(resultUri) + } + return readPickedDataRow(resultUri) + } - // Add the last contact - currentContact?.let { contact -> - contacts.add( - contact.copy( - phoneNumbers = currentPhoneNumbers.toTypedArray(), - emailAddresses = currentEmailAddresses.toTypedArray() - ) - ) + /** Reads the rows of an Android 17+ Contacts Picker session. Each row follows the ContactsContract.Data schema. */ + private fun readContactsPickerSession(sessionUri: Uri): Array { + val contactsByLookupKey = linkedMapOf() + + context.contentResolver.query(sessionUri, SESSION_PROJECTION, null, null, null)?.use { cursor -> + val lookupKeyIndex = cursor.getColumnIndex(ContactsContract.Contacts.LOOKUP_KEY) + val displayNameIndex = cursor.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME_PRIMARY) + val mimeTypeIndex = cursor.getColumnIndex(ContactsContract.Data.MIMETYPE) + val data1Index = cursor.getColumnIndex(ContactsContract.Data.DATA1) + val givenNameIndex = cursor.getColumnIndex(StructuredName.GIVEN_NAME) + val familyNameIndex = cursor.getColumnIndex(StructuredName.FAMILY_NAME) + + while (cursor.moveToNext()) { + val lookupKey = cursor.getStringOrNull(lookupKeyIndex) ?: continue + val contact = contactsByLookupKey.getOrPut(lookupKey) { + PickedContact(displayName = cursor.getStringOrNull(displayNameIndex)) + } + + when (cursor.getStringOrNull(mimeTypeIndex)) { + StructuredName.CONTENT_ITEM_TYPE -> { + contact.firstName = cursor.getStringOrNull(givenNameIndex) + contact.lastName = cursor.getStringOrNull(familyNameIndex) } + + Email.CONTENT_ITEM_TYPE -> cursor.getStringOrNull(data1Index)?.let { contact.emailAddresses.add(StringHolder(it)) } + Phone.CONTENT_ITEM_TYPE -> cursor.getStringOrNull(data1Index)?.let { contact.phoneNumbers.add(StringHolder(it)) } } } + } + + return contactsByLookupKey.values.map { it.toContact() }.toTypedArray() + } + + /** Reads the single email or phone row returned by Intent.ACTION_PICK before Android 17. */ + private fun readPickedDataRow(dataUri: Uri): Array { + context.contentResolver.query(dataUri, DATA_ROW_PROJECTION, null, null, null)?.use { cursor -> + if (!cursor.moveToFirst()) { + return emptyArray() + } + val value = cursor.getStringOrNull(cursor.getColumnIndex(ContactsContract.Data.DATA1)) ?: return emptyArray() + val contact = PickedContact(displayName = cursor.getStringOrNull(cursor.getColumnIndex(ContactsContract.Data.DISPLAY_NAME_PRIMARY))) + when (cursor.getStringOrNull(cursor.getColumnIndex(ContactsContract.Data.MIMETYPE))) { + Phone.CONTENT_ITEM_TYPE -> contact.phoneNumbers.add(StringHolder(value)) + Email.CONTENT_ITEM_TYPE -> contact.emailAddresses.add(StringHolder(value)) + else -> return emptyArray() + } + return arrayOf(contact.toContact()) + } + return emptyArray() + } + + private fun android.database.Cursor.getStringOrNull(index: Int): String? { + if (index < 0 || isNull(index)) { + return null + } + return getString(index) + } - // Update memory size based on contact count - estimatedMemorySize = contacts.size.toLong() * 1024 // Assume ~1KB per contact - contacts.toTypedArray() + private class PickedContact(val displayName: String?) { + var firstName: String? = null + var lastName: String? = null + val phoneNumbers = mutableListOf() + val emailAddresses = mutableListOf() + + fun toContact(): Contact { + val hasStructuredName = !firstName.isNullOrEmpty() || !lastName.isNullOrEmpty() + return Contact( + // Fall back to the display name when the contact has no structured name + firstName = if (hasStructuredName) firstName ?: "" else displayName ?: "", + lastName = if (hasStructuredName) lastName ?: "" else "", + phoneNumbers = phoneNumbers.toTypedArray(), + emailAddresses = emailAddresses.toTypedArray(), + // The picker doesn't grant access to contact photos + imageData = "" + ) } } companion object { - const val PERMISSION_REQUEST_CODE = 1 - val REQUIRED_PERMISSIONS = arrayOf(Manifest.permission.READ_CONTACTS) - - private val CONTACT_PROJECTION = arrayOf( + private const val TAG = "HybridContactsModule" + private const val PICK_CONTACT_REQUEST_CODE = 0xC047 + + // Android 17, where ContactsPickerSessionContract.ACTION_PICK_CONTACTS was added + private const val CONTACTS_PICKER_MIN_SDK = 37 + + private val SESSION_PROJECTION = arrayOf( + ContactsContract.Contacts.LOOKUP_KEY, + ContactsContract.Contacts.DISPLAY_NAME_PRIMARY, ContactsContract.Data.MIMETYPE, - ContactsContract.Data.CONTACT_ID, - ContactsContract.Data.DISPLAY_NAME, - ContactsContract.Contacts.PHOTO_URI, ContactsContract.Data.DATA1, - ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME, - ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME + StructuredName.GIVEN_NAME, + StructuredName.FAMILY_NAME ) - - private const val CONTACT_SELECTION = "${ContactsContract.Data.MIMETYPE} IN (?, ?, ?)" - - private val CONTACT_SELECTION_ARGS = arrayOf( - ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE, - ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE, - ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE + + private val DATA_ROW_PROJECTION = arrayOf( + ContactsContract.Data.DISPLAY_NAME_PRIMARY, + ContactsContract.Data.MIMETYPE, + ContactsContract.Data.DATA1 ) - - private const val CONTACT_SORT_ORDER = "${ContactsContract.Data.CONTACT_ID} ASC" } } diff --git a/modules/ExpensifyNitroUtils/ios/contacts/HybridContactsModule.swift b/modules/ExpensifyNitroUtils/ios/contacts/HybridContactsModule.swift index 44418d2e26b2..46d2f51f7b2c 100644 --- a/modules/ExpensifyNitroUtils/ios/contacts/HybridContactsModule.swift +++ b/modules/ExpensifyNitroUtils/ios/contacts/HybridContactsModule.swift @@ -37,7 +37,12 @@ final class HybridContactsModule: HybridContactsModuleSpec { return contacts } } - + + // The system contact picker is only used on Android, where READ_CONTACTS can't be requested. iOS keeps using getAll. + func pick(keys: [ContactFields]) throws -> Promise<[Contact]> { + Promise.resolved(withResult: []) + } + // This annotation forces the compiler to inline this function at every call site, // eliminating function call overhead for better performance during contact processing @inline(__always) diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridContactsModuleSpec.cpp b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridContactsModuleSpec.cpp index 8b9cf258107e..7d51cb0a1147 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridContactsModuleSpec.cpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridContactsModuleSpec.cpp @@ -94,4 +94,39 @@ namespace margelo::nitro::utils { }(); } + std::shared_ptr>> JHybridContactsModuleSpec::pick(const std::vector& keys) { + static const auto method = _javaPart->javaClassStatic()->getMethod(jni::alias_ref> /* keys */)>("pick"); + auto __result = method(_javaPart, [&](auto&& __input) { + size_t __size = __input.size(); + jni::local_ref> __array = jni::JArrayClass::newArray(__size); + for (size_t __i = 0; __i < __size; __i++) { + const auto& __element = __input[__i]; + auto __elementJni = JContactFields::fromCpp(__element); + __array->setElement(__i, *__elementJni); + } + return __array; + }(keys)); + return [&]() { + auto __promise = Promise>::create(); + __result->cthis()->addOnResolvedListener([=](const jni::alias_ref& __boxedResult) { + auto __result = jni::static_ref_cast>(__boxedResult); + __promise->resolve([&](auto&& __input) { + size_t __size = __input->size(); + std::vector __vector; + __vector.reserve(__size); + for (size_t __i = 0; __i < __size; __i++) { + auto __element = __input->getElement(__i); + __vector.push_back(__element->toCpp()); + } + return __vector; + }(__result)); + }); + __result->cthis()->addOnRejectedListener([=](const jni::alias_ref& __throwable) { + jni::JniException __jniError(__throwable); + __promise->reject(std::make_exception_ptr(__jniError)); + }); + return __promise; + }(); + } + } // namespace margelo::nitro::utils diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridContactsModuleSpec.hpp b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridContactsModuleSpec.hpp index 36a9aee8047d..fb8b9369c2ec 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridContactsModuleSpec.hpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridContactsModuleSpec.hpp @@ -55,6 +55,7 @@ namespace margelo::nitro::utils { public: // Methods std::shared_ptr>> getAll(const std::vector& keys) override; + std::shared_ptr>> pick(const std::vector& keys) override; private: jni::global_ref _javaPart; diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/HybridContactsModuleSpec.kt b/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/HybridContactsModuleSpec.kt index 2de192671efc..254915c2eb09 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/HybridContactsModuleSpec.kt +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/HybridContactsModuleSpec.kt @@ -33,6 +33,10 @@ abstract class HybridContactsModuleSpec: HybridObject() { @DoNotStrip @Keep abstract fun getAll(keys: Array): Promise> + + @DoNotStrip + @Keep + abstract fun pick(keys: Array): Promise> // Default implementation of `HybridObject.toString()` override fun toString(): String { diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/c++/HybridContactsModuleSpecSwift.hpp b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/c++/HybridContactsModuleSpecSwift.hpp index 17fb6c17149a..8e9655fcef63 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/c++/HybridContactsModuleSpecSwift.hpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/c++/HybridContactsModuleSpecSwift.hpp @@ -86,6 +86,15 @@ namespace margelo::nitro::utils { return __value; } + inline std::shared_ptr>> pick(const std::vector& keys) override { + auto __result = _swiftPart.pick(keys); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + private: ExpensifyNitroUtils::HybridContactsModuleSpec_cxx _swiftPart; }; diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/HybridContactsModuleSpec.swift b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/HybridContactsModuleSpec.swift index cd6b32d83873..63516a7a8d86 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/HybridContactsModuleSpec.swift +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/HybridContactsModuleSpec.swift @@ -14,6 +14,7 @@ public protocol HybridContactsModuleSpec_protocol: HybridObject { // Methods func getAll(keys: [ContactFields]) throws -> Promise<[Contact]> + func pick(keys: [ContactFields]) throws -> Promise<[Contact]> } public extension HybridContactsModuleSpec_protocol { diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/HybridContactsModuleSpec_cxx.swift b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/HybridContactsModuleSpec_cxx.swift index e8c768395282..de2bbf8da2d8 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/HybridContactsModuleSpec_cxx.swift +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/HybridContactsModuleSpec_cxx.swift @@ -148,4 +148,29 @@ open class HybridContactsModuleSpec_cxx { return bridge.create_Result_std__shared_ptr_Promise_std__vector_Contact____(__exceptionPtr) } } + + @inline(__always) + public final func pick(keys: bridge.std__vector_ContactFields_) -> bridge.Result_std__shared_ptr_Promise_std__vector_Contact____ { + do { + let __result = try self.__implementation.pick(keys: keys.map({ __item in __item })) + let __resultCpp = { () -> bridge.std__shared_ptr_Promise_std__vector_Contact___ in + let __promise = bridge.create_std__shared_ptr_Promise_std__vector_Contact___() + let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_std__vector_Contact___(__promise) + __result + .then({ __result in __promiseHolder.resolve({ () -> bridge.std__vector_Contact_ in + var __vector = bridge.create_std__vector_Contact_(__result.count) + for __item in __result { + __vector.push_back(__item) + } + return __vector + }()) }) + .catch({ __error in __promiseHolder.reject(__error.toCpp()) }) + return __promise + }() + return bridge.create_Result_std__shared_ptr_Promise_std__vector_Contact____(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_std__shared_ptr_Promise_std__vector_Contact____(__exceptionPtr) + } + } } diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/HybridContactsModuleSpec.cpp b/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/HybridContactsModuleSpec.cpp index dec0a2adc457..58a239ad2169 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/HybridContactsModuleSpec.cpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/HybridContactsModuleSpec.cpp @@ -15,6 +15,7 @@ namespace margelo::nitro::utils { // load custom methods/properties registerHybrids(this, [](Prototype& prototype) { prototype.registerHybridMethod("getAll", &HybridContactsModuleSpec::getAll); + prototype.registerHybridMethod("pick", &HybridContactsModuleSpec::pick); }); } diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/HybridContactsModuleSpec.hpp b/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/HybridContactsModuleSpec.hpp index cbb6831c685c..2cab86d59442 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/HybridContactsModuleSpec.hpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/HybridContactsModuleSpec.hpp @@ -55,6 +55,7 @@ namespace margelo::nitro::utils { public: // Methods virtual std::shared_ptr>> getAll(const std::vector& keys) = 0; + virtual std::shared_ptr>> pick(const std::vector& keys) = 0; protected: // Hybrid Setup diff --git a/modules/ExpensifyNitroUtils/src/specs/ContactsModule.nitro.ts b/modules/ExpensifyNitroUtils/src/specs/ContactsModule.nitro.ts index ea4521560604..965efc975bb0 100644 --- a/modules/ExpensifyNitroUtils/src/specs/ContactsModule.nitro.ts +++ b/modules/ExpensifyNitroUtils/src/specs/ContactsModule.nitro.ts @@ -26,6 +26,9 @@ type Contact = { interface ContactsModule extends HybridObject<{ios: 'swift'; android: 'kotlin'}> { getAll(keys: ContactFields[]): Promise; + + /** Opens the system contact picker and resolves with the contacts the user selected, or an empty array if they cancel */ + pick(keys: ContactFields[]): Promise; } export {CONTACT_FIELDS}; diff --git a/package-lock.json b/package-lock.json index c61a01f41f67..e4817fd261c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.5.6-5", + "version": "9.5.7-3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.5.6-5", + "version": "9.5.7-3", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -20,7 +20,7 @@ "@expensify/nitro-utils": "file:./modules/ExpensifyNitroUtils", "@expensify/react-native-background-task": "file:./modules/background-task", "@expensify/react-native-hybrid-app": "file:./modules/hybrid-app", - "@expensify/react-native-live-markdown": "0.1.342", + "@expensify/react-native-live-markdown": "0.1.343", "@expensify/react-native-wallet": "0.1.22", "@expo/metro-config": "57.0.7", "@formatjs/intl-listformat": "^7.5.7", @@ -43,17 +43,17 @@ "@react-native-firebase/app": "^22.2.1", "@react-native-google-signin/google-signin": "^10.0.1", "@react-native-picker/picker": "2.11.3", - "@react-navigation/bottom-tabs": "7.15.5", - "@react-navigation/core": "7.21.12", - "@react-navigation/material-top-tabs": "7.4.19", - "@react-navigation/native": "7.1.33", - "@react-navigation/native-stack": "7.14.5", - "@react-navigation/stack": "7.8.5", + "@react-navigation/bottom-tabs": "7.19.2", + "@react-navigation/core": "7.22.1", + "@react-navigation/material-top-tabs": "7.7.2", + "@react-navigation/native": "7.4.1", + "@react-navigation/native-stack": "7.19.2", + "@react-navigation/stack": "7.11.2", "@react-ng/bounds-observer": "^0.2.1", "@rnmapbox/maps": "10.3.2", "@sbaiahmed1/react-native-biometrics": "0.15.0", - "@sentry/core": "10.73.0", - "@sentry/react-native": "8.25.0", + "@sentry/core": "10.75.0", + "@sentry/react-native": "8.27.0", "@shopify/flash-list": "2.3.2", "@shopify/react-native-skia": "^2.11.2", "@ua/react-native-airship": "26.7.0", @@ -66,7 +66,7 @@ "date-fns-tz": "^3.2.0", "dom-serializer": "^0.2.2", "domhandler": "^5.0.3", - "expensify-common": "2.0.207", + "expensify-common": "2.0.211", "expo": "57.0.8", "expo-asset": "57.0.7", "expo-audio": "57.0.3", @@ -74,7 +74,7 @@ "expo-image": "57.0.1", "expo-image-manipulator": "57.0.6", "expo-location": "57.0.6", - "expo-modules-core": "57.0.7", + "expo-modules-core": "57.0.15", "expo-store-review": "57.0.1", "expo-task-manager": "57.0.6", "expo-video": "57.0.2", @@ -93,6 +93,7 @@ "mapbox-gl": "^3.24.0", "metro": "0.84.5", "metro-transform-plugins": "0.84.5", + "mime-db": "1.52.0", "onfido-sdk-ui": "14.53.1", "pako": "^2.1.0", "process": "^0.11.10", @@ -134,7 +135,7 @@ "react-native-qrcode-svg": "6.3.21", "react-native-quick-base64": "^3.0.1", "react-native-quick-crypto": "^1.1.6", - "react-native-reanimated": "4.5.5", + "react-native-reanimated": "4.7.0", "react-native-render-html": "6.3.1", "react-native-safe-area-context": "5.9.1", "react-native-screens": "4.28.0", @@ -146,7 +147,7 @@ "react-native-vision-camera": "^4.7.2", "react-native-web": "0.21.2", "react-native-webview": "16.0.0", - "react-native-worklets": "0.11.1", + "react-native-worklets": "0.13.0", "react-plaid-link": "3.3.2", "react-web-config": "^1.0.0", "react-webcam": "^7.1.1", @@ -186,7 +187,7 @@ "@react-native-community/cli-platform-ios": "20.1.0", "@react-native/babel-preset": "0.86.0", "@react-native/metro-config": "0.86.0", - "@react-navigation/devtools": "7.0.52", + "@react-navigation/devtools": "7.1.15", "@rock-js/platform-android": "0.13.4", "@rock-js/platform-ios": "0.13.4", "@rock-js/plugin-metro": "0.13.4", @@ -286,7 +287,7 @@ "source-map": "^0.7.4", "storybook": "10.4.6", "storybook-react-rsbuild": "^3.3.4", - "svgo": "^4.0.2", + "svgo": "^4.1.0", "type-fest": "4.35.0", "typescript": "^7.0.2", "typescript-eslint": "8.58.1", @@ -5895,9 +5896,9 @@ "link": true }, "node_modules/@expensify/react-native-live-markdown": { - "version": "0.1.342", - "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.342.tgz", - "integrity": "sha512-xt8t0pUTHHPfY9G25wZ0rQUP44eFdVQp44Mo2Ji2H66zr/qjUnDqDOQPJxi5azucUwI/uORmmMJOvXMdKsEDNg==", + "version": "0.1.343", + "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.343.tgz", + "integrity": "sha512-3M1FQ6YgMy9dv0LiMORI1rwKwpJ0z3Ce94NkuxqGvObHPuee1vHX2Ozi7th97c+MzBXkydDJgnq20yH20jic9Q==", "license": "MIT", "workspaces": [ "./example", @@ -14687,17 +14688,17 @@ } }, "node_modules/@react-navigation/bottom-tabs": { - "version": "7.15.5", - "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.15.5.tgz", - "integrity": "sha512-wQHredlCrRmShWQ1vF4HUcLdaiJ8fUgnbaeQH7BJ7MQVQh4mdzab0IOY/4QSmUyNRB350oyu1biTycyQ5FKWMQ==", + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.19.2.tgz", + "integrity": "sha512-sXUS7cySIj2cLwD1trdTnbqAm3TWYiF6L9i0VNES2+4ssUyBDgkIdmm7uxjhJ8ZSjzpWj8uSfLHkkSx0j0MpmA==", "license": "MIT", "dependencies": { - "@react-navigation/elements": "^2.9.10", + "@react-navigation/elements": "^2.9.43", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { - "@react-navigation/native": "^7.1.33", + "@react-navigation/native": "^7.4.1", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", @@ -14705,16 +14706,15 @@ } }, "node_modules/@react-navigation/core": { - "version": "7.21.12", - "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.21.12.tgz", - "integrity": "sha512-FopGMGy5df+IMqUYg9w7ygwK0oP4RaQbMnpb7VFP30+vZdd8MqsAGc+bZZwdiVSnzK9hzhgJtZV9XF0ZFlUYOQ==", + "version": "7.22.1", + "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.22.1.tgz", + "integrity": "sha512-6JAaYM4u6RPtVMUjN5ovX5DNBdBZtDqebk7kEwlncUP6fP4P94+qEYv1bRmtxSFs7UxiZwQQs+jzTsBaW2WeyA==", "license": "MIT", "dependencies": { "@react-navigation/routers": "^7.6.4", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", - "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" @@ -14724,15 +14724,15 @@ } }, "node_modules/@react-navigation/core/node_modules/react-is": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", - "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz", + "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==", "license": "MIT" }, "node_modules/@react-navigation/devtools": { - "version": "7.0.52", - "resolved": "https://registry.npmjs.org/@react-navigation/devtools/-/devtools-7.0.52.tgz", - "integrity": "sha512-YqSr1c4USLCdTttltfwOXoPNHlEhnLmSSehGPt7j7yrXDXj55qRQA4aNF0p02paTN2phN17oBaBPcC2nSbqtfw==", + "version": "7.1.15", + "resolved": "https://registry.npmjs.org/@react-navigation/devtools/-/devtools-7.1.15.tgz", + "integrity": "sha512-NLwHCr4vcQ7hvUaFol+GyOje0oMCTMIRCITFVkcpST6auejSpwT3/obk8lI2b+k+B9J4vb7gKy+3inW1ZlqG/A==", "dev": true, "license": "MIT", "dependencies": { @@ -14745,9 +14745,9 @@ } }, "node_modules/@react-navigation/elements": { - "version": "2.9.14", - "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.14.tgz", - "integrity": "sha512-lKqzu+su2pI/YIZmR7L7xdOs4UL+rVXKJAMpRMBrwInEy96SjIFst6QDGpE89Dunnu3VjVpjWfByo9f2GWBHDQ==", + "version": "2.9.43", + "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.43.tgz", + "integrity": "sha512-XOYpRfgT9RPV2VIoXeEfTVdmXwEPpHwkezDkZVsVNEN6rTWDo07BPyLmWsvXooczyMf3jHe9AB2p/U9lJArtiQ==", "license": "MIT", "dependencies": { "color": "^4.2.3", @@ -14756,7 +14756,7 @@ }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", - "@react-navigation/native": "^7.2.2", + "@react-navigation/native": "^7.4.1", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" @@ -14768,17 +14768,17 @@ } }, "node_modules/@react-navigation/material-top-tabs": { - "version": "7.4.19", - "resolved": "https://registry.npmjs.org/@react-navigation/material-top-tabs/-/material-top-tabs-7.4.19.tgz", - "integrity": "sha512-jYGgItg51bjy3ypKeLm7sNEmGh0tFLn0VLV/p06iUprwKzuIvQESFin9M2+HUHa0pE1+g5tnf/ECKQJiAw712Q==", + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/@react-navigation/material-top-tabs/-/material-top-tabs-7.7.2.tgz", + "integrity": "sha512-8BhfaB4a2VWRnuzu9hwKomICtxT/O03q8/S+f210Dx1KZxj4KdynSueSTHul5eH+3GaYodmpRffMXinHDG2B2A==", "license": "MIT", "dependencies": { - "@react-navigation/elements": "^2.9.10", + "@react-navigation/elements": "^2.9.43", "color": "^4.2.3", - "react-native-tab-view": "^4.3.0" + "react-native-tab-view": "^4.3.2" }, "peerDependencies": { - "@react-navigation/native": "^7.1.33", + "@react-navigation/native": "^7.4.1", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", @@ -14786,15 +14786,16 @@ } }, "node_modules/@react-navigation/native": { - "version": "7.1.33", - "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.33.tgz", - "integrity": "sha512-DpFdWGcgLajKZ1TuIvDNQsblN2QaUFWpTQaB8v7WRP9Mix8H/6TFoIrZd93pbymI2hybd6UYrD+lI408eWVcfw==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.4.1.tgz", + "integrity": "sha512-pEnu5XzAXV6RWr6A6pmZ7qLB+O6TJWMhBO25AjckMdr596UWulMPHQCtBcnOUfg6K+2h9ZUctHF2E5Xu4vcOrQ==", "license": "MIT", "dependencies": { - "@react-navigation/core": "^7.16.1", + "@react-navigation/core": "^7.22.1", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", + "standard-navigation": "^0.0.8", "use-latest-callback": "^0.2.4" }, "peerDependencies": { @@ -14803,18 +14804,18 @@ } }, "node_modules/@react-navigation/native-stack": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.14.5.tgz", - "integrity": "sha512-NuyMf21kKk3jODvYgpcDA+HwyWr/KEj72ciqquyEupZlsmQ3WNUGgdaixEB3A19+iPOvHLQzDLcoTrrqZk8Leg==", + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.19.2.tgz", + "integrity": "sha512-D6hmtgpMZz1x8R0PLbfqqKh5ajEkb13DguE7X/MNNKYL4riY4zD3TrjbcesUC2EZklpQ/EoJ6h6HAhMNfCCsoQ==", "license": "MIT", "dependencies": { - "@react-navigation/elements": "^2.9.10", + "@react-navigation/elements": "^2.9.43", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0", "warn-once": "^0.1.1" }, "peerDependencies": { - "@react-navigation/native": "^7.1.33", + "@react-navigation/native": "^7.4.1", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", @@ -14831,17 +14832,17 @@ } }, "node_modules/@react-navigation/stack": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/@react-navigation/stack/-/stack-7.8.5.tgz", - "integrity": "sha512-ZOD1gUhWpbI+1PD5mKZFnLBh3Vfq2bqhO5/NeEruaQwNdXkiiHpi59OUKMnFRQURjjYXf/skTM9hJa6zHdiyFw==", + "version": "7.11.2", + "resolved": "https://registry.npmjs.org/@react-navigation/stack/-/stack-7.11.2.tgz", + "integrity": "sha512-uz5RtTCTmcSsH1Lwas20wE8puW/DCTuKjQ/UBKk2obp+dSPreTCcJZG2W6sTIaz6WBp1zcUZWsajhDMfH6+CvA==", "license": "MIT", "dependencies": { - "@react-navigation/elements": "^2.9.10", + "@react-navigation/elements": "^2.9.43", "color": "^4.2.3", "use-latest-callback": "^0.2.4" }, "peerDependencies": { - "@react-navigation/native": "^7.1.33", + "@react-navigation/native": "^7.4.1", "react": ">= 18.2.0", "react-native": "*", "react-native-gesture-handler": ">= 2.0.0", @@ -15698,9 +15699,9 @@ "license": "CC0-1.0" }, "node_modules/@rsbuild/plugin-svgr/node_modules/svgo": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.3.tgz", - "integrity": "sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.5.tgz", + "integrity": "sha512-8SQMzdrvWaD8deUmrnYB+ASyxBVgWUOilg+A75nE/76WdLpj6LopCwiAVvkzkcqy/9b7t2Mg7faFLjg0ZRcZ3w==", "dev": true, "license": "MIT", "dependencies": { @@ -16468,30 +16469,30 @@ } }, "node_modules/@sentry/browser": { - "version": "10.73.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.73.0.tgz", - "integrity": "sha512-HqTe1S5RrWLufhX2LaFP3yNoMxfNDroh120bq1zdGHZfFDBMJQ0CDXxHO+L4UJfQ5dWdCCzWbXIAiZuWGa/DFQ==", + "version": "10.75.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.75.0.tgz", + "integrity": "sha512-CKGSJLWISQ6LgMaGnMpY+XvwWQ2iiHTayUs6SGLIvYeaGo2GTjS/Z4pnbCVHb2t1VxLZksyehgcC6ZYGJeQpzQ==", "license": "MIT", "dependencies": { - "@sentry/browser-utils": "10.73.0", + "@sentry/browser-utils": "10.75.0", "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.73.0", - "@sentry/feedback": "10.73.0", - "@sentry/replay": "10.73.0", - "@sentry/replay-canvas": "10.73.0" + "@sentry/core": "10.75.0", + "@sentry/feedback": "10.75.0", + "@sentry/replay": "10.75.0", + "@sentry/replay-canvas": "10.75.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry/browser-utils": { - "version": "10.73.0", - "resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.73.0.tgz", - "integrity": "sha512-qQygxJZ+RV779+iL1+lrJ4f4sZLgbgW0/JWPNp0YlcEAE62yCsdKbqoTEjB/EugdS4mSjBMX0chZC6rblu2Ycw==", + "version": "10.75.0", + "resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.75.0.tgz", + "integrity": "sha512-yZfdQ87RLat/7retF8zA6Z9zCAT12IyaPvIiu+bbi1E1V1OqDpS8MKNa/6LF69Ap7DVTORi9ITEgjlAqTXF0xQ==", "license": "MIT", "dependencies": { "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.73.0" + "@sentry/core": "10.75.0" }, "engines": { "node": ">=18" @@ -16957,9 +16958,9 @@ } }, "node_modules/@sentry/core": { - "version": "10.73.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.73.0.tgz", - "integrity": "sha512-FLO1UgH19RyasVpofu612WCOgb2nEH0dZy+R72d7p65XU9i0wxlMKm3+sgfwKmiSJp1Qhilaaxs4Jg6BbiM5HA==", + "version": "10.75.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.75.0.tgz", + "integrity": "sha512-5wDQpQqjJ6RHdPR+z6Q+47XlwoxRKBv0yyB0LKHqL/1azPOQbR+nllyLmmQDcoJpaksuLSmLA1q6hFX7gjDT2w==", "license": "MIT", "dependencies": { "@sentry/conventions": "^0.16.0" @@ -16969,12 +16970,12 @@ } }, "node_modules/@sentry/expo-upload-sourcemaps": { - "version": "8.25.0", - "resolved": "https://registry.npmjs.org/@sentry/expo-upload-sourcemaps/-/expo-upload-sourcemaps-8.25.0.tgz", - "integrity": "sha512-eiAXxyO20iHiwIWNQ67MMa8arNn1/XM65+8RvDrv3/tBxFnWvfcBX0vjqU/oWERUYANfZiQ/tjDjo9aMov8C3Q==", + "version": "8.27.0", + "resolved": "https://registry.npmjs.org/@sentry/expo-upload-sourcemaps/-/expo-upload-sourcemaps-8.27.0.tgz", + "integrity": "sha512-o90k7e0AQVJlvbWIPoFcAVXIxM38rsJfp3thHq6oqQCY06Mp0Yhw4SHRPl+fgpg18O09jiTyVWnepCkmJzsVjg==", "license": "MIT", "dependencies": { - "@sentry/cli": "3.7.0" + "@sentry/cli": "3.8.0" }, "bin": { "expo-upload-sourcemaps": "cli.js" @@ -16996,9 +16997,9 @@ } }, "node_modules/@sentry/expo-upload-sourcemaps/node_modules/@sentry/cli": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-3.7.0.tgz", - "integrity": "sha512-UV6/hUaEL7X1ATx5RHPfDEObh+nTRwOeUkNwF6R+HDo6iAki063GiLOmkfroemkVD0L4370SSvGtc5+Y7MZkhQ==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-3.8.0.tgz", + "integrity": "sha512-P/Zq0QEXnBbNX8oxd1maHKD06oAH5fxHsxt8QjEzi2J14w4hvwUi6FcMDf7eyuUkdQs8+OvOKeq/9pLuZxVVxQ==", "hasInstallScript": true, "license": "FSL-1.1-MIT", "dependencies": { @@ -17014,20 +17015,20 @@ "node": ">= 18" }, "optionalDependencies": { - "@sentry/cli-darwin": "3.7.0", - "@sentry/cli-linux-arm": "3.7.0", - "@sentry/cli-linux-arm64": "3.7.0", - "@sentry/cli-linux-i686": "3.7.0", - "@sentry/cli-linux-x64": "3.7.0", - "@sentry/cli-win32-arm64": "3.7.0", - "@sentry/cli-win32-i686": "3.7.0", - "@sentry/cli-win32-x64": "3.7.0" + "@sentry/cli-darwin": "3.8.0", + "@sentry/cli-linux-arm": "3.8.0", + "@sentry/cli-linux-arm64": "3.8.0", + "@sentry/cli-linux-i686": "3.8.0", + "@sentry/cli-linux-x64": "3.8.0", + "@sentry/cli-win32-arm64": "3.8.0", + "@sentry/cli-win32-i686": "3.8.0", + "@sentry/cli-win32-x64": "3.8.0" } }, "node_modules/@sentry/expo-upload-sourcemaps/node_modules/@sentry/cli-darwin": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-3.7.0.tgz", - "integrity": "sha512-Kb6Oem+6lfxkWSH8iz8DHwYmbMtYxFoTk3VHfeTkpz60TRj+0OPga/0ZEsCCgG/Df17E7z2kbkoe5Ld+dEj3Tw==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-3.8.0.tgz", + "integrity": "sha512-i+qTGaHqL6LaIfMYuEohExJyzZwvRRsiQm+vrol/WfFGvqogeijXAU2wXn3xBglszUK/ia1xWH4KxcnqNLOpdQ==", "license": "FSL-1.1-MIT", "optional": true, "os": [ @@ -17038,9 +17039,9 @@ } }, "node_modules/@sentry/expo-upload-sourcemaps/node_modules/@sentry/cli-linux-arm": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-3.7.0.tgz", - "integrity": "sha512-PKEFwwRCOG92u2dXusLbRK4cZ7XXlaUY8TkhaZy/PJ/FNsST7Sh+MbdWQCgPB0FsvZrgImcKIEyCJZtc0GunJg==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-3.8.0.tgz", + "integrity": "sha512-wcpKNI22usaf7ZIHLaWofHVN5wOlmTdVjnQO5pYqDZNHRvhbO7HwZfLghR/p7aLuhETtePIFhcgjWlyU4uvWZQ==", "cpu": [ "arm" ], @@ -17056,9 +17057,9 @@ } }, "node_modules/@sentry/expo-upload-sourcemaps/node_modules/@sentry/cli-linux-arm64": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-3.7.0.tgz", - "integrity": "sha512-m6VlsDJBo9/9ufASv4m4heKS0zfgUJdcSvKgIQerZaLwKOfhY8OP7zhlJPkFmnFjOjyvNyaqzoR00EnlprdwFw==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-3.8.0.tgz", + "integrity": "sha512-CS43j4vbLJBoB4c5MRZS7/nCf62yCnPIMQj7N+ZC50ze6EM8efEy4jq1ZZjz4G9u6AKuo6sntuaOlOGdZL5l6g==", "cpu": [ "arm64" ], @@ -17074,9 +17075,9 @@ } }, "node_modules/@sentry/expo-upload-sourcemaps/node_modules/@sentry/cli-linux-i686": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-3.7.0.tgz", - "integrity": "sha512-6NeOPqsPuyrOgVGPFunPxgqHCes+a8r6pbCgq9+qSD3oa5fk9H0KOKB0sIC0YyH43OGzdzmwn0XNbdF6jwJO6A==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-3.8.0.tgz", + "integrity": "sha512-ckQ5XeY+D5ISyFKrGnmYt5xarqQ1VHPMBJQnwnG0Hb1V116efAIusK5NqoHMP0o5Bu17BRlaxYA1l81HmvKqFQ==", "cpu": [ "x86", "ia32" @@ -17093,9 +17094,9 @@ } }, "node_modules/@sentry/expo-upload-sourcemaps/node_modules/@sentry/cli-linux-x64": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-3.7.0.tgz", - "integrity": "sha512-74R9fuAldhC38Aoco8XQWTBvs2T2NatrDvI/ISHihej2oSnbQmL7krJF3yWn33MchQWrsFKgg7mboqemJnreZA==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-3.8.0.tgz", + "integrity": "sha512-jMXbyLIuGFW1uuFSByy54edkHPIaiCVyymKW0ia+lgA6nfFEH4Y1Z+bxXyK6BBr8TD6/P5ROqiPivg0oFEDciQ==", "cpu": [ "x64" ], @@ -17111,9 +17112,9 @@ } }, "node_modules/@sentry/expo-upload-sourcemaps/node_modules/@sentry/cli-win32-arm64": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-3.7.0.tgz", - "integrity": "sha512-xhmTUW3N3m8iZ4mkV8SrNOr5UN8+6zpvlvJxCK6Z15Tp8S14N1T+hfKeyL2xpLxBcd7Q7oW1dYXUl5b7BHNqjw==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-3.8.0.tgz", + "integrity": "sha512-AtfvaIZ9hK/l2Q2It6PLTtLlQTRe7179kZVi++qd98iE7Ix4ysdgshhozW5uUu4gI9kauJC/fhe21p9lr0uwlA==", "cpu": [ "arm64" ], @@ -17127,9 +17128,9 @@ } }, "node_modules/@sentry/expo-upload-sourcemaps/node_modules/@sentry/cli-win32-i686": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-3.7.0.tgz", - "integrity": "sha512-/CvFzrCWUB0URWKYQeoNs8UbPJcBz6KD/AX27reyQxPd8S3AnTwcaYfCf8SRHEH9Iu+1IKzi5tUUNUB18b7ijg==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-3.8.0.tgz", + "integrity": "sha512-lIbGRYAM4DZPzwLiqOsT/vwhOr0o1qqx46buxdMnH05KZAYzMtGDbK0X9iGdhWQLOOp1/2vuUNVgMgCzSDyHXA==", "cpu": [ "x86", "ia32" @@ -17144,9 +17145,9 @@ } }, "node_modules/@sentry/expo-upload-sourcemaps/node_modules/@sentry/cli-win32-x64": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-3.7.0.tgz", - "integrity": "sha512-PNW1O6sZw3JXhhJFgOiW7isFHX/qbniIDxswM7kb0wF2aVUpLXLltbezchWwTAxIRXo9gcu/oMTK0rntdqL5Eg==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-3.8.0.tgz", + "integrity": "sha512-PJk9uYZsVnUcf7BfeJZcmgRIymRWojIVYCx3704VlOr65JFP5KmeKBVgHzfwPqLfjZaeerL81NO0fG3recDjqA==", "cpu": [ "x64" ], @@ -17160,26 +17161,26 @@ } }, "node_modules/@sentry/feedback": { - "version": "10.73.0", - "resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.73.0.tgz", - "integrity": "sha512-D6nSngX+e46Mae2/oh2bxBvxNK1z2NERbuMAhB5sx9x4xMBWIyGnYYTECehvEqV9+AqGAgxxhOZoYIG3AmRwww==", + "version": "10.75.0", + "resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.75.0.tgz", + "integrity": "sha512-YLVAFQXELJAOg7ke4oLkd8t5l0fJUockLlTQJg/2Trh7SB5Q4mkSwlnAkEAqDQ618vnpFsG/tnYMwtfZeffHjA==", "license": "MIT", "dependencies": { - "@sentry/core": "10.73.0" + "@sentry/core": "10.75.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry/react": { - "version": "10.73.0", - "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.73.0.tgz", - "integrity": "sha512-wJrzS98ddPvhGS/MKNHZyE8X7ecd4KwKdz7fHino2qkqBBrF4cxWr+q/uLTIk5PYWMkeJ4oKMjHAjOqmzGR4tg==", + "version": "10.75.0", + "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.75.0.tgz", + "integrity": "sha512-ghWv99h7k4WgMucqBhf4jZn+G0Owm5gCBCVKOKu88UDCWUd9aVJ8FGosAkATp/x/ogcZTnqG2dcXBD+1U+BJeA==", "license": "MIT", "dependencies": { - "@sentry/browser": "10.73.0", + "@sentry/browser": "10.75.0", "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.73.0" + "@sentry/core": "10.75.0" }, "engines": { "node": ">=18" @@ -17189,17 +17190,17 @@ } }, "node_modules/@sentry/react-native": { - "version": "8.25.0", - "resolved": "https://registry.npmjs.org/@sentry/react-native/-/react-native-8.25.0.tgz", - "integrity": "sha512-iYsa32WfiZN8JDaSxT1n0YwFUqvmAOWSRQaOonpxg7kkC77EQP1bOIkfWQvIfXbhrhwmRPTR8jhSQcLw0c/DDQ==", + "version": "8.27.0", + "resolved": "https://registry.npmjs.org/@sentry/react-native/-/react-native-8.27.0.tgz", + "integrity": "sha512-tlxjhqWe43B9bMRqn/lPrayXgsFBhxVjQn6kyXZ09DY0j0Y76tTiy5hkdG48RwCcBcv9SMZAtaiS2M9eTLyH3w==", "license": "MIT", "dependencies": { - "@sentry/browser": "10.73.0", - "@sentry/bundler-plugins": "10.73.0", - "@sentry/cli": "3.7.0", - "@sentry/core": "10.73.0", - "@sentry/expo-upload-sourcemaps": "8.25.0", - "@sentry/react": "10.73.0" + "@sentry/browser": "10.75.0", + "@sentry/bundler-plugins": "10.75.0", + "@sentry/cli": "3.8.0", + "@sentry/core": "10.75.0", + "@sentry/expo-upload-sourcemaps": "8.27.0", + "@sentry/react": "10.75.0" }, "bin": { "sentry-eas-build-on-complete": "scripts/eas-build-hook.js", @@ -17219,14 +17220,14 @@ } }, "node_modules/@sentry/react-native/node_modules/@sentry/bundler-plugins": { - "version": "10.73.0", - "resolved": "https://registry.npmjs.org/@sentry/bundler-plugins/-/bundler-plugins-10.73.0.tgz", - "integrity": "sha512-4X5m2hoqgKO6SxHR7MF9QnvxPNk0hwTJFixDJ/pzQGVM+C34j/rSabouBqd0M+ZdxFeAt/9kA5X0TyeceNo9YQ==", + "version": "10.75.0", + "resolved": "https://registry.npmjs.org/@sentry/bundler-plugins/-/bundler-plugins-10.75.0.tgz", + "integrity": "sha512-kpMO7NhvfqsobpShyW/YnNJmJpw7mPEAQorJnEzf0ZRqNvEGFjKKN19SZWbbBNdcQDvHPlRIVOC00zdXuw3y/w==", "license": "MIT", "dependencies": { "@babel/core": "^7.18.5", "@sentry/cli": "^2.58.6", - "@sentry/core": "10.73.0", + "@sentry/core": "10.75.0", "dotenv": "^17.4.2", "find-up": "^5.0.0", "glob": "^13.0.6", @@ -17414,9 +17415,9 @@ } }, "node_modules/@sentry/react-native/node_modules/@sentry/cli": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-3.7.0.tgz", - "integrity": "sha512-UV6/hUaEL7X1ATx5RHPfDEObh+nTRwOeUkNwF6R+HDo6iAki063GiLOmkfroemkVD0L4370SSvGtc5+Y7MZkhQ==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-3.8.0.tgz", + "integrity": "sha512-P/Zq0QEXnBbNX8oxd1maHKD06oAH5fxHsxt8QjEzi2J14w4hvwUi6FcMDf7eyuUkdQs8+OvOKeq/9pLuZxVVxQ==", "hasInstallScript": true, "license": "FSL-1.1-MIT", "dependencies": { @@ -17432,20 +17433,20 @@ "node": ">= 18" }, "optionalDependencies": { - "@sentry/cli-darwin": "3.7.0", - "@sentry/cli-linux-arm": "3.7.0", - "@sentry/cli-linux-arm64": "3.7.0", - "@sentry/cli-linux-i686": "3.7.0", - "@sentry/cli-linux-x64": "3.7.0", - "@sentry/cli-win32-arm64": "3.7.0", - "@sentry/cli-win32-i686": "3.7.0", - "@sentry/cli-win32-x64": "3.7.0" + "@sentry/cli-darwin": "3.8.0", + "@sentry/cli-linux-arm": "3.8.0", + "@sentry/cli-linux-arm64": "3.8.0", + "@sentry/cli-linux-i686": "3.8.0", + "@sentry/cli-linux-x64": "3.8.0", + "@sentry/cli-win32-arm64": "3.8.0", + "@sentry/cli-win32-i686": "3.8.0", + "@sentry/cli-win32-x64": "3.8.0" } }, "node_modules/@sentry/react-native/node_modules/@sentry/cli-darwin": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-3.7.0.tgz", - "integrity": "sha512-Kb6Oem+6lfxkWSH8iz8DHwYmbMtYxFoTk3VHfeTkpz60TRj+0OPga/0ZEsCCgG/Df17E7z2kbkoe5Ld+dEj3Tw==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-3.8.0.tgz", + "integrity": "sha512-i+qTGaHqL6LaIfMYuEohExJyzZwvRRsiQm+vrol/WfFGvqogeijXAU2wXn3xBglszUK/ia1xWH4KxcnqNLOpdQ==", "license": "FSL-1.1-MIT", "optional": true, "os": [ @@ -17456,9 +17457,9 @@ } }, "node_modules/@sentry/react-native/node_modules/@sentry/cli-linux-arm": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-3.7.0.tgz", - "integrity": "sha512-PKEFwwRCOG92u2dXusLbRK4cZ7XXlaUY8TkhaZy/PJ/FNsST7Sh+MbdWQCgPB0FsvZrgImcKIEyCJZtc0GunJg==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-3.8.0.tgz", + "integrity": "sha512-wcpKNI22usaf7ZIHLaWofHVN5wOlmTdVjnQO5pYqDZNHRvhbO7HwZfLghR/p7aLuhETtePIFhcgjWlyU4uvWZQ==", "cpu": [ "arm" ], @@ -17474,9 +17475,9 @@ } }, "node_modules/@sentry/react-native/node_modules/@sentry/cli-linux-arm64": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-3.7.0.tgz", - "integrity": "sha512-m6VlsDJBo9/9ufASv4m4heKS0zfgUJdcSvKgIQerZaLwKOfhY8OP7zhlJPkFmnFjOjyvNyaqzoR00EnlprdwFw==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-3.8.0.tgz", + "integrity": "sha512-CS43j4vbLJBoB4c5MRZS7/nCf62yCnPIMQj7N+ZC50ze6EM8efEy4jq1ZZjz4G9u6AKuo6sntuaOlOGdZL5l6g==", "cpu": [ "arm64" ], @@ -17492,9 +17493,9 @@ } }, "node_modules/@sentry/react-native/node_modules/@sentry/cli-linux-i686": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-3.7.0.tgz", - "integrity": "sha512-6NeOPqsPuyrOgVGPFunPxgqHCes+a8r6pbCgq9+qSD3oa5fk9H0KOKB0sIC0YyH43OGzdzmwn0XNbdF6jwJO6A==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-3.8.0.tgz", + "integrity": "sha512-ckQ5XeY+D5ISyFKrGnmYt5xarqQ1VHPMBJQnwnG0Hb1V116efAIusK5NqoHMP0o5Bu17BRlaxYA1l81HmvKqFQ==", "cpu": [ "x86", "ia32" @@ -17511,9 +17512,9 @@ } }, "node_modules/@sentry/react-native/node_modules/@sentry/cli-linux-x64": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-3.7.0.tgz", - "integrity": "sha512-74R9fuAldhC38Aoco8XQWTBvs2T2NatrDvI/ISHihej2oSnbQmL7krJF3yWn33MchQWrsFKgg7mboqemJnreZA==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-3.8.0.tgz", + "integrity": "sha512-jMXbyLIuGFW1uuFSByy54edkHPIaiCVyymKW0ia+lgA6nfFEH4Y1Z+bxXyK6BBr8TD6/P5ROqiPivg0oFEDciQ==", "cpu": [ "x64" ], @@ -17529,9 +17530,9 @@ } }, "node_modules/@sentry/react-native/node_modules/@sentry/cli-win32-arm64": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-3.7.0.tgz", - "integrity": "sha512-xhmTUW3N3m8iZ4mkV8SrNOr5UN8+6zpvlvJxCK6Z15Tp8S14N1T+hfKeyL2xpLxBcd7Q7oW1dYXUl5b7BHNqjw==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-3.8.0.tgz", + "integrity": "sha512-AtfvaIZ9hK/l2Q2It6PLTtLlQTRe7179kZVi++qd98iE7Ix4ysdgshhozW5uUu4gI9kauJC/fhe21p9lr0uwlA==", "cpu": [ "arm64" ], @@ -17545,9 +17546,9 @@ } }, "node_modules/@sentry/react-native/node_modules/@sentry/cli-win32-i686": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-3.7.0.tgz", - "integrity": "sha512-/CvFzrCWUB0URWKYQeoNs8UbPJcBz6KD/AX27reyQxPd8S3AnTwcaYfCf8SRHEH9Iu+1IKzi5tUUNUB18b7ijg==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-3.8.0.tgz", + "integrity": "sha512-lIbGRYAM4DZPzwLiqOsT/vwhOr0o1qqx46buxdMnH05KZAYzMtGDbK0X9iGdhWQLOOp1/2vuUNVgMgCzSDyHXA==", "cpu": [ "x86", "ia32" @@ -17562,9 +17563,9 @@ } }, "node_modules/@sentry/react-native/node_modules/@sentry/cli-win32-x64": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-3.7.0.tgz", - "integrity": "sha512-PNW1O6sZw3JXhhJFgOiW7isFHX/qbniIDxswM7kb0wF2aVUpLXLltbezchWwTAxIRXo9gcu/oMTK0rntdqL5Eg==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-3.8.0.tgz", + "integrity": "sha512-PJk9uYZsVnUcf7BfeJZcmgRIymRWojIVYCx3704VlOr65JFP5KmeKBVgHzfwPqLfjZaeerL81NO0fG3recDjqA==", "cpu": [ "x64" ], @@ -17628,9 +17629,9 @@ } }, "node_modules/@sentry/react-native/node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -17668,26 +17669,26 @@ } }, "node_modules/@sentry/replay": { - "version": "10.73.0", - "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.73.0.tgz", - "integrity": "sha512-nN2wjN/Y0J5BOJV5hqRHUEBfxwUsipp1PKjcDHh6Fpxnrtfldu3Y99E8cQInseo5heFdzEvrOHBBrqWZXOHVKQ==", + "version": "10.75.0", + "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.75.0.tgz", + "integrity": "sha512-p4xxEo/ayiMZG9tygr09YjqYAd52uq8Qi0EVoRRgUe70nQda0HVPVW519JLq0G+Th/5O5sKME9y1layLU3xlPg==", "license": "MIT", "dependencies": { - "@sentry/browser-utils": "10.73.0", - "@sentry/core": "10.73.0" + "@sentry/browser-utils": "10.75.0", + "@sentry/core": "10.75.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry/replay-canvas": { - "version": "10.73.0", - "resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.73.0.tgz", - "integrity": "sha512-sxa2lKkHPfF/j5xFpW7gocthWXRqyoHz8KCPy6yGc8plT477nl57iGSKhQDYsx1Ny10TLjs7YkIuPP1GY/Ax2Q==", + "version": "10.75.0", + "resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.75.0.tgz", + "integrity": "sha512-LNokI5Ud22nZ/o2il/2SfbNIARupWrbS6Mh9jZroZTjD38FN0Oi9vIexj6h1HN81C6+lP2Tmpaj+fl3/wOizzA==", "license": "MIT", "dependencies": { - "@sentry/core": "10.73.0", - "@sentry/replay": "10.73.0" + "@sentry/core": "10.75.0", + "@sentry/replay": "10.75.0" }, "engines": { "node": ">=18" @@ -17780,13 +17781,13 @@ "license": "MIT" }, "node_modules/@simple-git/argv-parser": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@simple-git/argv-parser/-/argv-parser-1.1.1.tgz", - "integrity": "sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@simple-git/argv-parser/-/argv-parser-2.0.1.tgz", + "integrity": "sha512-M++IaVWrN+vYalilOHfwvT2IL3wYZtVEBQpmX6DLSOJLtxbZ+SUeIDE3IiPogzhPACLajwtxfa5rp+ZX1FczzQ==", "dev": true, "license": "MIT", "dependencies": { - "@simple-git/args-pathspec": "^1.0.3" + "@simple-git/args-pathspec": "^1.0.4" } }, "node_modules/@sinclair/typebox": { @@ -20990,9 +20991,9 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.8.13", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", - "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -24507,15 +24508,6 @@ "dev": true, "license": "MIT" }, - "node_modules/decode-uri-component": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", - "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", - "license": "MIT", - "engines": { - "node": ">=0.10" - } - }, "node_modules/dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", @@ -26412,9 +26404,9 @@ } }, "node_modules/expensify-common": { - "version": "2.0.207", - "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.207.tgz", - "integrity": "sha512-AHrgp9LZkS9z/37G5ZbKbIwa9GqSCPVwfwilS1yhSQmFyL6Jb/eDxlopMfthaTbXO0NHTPRH2UmImlUtdlNYvw==", + "version": "2.0.211", + "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.211.tgz", + "integrity": "sha512-BMFDPN4UJrvfESDViMMt5p/JA8JXWfBpCYshqy+v6hhxgZ5D5oGcko3mLPi4eoNVD1M+etkIY4pQJIKb68D3hw==", "license": "MIT", "dependencies": { "awesome-phonenumber": "^5.4.0", @@ -26755,13 +26747,13 @@ } }, "node_modules/expo-modules-core": { - "version": "57.0.7", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.7.tgz", - "integrity": "sha512-5HrbCfgYmLs0a5dzfM4GmRGelVTPIg+eYp0vmSbWnKRTOPj5DyVOfg1rHGEsuNKp07QwDxfLJfQVp8CNbuvxMQ==", + "version": "57.0.15", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.15.tgz", + "integrity": "sha512-HBxPXsx3eVLLRjAY57/8ghKDWIxjaEbluKKYFIq1OHPJC85ZfHsawwc0SOU4Z/qu+mdLz7Pwt5IkkWimeNSH5A==", "license": "MIT", "dependencies": { "@expo/expo-modules-macros-plugin": "0.6.1", - "expo-modules-jsi": "~57.0.4", + "expo-modules-jsi": "~57.0.7", "invariant": "^2.2.4" }, "peerDependencies": { @@ -26776,9 +26768,9 @@ } }, "node_modules/expo-modules-jsi": { - "version": "57.0.4", - "resolved": "https://registry.npmjs.org/expo-modules-jsi/-/expo-modules-jsi-57.0.4.tgz", - "integrity": "sha512-vt7FyqUqqFXiRVnBqYD7y+GSPTgeua5Ocoy0+SYt+RSHkZEA2Fyop7If3g1TYDzQObYybPRo7TG2Rle1XLaWFw==", + "version": "57.0.8", + "resolved": "https://registry.npmjs.org/expo-modules-jsi/-/expo-modules-jsi-57.0.8.tgz", + "integrity": "sha512-MFwvKP0FXLkEET27KsKvOh/L7Dpu2d00UgzAlmKozJgtI23WTKSyKPxu+EOSs9peSgkHzKI0vQOhWjOseGBMkQ==", "license": "MIT", "peerDependencies": { "react-native": "*" @@ -27522,15 +27514,6 @@ "node": ">=8" } }, - "node_modules/filter-obj": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", - "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/find-babel-config": { "version": "2.0.0", "dev": true, @@ -36083,9 +36066,9 @@ } }, "node_modules/plist/node_modules/@xmldom/xmldom": { - "version": "0.9.10", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", - "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", + "version": "0.9.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.12.tgz", + "integrity": "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==", "license": "MIT", "engines": { "node": ">=14.6" @@ -36413,24 +36396,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/query-string": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", - "integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==", - "license": "MIT", - "dependencies": { - "decode-uri-component": "^0.2.2", - "filter-obj": "^1.1.0", - "split-on-first": "^1.0.0", - "strict-uri-encode": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -37522,9 +37487,9 @@ } }, "node_modules/react-native-reanimated": { - "version": "4.5.5", - "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.5.tgz", - "integrity": "sha512-xtJXZRZ1vkec1AIUVG02St2sbyZS5jc7227TlDu/HonqUYPeZAfiC4RI2jqL1dVMNBu1+7oBx/c9QkjF2kg6Lg==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.7.0.tgz", + "integrity": "sha512-8TyTrPisqz7WzmbhCz8F60Uw7NI2QeYWrY5Hk58/kurCA5L5KuHmhyQjxeq5k2YalpXUAKbdkn/mAMZWm/fU0g==", "license": "MIT", "dependencies": { "react-native-is-edge-to-edge": "^1.3.1", @@ -37532,8 +37497,8 @@ }, "peerDependencies": { "react": "*", - "react-native": "0.83 - 0.86", - "react-native-worklets": "0.10.x - 0.11.x" + "react-native": "0.86 - 0.88", + "react-native-worklets": "0.13.x" } }, "node_modules/react-native-reanimated/node_modules/semver": { @@ -37705,9 +37670,9 @@ } }, "node_modules/react-native-tab-view": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/react-native-tab-view/-/react-native-tab-view-4.3.0.tgz", - "integrity": "sha512-qPMF75uz/7+MuVG2g+YETdGMzlWZnhC6iI4h/7EBbwIBwNBIBi2z4OA6KhY3IOOBwGHXEIz5IyA6doDqifYBHg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/react-native-tab-view/-/react-native-tab-view-4.3.2.tgz", + "integrity": "sha512-QCHX9bTGMeME5pt4jJ1/H7oshX7Z2nA/Ts3m7FpKUbWfPPIL8rlNLZwkedgV273Eqau8jj8deRtfSZl9LCctCw==", "license": "MIT", "dependencies": { "use-latest-callback": "^0.2.4" @@ -37818,9 +37783,9 @@ } }, "node_modules/react-native-worklets": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.11.1.tgz", - "integrity": "sha512-e+deeFdC45RrQcRh0bYr0fbaaXSB6+mGTX4xlEzT76PCvNJEOmu/5aDbT7gpd9AMFAGhkO2b0eoDBErWuEvZ8Q==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.13.0.tgz", + "integrity": "sha512-1OrhAeAKaZF4XrBa4YbPQbKzNOpjh52HDlId8e6ete1OCHNdAlrQHzG4gznJ225EAj3dgQXHViJvK51M9B52jQ==", "license": "MIT", "dependencies": { "@babel/generator": "^7.27.1", @@ -37835,14 +37800,16 @@ "@babel/preset-typescript": "^7.28.5", "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1", + "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", - "semver": "^7.7.4" + "semver": "^7.7.4", + "whatwg-fetch": "^3.6.20" }, "peerDependencies": { "@babel/core": "*", "@react-native/metro-config": "*", "react": "*", - "react-native": "0.83 - 0.86" + "react-native": "0.86 - 0.88" } }, "node_modules/react-native-worklets/node_modules/semver": { @@ -39601,15 +39568,6 @@ "integrity": "sha512-D50hKrjZgBzqD3FT2Ek53f2dcDLAQT8SSGrzj3vidNH5ISRgceeGVJ2dQIthKOuayqFXfFjXheHNo4bbt9LhRQ==", "license": "MIT" }, - "node_modules/split-on-first": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", - "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/split-string": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", @@ -39767,6 +39725,15 @@ "node": ">=8" } }, + "node_modules/standard-navigation": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/standard-navigation/-/standard-navigation-0.0.8.tgz", + "integrity": "sha512-TyVbo7INUDWtsUWDFn8RR7kwR87U0S4xHfLfbbnyeC581TmmyqQ+eM+nPw8rQTSD8QitRVcYfPaSHr/QJiUy1g==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/statuses": { "version": "2.0.1", "license": "MIT", @@ -40511,15 +40478,6 @@ "node": ">= 0.10.0" } }, - "node_modules/strict-uri-encode": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", - "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/string_decoder": { "version": "1.1.1", "devOptional": true, @@ -40897,19 +40855,19 @@ "license": "MIT" }, "node_modules/svgo": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.2.tgz", - "integrity": "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.1.0.tgz", + "integrity": "sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q==", "dev": true, "license": "MIT", "dependencies": { "commander": "^11.1.0", - "css-select": "^5.1.0", + "css-select": "^6.0.0", "css-tree": "^3.0.1", - "css-what": "^6.1.0", + "css-what": "^7.0.0", "csso": "^5.0.5", "picocolors": "^1.1.1", - "sax": "^1.5.0" + "sax": "1.6.1" }, "bin": { "svgo": "bin/svgo.js" @@ -40932,6 +40890,23 @@ "node": ">=16" } }, + "node_modules/svgo/node_modules/css-select": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz", + "integrity": "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^7.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "nth-check": "^2.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/svgo/node_modules/css-tree": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", @@ -40946,6 +40921,62 @@ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, + "node_modules/svgo/node_modules/css-what": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz", + "integrity": "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/svgo/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/svgo/node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/svgo/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/svgo/node_modules/mdn-data": { "version": "2.12.2", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", @@ -40953,6 +40984,16 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/svgo/node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -42425,7 +42466,9 @@ } }, "node_modules/whatwg-fetch": { - "version": "3.6.2", + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", "license": "MIT" }, "node_modules/whatwg-url": { diff --git a/package.json b/package.json index 03e08bbdee9f..8e35b35275e1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.5.6-5", + "version": "9.5.7-3", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -96,7 +96,7 @@ "@expensify/nitro-utils": "file:./modules/ExpensifyNitroUtils", "@expensify/react-native-background-task": "file:./modules/background-task", "@expensify/react-native-hybrid-app": "file:./modules/hybrid-app", - "@expensify/react-native-live-markdown": "0.1.342", + "@expensify/react-native-live-markdown": "0.1.343", "@expensify/react-native-wallet": "0.1.22", "@expo/metro-config": "57.0.7", "@formatjs/intl-listformat": "^7.5.7", @@ -119,17 +119,17 @@ "@react-native-firebase/app": "^22.2.1", "@react-native-google-signin/google-signin": "^10.0.1", "@react-native-picker/picker": "2.11.3", - "@react-navigation/bottom-tabs": "7.15.5", - "@react-navigation/core": "7.21.12", - "@react-navigation/material-top-tabs": "7.4.19", - "@react-navigation/native": "7.1.33", - "@react-navigation/native-stack": "7.14.5", - "@react-navigation/stack": "7.8.5", + "@react-navigation/bottom-tabs": "7.19.2", + "@react-navigation/core": "7.22.1", + "@react-navigation/material-top-tabs": "7.7.2", + "@react-navigation/native": "7.4.1", + "@react-navigation/native-stack": "7.19.2", + "@react-navigation/stack": "7.11.2", "@react-ng/bounds-observer": "^0.2.1", "@rnmapbox/maps": "10.3.2", "@sbaiahmed1/react-native-biometrics": "0.15.0", - "@sentry/core": "10.73.0", - "@sentry/react-native": "8.25.0", + "@sentry/core": "10.75.0", + "@sentry/react-native": "8.27.0", "@shopify/flash-list": "2.3.2", "@shopify/react-native-skia": "^2.11.2", "@ua/react-native-airship": "26.7.0", @@ -142,7 +142,7 @@ "date-fns-tz": "^3.2.0", "dom-serializer": "^0.2.2", "domhandler": "^5.0.3", - "expensify-common": "2.0.207", + "expensify-common": "2.0.211", "expo": "57.0.8", "expo-asset": "57.0.7", "expo-audio": "57.0.3", @@ -150,7 +150,7 @@ "expo-image": "57.0.1", "expo-image-manipulator": "57.0.6", "expo-location": "57.0.6", - "expo-modules-core": "57.0.7", + "expo-modules-core": "57.0.15", "expo-store-review": "57.0.1", "expo-task-manager": "57.0.6", "expo-video": "57.0.2", @@ -169,6 +169,7 @@ "mapbox-gl": "^3.24.0", "metro": "0.84.5", "metro-transform-plugins": "0.84.5", + "mime-db": "1.52.0", "onfido-sdk-ui": "14.53.1", "pako": "^2.1.0", "process": "^0.11.10", @@ -210,7 +211,7 @@ "react-native-qrcode-svg": "6.3.21", "react-native-quick-base64": "^3.0.1", "react-native-quick-crypto": "^1.1.6", - "react-native-reanimated": "4.5.5", + "react-native-reanimated": "4.7.0", "react-native-render-html": "6.3.1", "react-native-safe-area-context": "5.9.1", "react-native-screens": "4.28.0", @@ -222,7 +223,7 @@ "react-native-vision-camera": "^4.7.2", "react-native-web": "0.21.2", "react-native-webview": "16.0.0", - "react-native-worklets": "0.11.1", + "react-native-worklets": "0.13.0", "react-plaid-link": "3.3.2", "react-web-config": "^1.0.0", "react-webcam": "^7.1.1", @@ -262,7 +263,7 @@ "@react-native-community/cli-platform-ios": "20.1.0", "@react-native/babel-preset": "0.86.0", "@react-native/metro-config": "0.86.0", - "@react-navigation/devtools": "7.0.52", + "@react-navigation/devtools": "7.1.15", "@rock-js/platform-android": "0.13.4", "@rock-js/platform-ios": "0.13.4", "@rock-js/plugin-metro": "0.13.4", @@ -362,7 +363,7 @@ "source-map": "^0.7.4", "storybook": "10.4.6", "storybook-react-rsbuild": "^3.3.4", - "svgo": "^4.0.2", + "svgo": "^4.1.0", "type-fest": "4.35.0", "typescript": "^7.0.2", "typescript-eslint": "8.58.1", @@ -400,6 +401,7 @@ "send": "0.19.0", "regexpu-core": "6.4.0", "adm-zip": "0.6.1", + "@simple-git/argv-parser": "2.0.1", "babel-plugin-react-compiler": "0.0.0-experimental-a1856f3-20260507", "react": "19.2.3", "react-dom": "19.2.3", @@ -430,7 +432,7 @@ "expo": "57.0.8" }, "expo-modules-core": { - "react-native-worklets": "0.11.1" + "react-native-worklets": "0.13.0" } }, "expo": { diff --git a/patches/@expensify/react-native-live-markdown/@expensify+react-native-live-markdown+0.1.342+001+register-parser-worklet-in-insertion-effect.patch b/patches/@expensify/react-native-live-markdown/@expensify+react-native-live-markdown+0.1.343+001+register-parser-worklet-in-insertion-effect.patch similarity index 99% rename from patches/@expensify/react-native-live-markdown/@expensify+react-native-live-markdown+0.1.342+001+register-parser-worklet-in-insertion-effect.patch rename to patches/@expensify/react-native-live-markdown/@expensify+react-native-live-markdown+0.1.343+001+register-parser-worklet-in-insertion-effect.patch index 9f114fa1a8a8..a6a96307db34 100644 --- a/patches/@expensify/react-native-live-markdown/@expensify+react-native-live-markdown+0.1.342+001+register-parser-worklet-in-insertion-effect.patch +++ b/patches/@expensify/react-native-live-markdown/@expensify+react-native-live-markdown+0.1.343+001+register-parser-worklet-in-insertion-effect.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@expensify/react-native-live-markdown/android/src/main/cpp/MarkdownParser.cpp b/node_modules/@expensify/react-native-live-markdown/android/src/main/cpp/MarkdownParser.cpp -index 4edd28e..c98baa7 100644 +index 534e20c..007eb55 100644 --- a/node_modules/@expensify/react-native-live-markdown/android/src/main/cpp/MarkdownParser.cpp +++ b/node_modules/@expensify/react-native-live-markdown/android/src/main/cpp/MarkdownParser.cpp @@ -11,11 +11,15 @@ namespace livemarkdown { @@ -18,10 +18,10 @@ index 4edd28e..c98baa7 100644 - const auto markdownWorklet = expensify::livemarkdown::getMarkdownWorklet(parserId); - const auto input = jsi::String::createFromUtf8(rt, text->toStdString()); - const auto output = markdownRuntime->runGuarded(markdownWorklet, input); + const auto output = markdownRuntime->runSync(markdownWorklet, input); diff --git a/node_modules/@expensify/react-native-live-markdown/android/src/main/java/com/expensify/livemarkdown/MarkdownParser.java b/node_modules/@expensify/react-native-live-markdown/android/src/main/java/com/expensify/livemarkdown/MarkdownParser.java -index 3e108db..260925b 100644 +index d38fd02..1f56d0e 100644 --- a/node_modules/@expensify/react-native-live-markdown/android/src/main/java/com/expensify/livemarkdown/MarkdownParser.java +++ b/node_modules/@expensify/react-native-live-markdown/android/src/main/java/com/expensify/livemarkdown/MarkdownParser.java @@ -1,6 +1,7 @@ @@ -72,7 +72,7 @@ index 1d73622..f506f5f 100644 withParserId:(nonnull NSNumber *)parserId completion:(nullable void (^)(void))completion; diff --git a/node_modules/@expensify/react-native-live-markdown/apple/MarkdownParser.mm b/node_modules/@expensify/react-native-live-markdown/apple/MarkdownParser.mm -index 89bb209..3c438f7 100644 +index 28d8ea0..7b14ced 100644 --- a/node_modules/@expensify/react-native-live-markdown/apple/MarkdownParser.mm +++ b/node_modules/@expensify/react-native-live-markdown/apple/MarkdownParser.mm @@ -171,13 +171,13 @@ - (void)drainPendingWarmups diff --git a/patches/@expensify/react-native-live-markdown/details.md b/patches/@expensify/react-native-live-markdown/details.md index 8c66cf91e3a5..2bd1b68e183b 100644 --- a/patches/@expensify/react-native-live-markdown/details.md +++ b/patches/@expensify/react-native-live-markdown/details.md @@ -1,6 +1,6 @@ # `@expensify/react-native-live-markdown` patches -### [@expensify+react-native-live-markdown+0.1.342+001+register-parser-worklet-in-insertion-effect.patch](@expensify+react-native-live-markdown+0.1.342+001+register-parser-worklet-in-insertion-effect.patch) +### [@expensify+react-native-live-markdown+0.1.343+001+register-parser-worklet-in-insertion-effect.patch](@expensify+react-native-live-markdown+0.1.343+001+register-parser-worklet-in-insertion-effect.patch) - Reason: `MarkdownTextInput` registered its parser worklet during render (`useMemo`) and unregistered it in an effect cleanup. React runs that cleanup without unmounting the component under StrictMode and whenever a hidden `` disconnects effects, so the native view kept a parser id that pointed at nothing and the input silently stopped formatting markdown. The patch moves the registration into `useInsertionEffect` in a new `useParserId` hook, chooses the id in JS (one per mounted input, a fresh one on every `parser` change) and turns the native registry into a plain map. A parse for an id with no registered worklet logs a warning and returns no ranges without caching them, so the next parse picks the parser up once it is registered. It carries the `src/`, `lib/` and native (`cpp/`, `apple/`, `android/`) parts of the upstream PR. - Depends on: `react-native+0.86.0+044+run-insertion-effect-cleanup-in-hidden-subtree.patch`. Without it Fabric skips the insertion cleanup for an input removed while its `` is hidden, and the worklet leaks in the native registry. Formatting stays correct either way because ids are never reused. diff --git a/patches/expo-modules-core/details.md b/patches/expo-modules-core/details.md index 50bed85a837c..0ad1cb38f52e 100644 --- a/patches/expo-modules-core/details.md +++ b/patches/expo-modules-core/details.md @@ -1,6 +1,6 @@ # `expo-modules-core` patches -### [expo-modules-core+57.0.7+001+disableViewRecycling.patch](expo-modules-core+57.0.7+001+disableViewRecycling.patch) +### [expo-modules-core+57.0.15+001+disableViewRecycling.patch](expo-modules-core+57.0.15+001+disableViewRecycling.patch) - Reason: Opt-out from RN Fabric view recycling - Upstream PR/issue: https://expo.canny.io/feature-requests/p/opt-out-of-react-native-fabric-view-recycling-for-expofabricviewobjc diff --git a/patches/expo-modules-core/expo-modules-core+57.0.7+001+disableViewRecycling.patch b/patches/expo-modules-core/expo-modules-core+57.0.15+001+disableViewRecycling.patch similarity index 83% rename from patches/expo-modules-core/expo-modules-core+57.0.7+001+disableViewRecycling.patch rename to patches/expo-modules-core/expo-modules-core+57.0.15+001+disableViewRecycling.patch index 88bc73dcde81..2a6f018796d9 100644 --- a/patches/expo-modules-core/expo-modules-core+57.0.7+001+disableViewRecycling.patch +++ b/patches/expo-modules-core/expo-modules-core+57.0.15+001+disableViewRecycling.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/expo-modules-core/ios/Fabric/ExpoFabricViewObjC.mm b/node_modules/expo-modules-core/ios/Fabric/ExpoFabricViewObjC.mm -index 0645540..9243248 100644 +index 370015f..1eb91cb 100644 --- a/node_modules/expo-modules-core/ios/Fabric/ExpoFabricViewObjC.mm +++ b/node_modules/expo-modules-core/ios/Fabric/ExpoFabricViewObjC.mm -@@ -137,6 +137,11 @@ - (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask +@@ -218,6 +218,11 @@ - (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask } } diff --git a/patches/react-native-reanimated/details.md b/patches/react-native-reanimated/details.md deleted file mode 100644 index 638154d65bb8..000000000000 --- a/patches/react-native-reanimated/details.md +++ /dev/null @@ -1,9 +0,0 @@ - -# `react-native-reanimated` patches - -### [react-native-reanimated+4.5.5+001+catch-all-exceptions-on-stoi.patch](react-native-reanimated+4.5.5+001+catch-all-exceptions-on-stoi.patch) - -- Reason: Reanimated wasn't able to catch an exception here, so the catch clause was broadened. -- Upstream PR/issue: 🛑 -- E/App issue: 🛑 -- PR Introducing Patch: [Upgrade to React Native 0.76](https://github.com/Expensify/App/pull/51475) diff --git a/patches/react-native-reanimated/react-native-reanimated+4.5.5+001+catch-all-exceptions-on-stoi.patch b/patches/react-native-reanimated/react-native-reanimated+4.5.5+001+catch-all-exceptions-on-stoi.patch deleted file mode 100644 index 73ced70d9f6e..000000000000 --- a/patches/react-native-reanimated/react-native-reanimated+4.5.5+001+catch-all-exceptions-on-stoi.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/node_modules/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp b/node_modules/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp -index 9402e3e..ddc29cd 100644 ---- a/node_modules/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp -+++ b/node_modules/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp -@@ -986,7 +986,7 @@ void LayoutAnimationsProxy_Legacy::transferConfigFromNativeID(const std::string - auto nativeId = stoi(nativeIdString); - layoutAnimationsManager_->transferConfigFromNativeID(nativeId, tag); - } catch (std::invalid_argument) { -- } catch (std::out_of_range) {} -+ } catch (...) {} - } - - // When entering animations start, we temporarily set opacity to 0 diff --git a/patches/react-native-screens/details.md b/patches/react-native-screens/details.md new file mode 100644 index 000000000000..1460c9f4d25e --- /dev/null +++ b/patches/react-native-screens/details.md @@ -0,0 +1,17 @@ +# `react-native-screens` patches + +### [react-native-screens+4.28.0+001+fix-fragment-already-added-in-screenstack-onupdate.patch](react-native-screens+4.28.0+001+fix-fragment-already-added-in-screenstack-onupdate.patch) + +- Reason: + + ``` + This patch fixes a fatal Android crash (java.lang.IllegalStateException: Fragment already added: ScreenStackFragment) in ScreenStack.onUpdate. + + When re-attaching screens from visibleBottom upward, onUpdate adds every fragment without checking whether it is already attached. If the screen under a translucent modal (for example, the AI features promo modal) is detached while the modal stays attached, the modal is added twice in the same transaction and the commit throws. The patch removes an already attached fragment before re-adding it, so it is re-appended in stack order and the translucent top stays on top. + + The skip-only guard proposed upstream stops the crash but leaves the modal hidden behind the re-added screen. Remove this patch once upstream ships a fix that keeps the draw order. + ``` + +- Upstream PR/issue: https://github.com/software-mansion/react-native-screens/issues/4156, https://github.com/software-mansion/react-native-screens/pull/4157 +- E/App issue: https://github.com/Expensify/App/issues/102046 +- PR introducing patch: https://github.com/Expensify/App/pull/103126 diff --git a/patches/react-native-screens/react-native-screens+4.28.0+001+fix-fragment-already-added-in-screenstack-onupdate.patch b/patches/react-native-screens/react-native-screens+4.28.0+001+fix-fragment-already-added-in-screenstack-onupdate.patch new file mode 100644 index 000000000000..6c37775868ba --- /dev/null +++ b/patches/react-native-screens/react-native-screens+4.28.0+001+fix-fragment-already-added-in-screenstack-onupdate.patch @@ -0,0 +1,15 @@ +diff --git a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/ScreenStack.kt b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/ScreenStack.kt +index 2372cac..8bfaace 100644 +--- a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/ScreenStack.kt ++++ b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/ScreenStack.kt +@@ -286,6 +286,10 @@ class ScreenStack( + // dismissed screens are already removed above & preloaded ones must stay detached + .filter { !dismissedWrappers.contains(it) && it.screen.activityState !== Screen.ActivityState.INACTIVE } + .forEach { wrapper -> ++ // Remove and re-add attached fragments so they're re-appended in stack order and the translucent top stays on top. ++ if (wrapper.fragment.isAdded) { ++ transaction.remove(wrapper.fragment) ++ } + // TODO: It should be enough to dispatch this on commit action once. + transaction.add(id, wrapper.fragment).runOnCommit { + top?.screen?.bringToFront() diff --git a/patches/react-native-tab-view/details.md b/patches/react-native-tab-view/details.md index c6952b53e27b..1edc11f5121c 100644 --- a/patches/react-native-tab-view/details.md +++ b/patches/react-native-tab-view/details.md @@ -1,6 +1,6 @@ # `react-native-tab-view` patches -### [react-native-tab-view+4.3.0+001+fix-tab-animation.patch](react-native-tab-view+4.3.0+001+fix-tab-animation.patch) +### [react-native-tab-view+4.3.2+001+fix-tab-animation.patch](react-native-tab-view+4.3.2+001+fix-tab-animation.patch) - Reason: @@ -13,18 +13,22 @@ - E/App issue: 🛑 - PR Introducing Patch: [#39854](https://github.com/Expensify/App/pull/39854) -### [react-native-tab-view+4.3.0+002+fix-glitching-on-initial-load.patch](react-native-tab-view+4.3.0+002+fix-glitching-on-initial-load.patch) +### [react-native-tab-view+4.3.2+002+fix-glitching-on-initial-load.patch](react-native-tab-view+4.3.2+002+fix-glitching-on-initial-load.patch) - Reason: ``` There was a visible glitching on initial load. The glitch was visible because two animated values: position and translateX were equal to 0 for a brief moment before changing to a proper value. ``` + + The 4.3.2 re-port dropped the `lib/typescript/src/PanResponderAdapter.d.ts.map` hunk. It only rewrites a + source map for declaration files, which nothing at runtime reads, and its base content changed in 4.3.2. + The `PanResponderAdapter.js` and `.js.map` hunks are carried over unchanged. - Upstream PR/issue: https://github.com/react-navigation/react-navigation/pull/12627#issuecomment-2945055209 - E/App issue: https://github.com/Expensify/App/issues/62346 - PR Introducing Patch: [#63570](https://github.com/Expensify/App/pull/63570) -### [react-native-tab-view+4.3.0+003+fix-web-onTabSelect-on-mount.patch](react-native-tab-view+4.3.0+003+fix-web-onTabSelect-on-mount.patch) +### [react-native-tab-view+4.3.2+003+fix-web-onTabSelect-on-mount.patch](react-native-tab-view+4.3.2+003+fix-web-onTabSelect-on-mount.patch) - Reason: ``` @@ -35,7 +39,7 @@ - E/App issue: https://github.com/Expensify/App/issues/71913#issuecomment-3584103273 - PR Introducing Patch: [#76586](https://github.com/Expensify/App/pull/76586) -### [react-native-tab-view+4.3.0+004+fix-native-onTabSelect-on-mount.patch](react-native-tab-view+4.3.0+004+fix-native-onTabSelect-on-mount.patch) +### [react-native-tab-view+4.3.2+004+fix-native-onTabSelect-on-mount.patch](react-native-tab-view+4.3.2+004+fix-native-onTabSelect-on-mount.patch) - Reason: ``` @@ -46,7 +50,7 @@ - Upstream PR/issue: 🛑 (must merge https://github.com/react-navigation/react-navigation/pull/12627 first) - E/App issue: https://github.com/Expensify/App/issues/83010 -### [react-native-tab-view+4.3.0+005+fix-pager-scrollleft-91610.patch](react-native-tab-view+4.3.0+005+fix-pager-scrollleft-91610.patch) +### [react-native-tab-view+4.3.2+005+fix-pager-scrollleft-91610.patch](react-native-tab-view+4.3.2+005+fix-pager-scrollleft-91610.patch) - Reason: ``` diff --git a/patches/react-native-tab-view/react-native-tab-view+4.3.0+001+fix-tab-animation.patch b/patches/react-native-tab-view/react-native-tab-view+4.3.2+001+fix-tab-animation.patch similarity index 84% rename from patches/react-native-tab-view/react-native-tab-view+4.3.0+001+fix-tab-animation.patch rename to patches/react-native-tab-view/react-native-tab-view+4.3.2+001+fix-tab-animation.patch index ff7f28cbb451..688814ff9bc4 100644 --- a/patches/react-native-tab-view/react-native-tab-view+4.3.0+001+fix-tab-animation.patch +++ b/patches/react-native-tab-view/react-native-tab-view+4.3.2+001+fix-tab-animation.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/react-native-tab-view/src/PagerViewAdapter.tsx b/node_modules/react-native-tab-view/src/PagerViewAdapter.tsx -index d4a0a0e..0000001 100644 +index d4a0a0e..d66b0cb 100644 --- a/node_modules/react-native-tab-view/src/PagerViewAdapter.tsx +++ b/node_modules/react-native-tab-view/src/PagerViewAdapter.tsx @@ -134,9 +134,10 @@ export function PagerViewAdapter({ @@ -14,10 +14,7 @@ index d4a0a0e..0000001 100644 ); return children({ -@@ -163,9 +164,11 @@ export function PagerViewAdapter({ - ], - { useNativeDriver } - )} +@@ -166,6 +167,8 @@ export function PagerViewAdapter({ onPageSelected={(e) => { const index = e.nativeEvent.position; indexRef.current = index; diff --git a/patches/react-native-tab-view/react-native-tab-view+4.3.0+002+fix-glitching-on-initial-load.patch b/patches/react-native-tab-view/react-native-tab-view+4.3.2+002+fix-glitching-on-initial-load.patch similarity index 90% rename from patches/react-native-tab-view/react-native-tab-view+4.3.0+002+fix-glitching-on-initial-load.patch rename to patches/react-native-tab-view/react-native-tab-view+4.3.2+002+fix-glitching-on-initial-load.patch index bb6e9c1a5857..01e6a306a1f5 100644 --- a/patches/react-native-tab-view/react-native-tab-view+4.3.0+002+fix-glitching-on-initial-load.patch +++ b/patches/react-native-tab-view/react-native-tab-view+4.3.2+002+fix-glitching-on-initial-load.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/react-native-tab-view/lib/module/PanResponderAdapter.js b/node_modules/react-native-tab-view/lib/module/PanResponderAdapter.js -index 22c1705..0000002 100644 +index 22c1705..fdb8c83 100644 --- a/node_modules/react-native-tab-view/lib/module/PanResponderAdapter.js +++ b/node_modules/react-native-tab-view/lib/module/PanResponderAdapter.js @@ -79,10 +79,38 @@ export function PanResponderAdapter({ @@ -85,12 +85,3 @@ index 95b628e..2a31760 100644 -{"version":3,"names":["React","Animated","Keyboard","PanResponder","StyleSheet","View","useLatestCallback","useAnimatedValue","jsx","_jsx","DEAD_ZONE","DefaultTransitionSpec","timing","spring","stiffness","damping","mass","overshootClamping","PanResponderAdapter","layout","keyboardDismissMode","swipeEnabled","navigationState","onIndexChange","onTabSelect","onSwipeStart","onSwipeEnd","children","style","animationEnabled","layoutDirection","routes","index","panX","listenersRef","useRef","navigationStateRef","layoutRef","onIndexChangeRef","onTabSelectRef","currentIndexRef","pendingIndexRef","undefined","swipeVelocityThreshold","swipeDistanceThreshold","width","jumpToIndex","animate","offset","current","transitionConfig","parallel","toValue","useNativeDriver","start","finished","setValue","useEffect","useLayoutEffect","dismiss","isMovingHorizontally","_","gestureState","Math","abs","dx","dy","vx","vy","canMoveScreen","event","diffX","length","startGesture","stopAnimation","setOffset","_value","respondToGesture","position","_offset","next","ceil","floor","forEach","listener","finishGesture","flattenOffset","currentIndex","nextIndex","round","min","max","isFinite","addEnterListener","push","indexOf","splice","jumpTo","key","findIndex","route","panResponder","create","onMoveShouldSetPanResponder","onMoveShouldSetPanResponderCapture","onPanResponderGrant","onPanResponderMove","onPanResponderTerminate","onPanResponderRelease","onPanResponderTerminationRequest","maxTranslate","translateX","multiply","interpolate","inputRange","outputRange","extrapolate","useMemo","divide","Value","render","styles","sheet","transform","panHandlers","Children","map","child","i","focused","absoluteFill","flex","flexDirection","alignItems"],"sourceRoot":"../../src","sources":["PanResponderAdapter.tsx"],"mappings":";;AAAA,OAAO,KAAKA,KAAK,MAAM,OAAO;AAC9B,SACEC,QAAQ,EAERC,QAAQ,EACRC,YAAY,EAEZC,UAAU,EACVC,IAAI,QACC,cAAc;AACrB,OAAOC,iBAAiB,MAAM,qBAAqB;AAUnD,SAASC,gBAAgB,QAAQ,uBAAoB;AAAC,SAAAC,GAAA,IAAAC,IAAA;AAsBtD,MAAMC,SAAS,GAAG,EAAE;AAEpB,MAAMC,qBAAqB,GAAG;EAC5BC,MAAM,EAAEX,QAAQ,CAACY,MAAM;EACvBC,SAAS,EAAE,IAAI;EACfC,OAAO,EAAE,GAAG;EACZC,IAAI,EAAE,CAAC;EACPC,iBAAiB,EAAE;AACrB,CAAC;AAED,OAAO,SAASC,mBAAmBA,CAAkB;EACnDC,MAAM;EACNC,mBAAmB,GAAG,MAAM;EAC5BC,YAAY,GAAG,IAAI;EACnBC,eAAe;EACfC,aAAa;EACbC,WAAW;EACXC,YAAY;EACZC,UAAU;EACVC,QAAQ;EACRC,KAAK;EACLC,gBAAgB,GAAG,KAAK;EACxBC,eAAe,GAAG;AACV,CAAC,EAAE;EACX,MAAM;IAAEC,MAAM;IAAEC;EAAM,CAAC,GAAGV,eAAe;EAEzC,MAAMW,IAAI,GAAG1B,gBAAgB,CAAC,CAAC,CAAC;EAEhC,MAAM2B,YAAY,GAAGlC,KAAK,CAACmC,MAAM,CAAa,EAAE,CAAC;EAEjD,MAAMC,kBAAkB,GAAGpC,KAAK,CAACmC,MAAM,CAACb,eAAe,CAAC;EACxD,MAAMe,SAAS,GAAGrC,KAAK,CAACmC,MAAM,CAAChB,MAAM,CAAC;EACtC,MAAMmB,gBAAgB,GAAGtC,KAAK,CAACmC,MAAM,CAACZ,aAAa,CAAC;EACpD,MAAMgB,cAAc,GAAGvC,KAAK,CAACmC,MAAM,CAACX,WAAW,CAAC;EAChD,MAAMgB,eAAe,GAAGxC,KAAK,CAACmC,MAAM,CAACH,KAAK,CAAC;EAC3C,MAAMS,eAAe,GAAGzC,KAAK,CAACmC,MAAM,CAASO,SAAS,CAAC;EAEvD,MAAMC,sBAAsB,GAAG,IAAI;EACnC,MAAMC,sBAAsB,GAAGzB,MAAM,CAAC0B,KAAK,GAAG,IAAI;EAElD,MAAMC,WAAW,GAAGxC,iBAAiB,CACnC,CAAC0B,KAAa,EAAEe,OAAO,GAAGlB,gBAAgB,KAAK;IAC7C,MAAMmB,MAAM,GAAG,CAAChB,KAAK,GAAGK,SAAS,CAACY,OAAO,CAACJ,KAAK;IAE/C,MAAM;MAAEjC,MAAM;MAAE,GAAGsC;IAAiB,CAAC,GAAGvC,qBAAqB;IAE7D,IAAIoC,OAAO,EAAE;MACX9C,QAAQ,CAACkD,QAAQ,CAAC,CAChBvC,MAAM,CAACqB,IAAI,EAAE;QACX,GAAGiB,gBAAgB;QACnBE,OAAO,EAAEJ,MAAM;QACfK,eAAe,EAAE;MACnB,CAAC,CAAC,CACH,CAAC,CAACC,KAAK,CAAC,CAAC;QAAEC;MAAS,CAAC,KAAK;QACzB,IAAIA,QAAQ,EAAE;UACZjB,gBAAgB,CAACW,OAAO,CAACjB,KAAK,CAAC;UAC/BO,cAAc,CAACU,OAAO,GAAG;YAAEjB;UAAM,CAAC,CAAC;UACnCS,eAAe,CAACQ,OAAO,GAAGP,SAAS;QACrC;MACF,CAAC,CAAC;MACFD,eAAe,CAACQ,OAAO,GAAGjB,KAAK;IACjC,CAAC,MAAM;MACLC,IAAI,CAACuB,QAAQ,CAACR,MAAM,CAAC;MACrBV,gBAAgB,CAACW,OAAO,CAACjB,KAAK,CAAC;MAC/BO,cAAc,CAACU,OAAO,GAAG;QAAEjB;MAAM,CAAC,CAAC;MACnCS,eAAe,CAACQ,OAAO,GAAGP,SAAS;IACrC;EACF,CACF,CAAC;EAED1C,KAAK,CAACyD,SAAS,CAAC,MAAM;IACpBrB,kBAAkB,CAACa,OAAO,GAAG3B,eAAe;IAC5Ce,SAAS,CAACY,OAAO,GAAG9B,MAAM;IAC1BmB,gBAAgB,CAACW,OAAO,GAAG1B,aAAa;IACxCgB,cAAc,CAACU,OAAO,GAAGzB,WAAW;EACtC,CAAC,CAAC;EAEFxB,KAAK,CAAC0D,eAAe,CAAC,MAAM;IAC1B,MAAMV,MAAM,GAAG,CAACZ,kBAAkB,CAACa,OAAO,CAACjB,KAAK,GAAGb,MAAM,CAAC0B,KAAK;IAE/DZ,IAAI,CAACuB,QAAQ,CAACR,MAAM,CAAC;EACvB,CAAC,EAAE,CAAC7B,MAAM,CAAC0B,KAAK,EAAEZ,IAAI,CAAC,CAAC;EAExBjC,KAAK,CAACyD,SAAS,CAAC,MAAM;IACpB,IAAIrC,mBAAmB,KAAK,MAAM,EAAE;MAClClB,QAAQ,CAACyD,OAAO,CAAC,CAAC;IACpB;IAEA,IAAIxC,MAAM,CAAC0B,KAAK,IAAIL,eAAe,CAACS,OAAO,KAAKjB,KAAK,EAAE;MACrDQ,eAAe,CAACS,OAAO,GAAGjB,KAAK;MAC/Bc,WAAW,CAACd,KAAK,CAAC;IACpB;EACF,CAAC,EAAE,CAACc,WAAW,EAAE1B,mBAAmB,EAAED,MAAM,CAAC0B,KAAK,EAAEb,KAAK,CAAC,CAAC;EAE3D,MAAM4B,oBAAoB,GAAGA,CAC3BC,CAAwB,EACxBC,YAAsC,KACnC;IACH,OACEC,IAAI,CAACC,GAAG,CAACF,YAAY,CAACG,EAAE,CAAC,GAAGF,IAAI,CAACC,GAAG,CAACF,YAAY,CAACI,EAAE,GAAG,CAAC,CAAC,IACzDH,IAAI,CAACC,GAAG,CAACF,YAAY,CAACK,EAAE,CAAC,GAAGJ,IAAI,CAACC,GAAG,CAACF,YAAY,CAACM,EAAE,GAAG,CAAC,CAAC;EAE7D,CAAC;EAED,MAAMC,aAAa,GAAGA,CACpBC,KAA4B,EAC5BR,YAAsC,KACnC;IACH,IAAIzC,YAAY,KAAK,KAAK,EAAE;MAC1B,OAAO,KAAK;IACd;IAEA,MAAMkD,KAAK,GACTzC,eAAe,KAAK,KAAK,GAAG,CAACgC,YAAY,CAACG,EAAE,GAAGH,YAAY,CAACG,EAAE;IAEhE,OACEL,oBAAoB,CAACU,KAAK,EAAER,YAAY,CAAC,KACvCS,KAAK,IAAI7D,SAAS,IAAI8B,eAAe,CAACS,OAAO,GAAG,CAAC,IAChDsB,KAAK,IAAI,CAAC7D,SAAS,IAAI8B,eAAe,CAACS,OAAO,GAAGlB,MAAM,CAACyC,MAAM,GAAG,CAAE,CAAC;EAE3E,CAAC;EAED,MAAMC,YAAY,GAAGA,CAAA,KAAM;IACzBhD,YAAY,GAAG,CAAC;IAEhB,IAAIL,mBAAmB,KAAK,SAAS,EAAE;MACrClB,QAAQ,CAACyD,OAAO,CAAC,CAAC;IACpB;IAEA1B,IAAI,CAACyC,aAAa,CAAC,CAAC;IACpB;IACAzC,IAAI,CAAC0C,SAAS,CAAC1C,IAAI,CAAC2C,MAAM,CAAC;EAC7B,CAAC;EAED,MAAMC,gBAAgB,GAAGA,CACvBhB,CAAwB,EACxBC,YAAsC,KACnC;IACH,MAAMS,KAAK,GACTzC,eAAe,KAAK,KAAK,GAAG,CAACgC,YAAY,CAACG,EAAE,GAAGH,YAAY,CAACG,EAAE;IAEhE;IACE;IACCM,KAAK,GAAG,CAAC,IAAIvC,KAAK,IAAI,CAAC;IACxB;IACCuC,KAAK,GAAG,CAAC,IAAIvC,KAAK,IAAID,MAAM,CAACyC,MAAM,GAAG,CAAE,EACzC;MACA;IACF;IAEA,IAAIrD,MAAM,CAAC0B,KAAK,EAAE;MAChB;MACA,MAAMiC,QAAQ,GAAG,CAAC7C,IAAI,CAAC8C,OAAO,GAAGR,KAAK,IAAI,CAACpD,MAAM,CAAC0B,KAAK;MACvD,MAAMmC,IAAI,GACRF,QAAQ,GAAG9C,KAAK,GAAG+B,IAAI,CAACkB,IAAI,CAACH,QAAQ,CAAC,GAAGf,IAAI,CAACmB,KAAK,CAACJ,QAAQ,CAAC;MAE/D,IAAIE,IAAI,KAAKhD,KAAK,EAAE;QAClBE,YAAY,CAACe,OAAO,CAACkC,OAAO,CAAEC,QAAQ,IAAKA,QAAQ,CAACJ,IAAI,CAAC,CAAC;MAC5D;IACF;IAEA/C,IAAI,CAACuB,QAAQ,CAACe,KAAK,CAAC;EACtB,CAAC;EAED,MAAMc,aAAa,GAAGA,CACpBxB,CAAwB,EACxBC,YAAsC,KACnC;IACH7B,IAAI,CAACqD,aAAa,CAAC,CAAC;IAEpB5D,UAAU,GAAG,CAAC;IAEd,MAAM6D,YAAY,GAChB,OAAO9C,eAAe,CAACQ,OAAO,KAAK,QAAQ,GACvCR,eAAe,CAACQ,OAAO,GACvBT,eAAe,CAACS,OAAO;IAE7B,IAAIuC,SAAS,GAAGD,YAAY;IAE5B,IACExB,IAAI,CAACC,GAAG,CAACF,YAAY,CAACG,EAAE,CAAC,GAAGF,IAAI,CAACC,GAAG,CAACF,YAAY,CAACI,EAAE,CAAC,IACrDH,IAAI,CAACC,GAAG,CAACF,YAAY,CAACK,EAAE,CAAC,GAAGJ,IAAI,CAACC,GAAG,CAACF,YAAY,CAACM,EAAE,CAAC,KACpDL,IAAI,CAACC,GAAG,CAACF,YAAY,CAACG,EAAE,CAAC,GAAGrB,sBAAsB,IACjDmB,IAAI,CAACC,GAAG,CAACF,YAAY,CAACK,EAAE,CAAC,GAAGxB,sBAAsB,CAAC,EACrD;MACA6C,SAAS,GAAGzB,IAAI,CAAC0B,KAAK,CACpB1B,IAAI,CAAC2B,GAAG,CACN3B,IAAI,CAAC4B,GAAG,CACN,CAAC,EACD7D,eAAe,KAAK,KAAK,GACrByD,YAAY,GAAGzB,YAAY,CAACG,EAAE,GAAGF,IAAI,CAACC,GAAG,CAACF,YAAY,CAACG,EAAE,CAAC,GAC1DsB,YAAY,GAAGzB,YAAY,CAACG,EAAE,GAAGF,IAAI,CAACC,GAAG,CAACF,YAAY,CAACG,EAAE,CAC/D,CAAC,EACDlC,MAAM,CAACyC,MAAM,GAAG,CAClB,CACF,CAAC;MAEDhC,eAAe,CAACS,OAAO,GAAGuC,SAAS;IACrC;IAEA,IAAI,CAACI,QAAQ,CAACJ,SAAS,CAAC,EAAE;MACxBA,SAAS,GAAGD,YAAY;IAC1B;IAEAzC,WAAW,CAAC0C,SAAS,EAAE,IAAI,CAAC;EAC9B,CAAC;EAED,MAAMK,gBAAgB,GAAGvF,iBAAiB,CAAE8E,QAAkB,IAAK;IACjElD,YAAY,CAACe,OAAO,CAAC6C,IAAI,CAACV,QAAQ,CAAC;IAEnC,OAAO,MAAM;MACX,MAAMpD,KAAK,GAAGE,YAAY,CAACe,OAAO,CAAC8C,OAAO,CAACX,QAAQ,CAAC;MAEpD,IAAIpD,KAAK,GAAG,CAAC,CAAC,EAAE;QACdE,YAAY,CAACe,OAAO,CAAC+C,MAAM,CAAChE,KAAK,EAAE,CAAC,CAAC;MACvC;IACF,CAAC;EACH,CAAC,CAAC;EAEF,MAAMiE,MAAM,GAAG3F,iBAAiB,CAAE4F,GAAW,IAAK;IAChD,MAAMlE,KAAK,GAAGI,kBAAkB,CAACa,OAAO,CAAClB,MAAM,CAACoE,SAAS,CACtDC,KAAsB,IAAKA,KAAK,CAACF,GAAG,KAAKA,GAC5C,CAAC;IAEDpD,WAAW,CAACd,KAAK,CAAC;IAClBT,aAAa,CAACS,KAAK,CAAC;EACtB,CAAC,CAAC;EAEF,MAAMqE,YAAY,GAAGlG,YAAY,CAACmG,MAAM,CAAC;IACvCC,2BAA2B,EAAElC,aAAa;IAC1CmC,kCAAkC,EAAEnC,aAAa;IACjDoC,mBAAmB,EAAEhC,YAAY;IACjCiC,kBAAkB,EAAE7B,gBAAgB;IACpC8B,uBAAuB,EAAEtB,aAAa;IACtCuB,qBAAqB,EAAEvB,aAAa;IACpCwB,gCAAgC,EAAEA,CAAA,KAAM;EAC1C,CAAC,CAAC;EAEF,MAAMC,YAAY,GAAG3F,MAAM,CAAC0B,KAAK,IAAId,MAAM,CAACyC,MAAM,GAAG,CAAC,CAAC;EACvD,MAAMuC,UAAU,GAAG9G,QAAQ,CAAC+G,QAAQ,CAClC/E,IAAI,CAACgF,WAAW,CAAC;IACfC,UAAU,EAAE,CAAC,CAACJ,YAAY,EAAE,CAAC,CAAC;IAC9BK,WAAW,EAAE,CAAC,CAACL,YAAY,EAAE,CAAC,CAAC;IAC/BM,WAAW,EAAE;EACf,CAAC,CAAC,EACFtF,eAAe,KAAK,KAAK,GAAG,CAAC,CAAC,GAAG,CACnC,CAAC;EAED,MAAMgD,QAAQ,GAAG9E,KAAK,CAACqH,OAAO,CAC5B,MAAOlG,MAAM,CAAC0B,KAAK,GAAG5C,QAAQ,CAACqH,MAAM,CAACrF,IAAI,EAAE,CAACd,MAAM,CAAC0B,KAAK,CAAC,GAAG,IAAK,EAClE,CAAC1B,MAAM,CAAC0B,KAAK,EAAEZ,IAAI,CACrB,CAAC;EAED,OAAON,QAAQ,CAAC;IACdmD,QAAQ,EAAEA,QAAQ,IAAI,IAAI7E,QAAQ,CAACsH,KAAK,CAACvF,KAAK,CAAC;IAC/C6D,gBAAgB;IAChBI,MAAM;IACNuB,MAAM,EAAG7F,QAAQ,iBACflB,IAAA,CAACR,QAAQ,CAACI,IAAI;MACZuB,KAAK,EAAE,CACL6F,MAAM,CAACC,KAAK,EACZvG,MAAM,CAAC0B,KAAK,GACR;QACEA,KAAK,EAAEd,MAAM,CAACyC,MAAM,GAAGrD,MAAM,CAAC0B,KAAK;QACnC8E,SAAS,EAAE,CAAC;UAAEZ;QAAW,CAAC;MAC5B,CAAC,GACD,IAAI,EACRnF,KAAK,CACL;MAAA,GACEyE,YAAY,CAACuB,WAAW;MAAAjG,QAAA,EAE3B3B,KAAK,CAAC6H,QAAQ,CAACC,GAAG,CAACnG,QAAQ,EAAE,CAACoG,KAAK,EAAEC,CAAC,KAAK;QAC1C,MAAM5B,KAAK,GAAGrE,MAAM,CAACiG,CAAC,CAAC;QACvB,MAAMC,OAAO,GAAGD,CAAC,KAAKhG,KAAK;QAE3B,oBACEvB,IAAA,CAACJ,IAAI;UAEHuB,KAAK,EACHT,MAAM,CAAC0B,KAAK,GACR;YAAEA,KAAK,EAAE1B,MAAM,CAAC0B;UAAM,CAAC,GACvBoF,OAAO,GACL7H,UAAU,CAAC8H,YAAY,GACvB,IACP;UAAAvG,QAAA,EAEAsG,OAAO,IAAI9G,MAAM,CAAC0B,KAAK,GAAGkF,KAAK,GAAG;QAAI,GATlC3B,KAAK,CAACF,GAUP,CAAC;MAEX,CAAC;IAAC,CACW;EAEnB,CAAC,CAAC;AACJ;AAEA,MAAMuB,MAAM,GAAGrH,UAAU,CAACkG,MAAM,CAAC;EAC/BoB,KAAK,EAAE;IACLS,IAAI,EAAE,CAAC;IACPC,aAAa,EAAE,KAAK;IACpBC,UAAU,EAAE;EACd;AACF,CAAC,CAAC","ignoreList":[]} +{"version":3,"names":["React","Animated","Keyboard","PanResponder","StyleSheet","View","useLatestCallback","useAnimatedValue","jsx","_jsx","DEAD_ZONE","DefaultTransitionSpec","timing","spring","stiffness","damping","mass","overshootClamping","PanResponderAdapter","layout","keyboardDismissMode","swipeEnabled","navigationState","onIndexChange","onTabSelect","onSwipeStart","onSwipeEnd","children","style","animationEnabled","layoutDirection","routes","index","panX","listenersRef","useRef","navigationStateRef","layoutRef","onIndexChangeRef","onTabSelectRef","currentIndexRef","pendingIndexRef","undefined","swipeVelocityThreshold","swipeDistanceThreshold","width","jumpToIndex","animate","offset","current","transitionConfig","parallel","toValue","useNativeDriver","start","finished","setValue","useEffect","position","setPosition","useState","translateX","setTranslateX","divide","maxTranslate","length","multiply","interpolate","inputRange","outputRange","extrapolate","dismiss","isMovingHorizontally","_","gestureState","Math","abs","dx","dy","vx","vy","canMoveScreen","event","diffX","startGesture","stopAnimation","setOffset","_value","respondToGesture","_offset","next","ceil","floor","forEach","listener","finishGesture","flattenOffset","currentIndex","nextIndex","round","min","max","isFinite","addEnterListener","push","indexOf","splice","jumpTo","key","findIndex","route","panResponder","create","onMoveShouldSetPanResponder","onMoveShouldSetPanResponderCapture","onPanResponderGrant","onPanResponderMove","onPanResponderTerminate","onPanResponderRelease","onPanResponderTerminationRequest","Value","render","styles","sheet","transform","panHandlers","Children","map","child","i","focused","absoluteFill","flex","flexDirection","alignItems"],"sourceRoot":"../../src","sources":["PanResponderAdapter.tsx"],"mappings":";;AAAA,OAAO,KAAKA,KAAK,MAAM,OAAO;AAC9B,SACEC,QAAQ,EAERC,QAAQ,EACRC,YAAY,EAEZC,UAAU,EACVC,IAAI,QACC,cAAc;AACrB,OAAOC,iBAAiB,MAAM,qBAAqB;AAUnD,SAASC,gBAAgB,QAAQ,oBAAoB;AAAC,SAAAC,GAAA,IAAAC,IAAA;AAsBtD,MAAMC,SAAS,GAAG,EAAE;AAEpB,MAAMC,qBAAqB,GAAG;EAC5BC,MAAM,EAAEX,QAAQ,CAACY,MAAM;EACvBC,SAAS,EAAE,IAAI;EACfC,OAAO,EAAE,GAAG;EACZC,IAAI,EAAE,CAAC;EACPC,iBAAiB,EAAE;AACrB,CAAC;AAED,OAAO,SAASC,mBAAmBA,CAAkB;EACnDC,MAAM;EACNC,mBAAmB,GAAG,MAAM;EAC5BC,YAAY,GAAG,IAAI;EACnBC,eAAe;EACfC,aAAa;EACbC,WAAW;EACXC,YAAY;EACZC,UAAU;EACVC,QAAQ;EACRC,KAAK;EACLC,gBAAgB,GAAG,KAAK;EACxBC,eAAe,GAAG;AACV,CAAC,EAAE;EACX,MAAM;IAAEC,MAAM;IAAEC;EAAM,CAAC,GAAGV,eAAe;EAEzC,MAAMW,IAAI,GAAG1B,gBAAgB,CAAC,CAAC,CAAC;EAEhC,MAAM2B,YAAY,GAAGlC,KAAK,CAACmC,MAAM,CAAa,EAAE,CAAC;EAEjD,MAAMC,kBAAkB,GAAGpC,KAAK,CAACmC,MAAM,CAACb,eAAe,CAAC;EACxD,MAAMe,SAAS,GAAGrC,KAAK,CAACmC,MAAM,CAAChB,MAAM,CAAC;EACtC,MAAMmB,gBAAgB,GAAGtC,KAAK,CAACmC,MAAM,CAACZ,aAAa,CAAC;EACpD,MAAMgB,cAAc,GAAGvC,KAAK,CAACmC,MAAM,CAACX,WAAW,CAAC;EAChD,MAAMgB,eAAe,GAAGxC,KAAK,CAACmC,MAAM,CAACH,KAAK,CAAC;EAC3C,MAAMS,eAAe,GAAGzC,KAAK,CAACmC,MAAM,CAASO,SAAS,CAAC;EAEvD,MAAMC,sBAAsB,GAAG,IAAI;EACnC,MAAMC,sBAAsB,GAAGzB,MAAM,CAAC0B,KAAK,GAAG,IAAI;EAElD,MAAMC,WAAW,GAAGxC,iBAAiB,CACnC,CAAC0B,KAAa,EAAEe,OAAO,GAAGlB,gBAAgB,KAAK;IAC7C,MAAMmB,MAAM,GAAG,CAAChB,KAAK,GAAGK,SAAS,CAACY,OAAO,CAACJ,KAAK;IAE/C,MAAM;MAAEjC,MAAM;MAAE,GAAGsC;IAAiB,CAAC,GAAGvC,qBAAqB;IAE7D,IAAIoC,OAAO,EAAE;MACX9C,QAAQ,CAACkD,QAAQ,CAAC,CAChBvC,MAAM,CAACqB,IAAI,EAAE;QACX,GAAGiB,gBAAgB;QACnBE,OAAO,EAAEJ,MAAM;QACfK,eAAe,EAAE;MACnB,CAAC,CAAC,CACH,CAAC,CAACC,KAAK,CAAC,CAAC;QAAEC;MAAS,CAAC,KAAK;QACzB,IAAIA,QAAQ,EAAE;UACZjB,gBAAgB,CAACW,OAAO,CAACjB,KAAK,CAAC;UAC/BO,cAAc,CAACU,OAAO,GAAG;YAAEjB;UAAM,CAAC,CAAC;UACnCS,eAAe,CAACQ,OAAO,GAAGP,SAAS;QACrC;MACF,CAAC,CAAC;MACFD,eAAe,CAACQ,OAAO,GAAGjB,KAAK;IACjC,CAAC,MAAM;MACLC,IAAI,CAACuB,QAAQ,CAACR,MAAM,CAAC;MACrBV,gBAAgB,CAACW,OAAO,CAACjB,KAAK,CAAC;MAC/BO,cAAc,CAACU,OAAO,GAAG;QAAEjB;MAAM,CAAC,CAAC;MACnCS,eAAe,CAACQ,OAAO,GAAGP,SAAS;IACrC;EACF,CACF,CAAC;EAED1C,KAAK,CAACyD,SAAS,CAAC,MAAM;IACpBrB,kBAAkB,CAACa,OAAO,GAAG3B,eAAe;IAC5Ce,SAAS,CAACY,OAAO,GAAG9B,MAAM;IAC1BmB,gBAAgB,CAACW,OAAO,GAAG1B,aAAa;IACxCgB,cAAc,CAACU,OAAO,GAAGzB,WAAW;EACtC,CAAC,CAAC;EAEF,MAAM,CAACkC,QAAQ,EAAEC,WAAW,CAAC,GAAG3D,KAAK,CAAC4D,QAAQ,CAA2C,IAAI,CAAC;EAC9F,MAAM,CAACC,UAAU,EAAEC,aAAa,CAAC,GAAG9D,KAAK,CAAC4D,QAAQ,CAAiD,IAAI,CAAC;EAExG5D,KAAK,CAACyD,SAAS,CAAC,MAAM;IACpB,MAAMT,MAAM,GAAG,CAACZ,kBAAkB,CAACa,OAAO,CAACjB,KAAK,GAAGb,MAAM,CAAC0B,KAAK;IAE/DZ,IAAI,CAACuB,QAAQ,CAACR,MAAM,CAAC;;IAErB;IACA,IAAI,CAAC7B,MAAM,CAAC0B,KAAK,EAAE;MACjB;MACA;MACA;MACA;MACAc,WAAW,CAAC,IAAI,CAAC;MACjB;MACAG,aAAa,CAAC,IAAI,CAAC;MACnB;IACF;;IAEA;IACA;IACA;IACA;IACAH,WAAW,CAACxC,MAAM,CAAC0B,KAAK,GAAG5C,QAAQ,CAAC8D,MAAM,CAAC9B,IAAI,EAAE,CAACd,MAAM,CAAC0B,KAAK,CAAC,GAAG,IAAI,CAAC;IACvE,MAAMmB,YAAY,GAAG7C,MAAM,CAAC0B,KAAK,IAAId,MAAM,CAACkC,MAAM,GAAG,CAAC,CAAC;;IAEvD;IACAH,aAAa,CACX7D,QAAQ,CAACiE,QAAQ,CACfjC,IAAI,CAACkC,WAAW,CAAC;MACfC,UAAU,EAAE,CAAC,CAACJ,YAAY,EAAE,CAAC,CAAC;MAC9BK,WAAW,EAAE,CAAC,CAACL,YAAY,EAAE,CAAC,CAAC;MAC/BM,WAAW,EAAE;IACf,CAAC,CAAC,EACFxC,eAAe,KAAK,KAAK,GAAG,CAAC,CAAC,GAAG,CACnC,CACF,CAAC;EACH,CAAC,EAAE,CAACX,MAAM,CAAC0B,KAAK,EAAEZ,IAAI,EAAEF,MAAM,CAACkC,MAAM,EAAEnC,eAAe,CAAC,CAAC;EAExD9B,KAAK,CAACyD,SAAS,CAAC,MAAM;IACpB,IAAIrC,mBAAmB,KAAK,MAAM,EAAE;MAClClB,QAAQ,CAACqE,OAAO,CAAC,CAAC;IACpB;IAEA,IAAIpD,MAAM,CAAC0B,KAAK,IAAIL,eAAe,CAACS,OAAO,KAAKjB,KAAK,EAAE;MACrDQ,eAAe,CAACS,OAAO,GAAGjB,KAAK;MAC/Bc,WAAW,CAACd,KAAK,CAAC;IACpB;EACF,CAAC,EAAE,CAACc,WAAW,EAAE1B,mBAAmB,EAAED,MAAM,CAAC0B,KAAK,EAAEb,KAAK,CAAC,CAAC;EAE3D,MAAMwC,oBAAoB,GAAGA,CAC3BC,CAAwB,EACxBC,YAAsC,KACnC;IACH,OACEC,IAAI,CAACC,GAAG,CAACF,YAAY,CAACG,EAAE,CAAC,GAAGF,IAAI,CAACC,GAAG,CAACF,YAAY,CAACI,EAAE,GAAG,CAAC,CAAC,IACzDH,IAAI,CAACC,GAAG,CAACF,YAAY,CAACK,EAAE,CAAC,GAAGJ,IAAI,CAACC,GAAG,CAACF,YAAY,CAACM,EAAE,GAAG,CAAC,CAAC;EAE7D,CAAC;EAED,MAAMC,aAAa,GAAGA,CACpBC,KAA4B,EAC5BR,YAAsC,KACnC;IACH,IAAIrD,YAAY,KAAK,KAAK,EAAE;MAC1B,OAAO,KAAK;IACd;IAEA,MAAM8D,KAAK,GACTrD,eAAe,KAAK,KAAK,GAAG,CAAC4C,YAAY,CAACG,EAAE,GAAGH,YAAY,CAACG,EAAE;IAEhE,OACEL,oBAAoB,CAACU,KAAK,EAAER,YAAY,CAAC,KACvCS,KAAK,IAAIzE,SAAS,IAAI8B,eAAe,CAACS,OAAO,GAAG,CAAC,IAChDkC,KAAK,IAAI,CAACzE,SAAS,IAAI8B,eAAe,CAACS,OAAO,GAAGlB,MAAM,CAACkC,MAAM,GAAG,CAAE,CAAC;EAE3E,CAAC;EAED,MAAMmB,YAAY,GAAGA,CAAA,KAAM;IACzB3D,YAAY,GAAG,CAAC;IAEhB,IAAIL,mBAAmB,KAAK,SAAS,EAAE;MACrClB,QAAQ,CAACqE,OAAO,CAAC,CAAC;IACpB;IAEAtC,IAAI,CAACoD,aAAa,CAAC,CAAC;IACpB;IACApD,IAAI,CAACqD,SAAS,CAACrD,IAAI,CAACsD,MAAM,CAAC;EAC7B,CAAC;EAED,MAAMC,gBAAgB,GAAGA,CACvBf,CAAwB,EACxBC,YAAsC,KACnC;IACH,MAAMS,KAAK,GACTrD,eAAe,KAAK,KAAK,GAAG,CAAC4C,YAAY,CAACG,EAAE,GAAGH,YAAY,CAACG,EAAE;IAEhE;IACE;IACCM,KAAK,GAAG,CAAC,IAAInD,KAAK,IAAI,CAAC;IACxB;IACCmD,KAAK,GAAG,CAAC,IAAInD,KAAK,IAAID,MAAM,CAACkC,MAAM,GAAG,CAAE,EACzC;MACA;IACF;IAEA,IAAI9C,MAAM,CAAC0B,KAAK,EAAE;MAChB;MACA,MAAMa,QAAQ,GAAG,CAACzB,IAAI,CAACwD,OAAO,GAAGN,KAAK,IAAI,CAAChE,MAAM,CAAC0B,KAAK;MACvD,MAAM6C,IAAI,GACRhC,QAAQ,GAAG1B,KAAK,GAAG2C,IAAI,CAACgB,IAAI,CAACjC,QAAQ,CAAC,GAAGiB,IAAI,CAACiB,KAAK,CAAClC,QAAQ,CAAC;MAE/D,IAAIgC,IAAI,KAAK1D,KAAK,EAAE;QAClBE,YAAY,CAACe,OAAO,CAAC4C,OAAO,CAAEC,QAAQ,IAAKA,QAAQ,CAACJ,IAAI,CAAC,CAAC;MAC5D;IACF;IAEAzD,IAAI,CAACuB,QAAQ,CAAC2B,KAAK,CAAC;EACtB,CAAC;EAED,MAAMY,aAAa,GAAGA,CACpBtB,CAAwB,EACxBC,YAAsC,KACnC;IACHzC,IAAI,CAAC+D,aAAa,CAAC,CAAC;IAEpBtE,UAAU,GAAG,CAAC;IAEd,MAAMuE,YAAY,GAChB,OAAOxD,eAAe,CAACQ,OAAO,KAAK,QAAQ,GACvCR,eAAe,CAACQ,OAAO,GACvBT,eAAe,CAACS,OAAO;IAE7B,IAAIiD,SAAS,GAAGD,YAAY;IAE5B,IACEtB,IAAI,CAACC,GAAG,CAACF,YAAY,CAACG,EAAE,CAAC,GAAGF,IAAI,CAACC,GAAG,CAACF,YAAY,CAACI,EAAE,CAAC,IACrDH,IAAI,CAACC,GAAG,CAACF,YAAY,CAACK,EAAE,CAAC,GAAGJ,IAAI,CAACC,GAAG,CAACF,YAAY,CAACM,EAAE,CAAC,KACpDL,IAAI,CAACC,GAAG,CAACF,YAAY,CAACG,EAAE,CAAC,GAAGjC,sBAAsB,IACjD+B,IAAI,CAACC,GAAG,CAACF,YAAY,CAACK,EAAE,CAAC,GAAGpC,sBAAsB,CAAC,EACrD;MACAuD,SAAS,GAAGvB,IAAI,CAACwB,KAAK,CACpBxB,IAAI,CAACyB,GAAG,CACNzB,IAAI,CAAC0B,GAAG,CACN,CAAC,EACDvE,eAAe,KAAK,KAAK,GACrBmE,YAAY,GAAGvB,YAAY,CAACG,EAAE,GAAGF,IAAI,CAACC,GAAG,CAACF,YAAY,CAACG,EAAE,CAAC,GAC1DoB,YAAY,GAAGvB,YAAY,CAACG,EAAE,GAAGF,IAAI,CAACC,GAAG,CAACF,YAAY,CAACG,EAAE,CAC/D,CAAC,EACD9C,MAAM,CAACkC,MAAM,GAAG,CAClB,CACF,CAAC;MAEDzB,eAAe,CAACS,OAAO,GAAGiD,SAAS;IACrC;IAEA,IAAI,CAACI,QAAQ,CAACJ,SAAS,CAAC,EAAE;MACxBA,SAAS,GAAGD,YAAY;IAC1B;IAEAnD,WAAW,CAACoD,SAAS,EAAE,IAAI,CAAC;EAC9B,CAAC;EAED,MAAMK,gBAAgB,GAAGjG,iBAAiB,CAAEwF,QAAkB,IAAK;IACjE5D,YAAY,CAACe,OAAO,CAACuD,IAAI,CAACV,QAAQ,CAAC;IAEnC,OAAO,MAAM;MACX,MAAM9D,KAAK,GAAGE,YAAY,CAACe,OAAO,CAACwD,OAAO,CAACX,QAAQ,CAAC;MAEpD,IAAI9D,KAAK,GAAG,CAAC,CAAC,EAAE;QACdE,YAAY,CAACe,OAAO,CAACyD,MAAM,CAAC1E,KAAK,EAAE,CAAC,CAAC;MACvC;IACF,CAAC;EACH,CAAC,CAAC;EAEF,MAAM2E,MAAM,GAAGrG,iBAAiB,CAAEsG,GAAW,IAAK;IAChD,MAAM5E,KAAK,GAAGI,kBAAkB,CAACa,OAAO,CAAClB,MAAM,CAAC8E,SAAS,CACtDC,KAAsB,IAAKA,KAAK,CAACF,GAAG,KAAKA,GAC5C,CAAC;IAED9D,WAAW,CAACd,KAAK,CAAC;IAClBT,aAAa,CAACS,KAAK,CAAC;EACtB,CAAC,CAAC;EAEF,MAAM+E,YAAY,GAAG5G,YAAY,CAAC6G,MAAM,CAAC;IACvCC,2BAA2B,EAAEhC,aAAa;IAC1CiC,kCAAkC,EAAEjC,aAAa;IACjDkC,mBAAmB,EAAE/B,YAAY;IACjCgC,kBAAkB,EAAE5B,gBAAgB;IACpC6B,uBAAuB,EAAEtB,aAAa;IACtCuB,qBAAqB,EAAEvB,aAAa;IACpCwB,gCAAgC,EAAEA,CAAA,KAAM;EAC1C,CAAC,CAAC;EAEF,OAAO5F,QAAQ,CAAC;IACd+B,QAAQ,EAAEA,QAAQ,IAAI,IAAIzD,QAAQ,CAACuH,KAAK,CAACxF,KAAK,CAAC;IAC/CuE,gBAAgB;IAChBI,MAAM;IACNc,MAAM,EAAG9F,QAAQ,iBACflB,IAAA,CAACR,QAAQ,CAACI,IAAI;MACZuB,KAAK,EAAE,CACL8F,MAAM,CAACC,KAAK,EACZ9D,UAAU,GACN;QACEhB,KAAK,EAAEd,MAAM,CAACkC,MAAM,GAAG9C,MAAM,CAAC0B,KAAK;QACnC+E,SAAS,EAAE,CAAC;UAAE/D;QAAW,CAAC;MAC5B,CAAC,GACD,IAAI,EACRjC,KAAK,CACL;MAAA,GACEmF,YAAY,CAACc,WAAW;MAAAlG,QAAA,EAE3B3B,KAAK,CAAC8H,QAAQ,CAACC,GAAG,CAACpG,QAAQ,EAAE,CAACqG,KAAK,EAAEC,CAAC,KAAK;QAC1C,MAAMnB,KAAK,GAAG/E,MAAM,CAACkG,CAAC,CAAC;QACvB,MAAMC,OAAO,GAAGD,CAAC,KAAKjG,KAAK;QAE3B,oBACEvB,IAAA,CAACJ,IAAI;UAEHuB,KAAK,EACHiC,UAAU,GACN;YAAEhB,KAAK,EAAE1B,MAAM,CAAC0B;UAAM,CAAC,GACvBqF,OAAO,GACL9H,UAAU,CAAC+H,YAAY,GACvB,IACP;UAAAxG,QAAA,EAEAuG,OAAO,IAAIrE,UAAU,GAAGmE,KAAK,GAAG;QAAI,GAThClB,KAAK,CAACF,GAUP,CAAC;MAEX,CAAC;IAAC,CACW;EAEnB,CAAC,CAAC;AACJ;AAEA,MAAMc,MAAM,GAAGtH,UAAU,CAAC4G,MAAM,CAAC;EAC/BW,KAAK,EAAE;IACLS,IAAI,EAAE,CAAC;IACPC,aAAa,EAAE,KAAK;IACpBC,UAAU,EAAE;EACd;AACF,CAAC,CAAC","ignoreList":[]} \ No newline at end of file -diff --git a/node_modules/react-native-tab-view/lib/typescript/src/PanResponderAdapter.d.ts.map b/node_modules/react-native-tab-view/lib/typescript/src/PanResponderAdapter.d.ts.map -index f477de0..4d55ccb 100644 ---- a/node_modules/react-native-tab-view/lib/typescript/src/PanResponderAdapter.d.ts.map -+++ b/node_modules/react-native-tab-view/lib/typescript/src/PanResponderAdapter.d.ts.map -@@ -1 +1 @@ --{"version":3,"file":"PanResponderAdapter.d.ts","sourceRoot":"","sources":["../../../src/PanResponderAdapter.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,EACL,QAAQ,EAOT,MAAM,cAAc,CAAC;AAGtB,OAAO,KAAK,EACV,iBAAiB,EACjB,MAAM,EAEN,eAAe,EACf,UAAU,EACV,KAAK,EACN,MAAM,SAAS,CAAC;AAGjB,KAAK,KAAK,CAAC,CAAC,SAAS,KAAK,IAAI,UAAU,GAAG;IACzC,MAAM,EAAE,MAAM,CAAC;IACf,aAAa,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACvC,WAAW,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IACjD,eAAe,EAAE,eAAe,CAAC,CAAC,CAAC,CAAC;IACpC,QAAQ,EAAE,CACR,KAAK,EAAE,iBAAiB,GAAG;QAGzB,QAAQ,EAAE,QAAQ,CAAC,qBAAqB,CAAC,MAAM,CAAC,CAAC;QAGjD,MAAM,EAAE,CAAC,QAAQ,EAAE,KAAK,CAAC,SAAS,KAAK,KAAK,CAAC,SAAS,CAAC;QAGvD,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;KAC/B,KACE,KAAK,CAAC,YAAY,CAAC;CACzB,CAAC;AAYF,wBAAgB,mBAAmB,CAAC,CAAC,SAAS,KAAK,EAAE,EACnD,MAAM,EACN,mBAA4B,EAC5B,YAAmB,EACnB,eAAe,EACf,aAAa,EACb,WAAW,EACX,YAAY,EACZ,UAAU,EACV,QAAQ,EACR,KAAK,EACL,gBAAwB,EACxB,eAAuB,GACxB,EAAE,KAAK,CAAC,CAAC,CAAC,0EA8QV"} -\ No newline at end of file -+{"version":3,"file":"PanResponderAdapter.d.ts","sourceRoot":"","sources":["../../../src/PanResponderAdapter.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,EACL,QAAQ,EAOT,MAAM,cAAc,CAAC;AAGtB,OAAO,KAAK,EACV,iBAAiB,EACjB,MAAM,EAEN,eAAe,EACf,UAAU,EACV,KAAK,EACN,MAAM,SAAS,CAAC;AAGjB,KAAK,KAAK,CAAC,CAAC,SAAS,KAAK,IAAI,UAAU,GAAG;IACzC,MAAM,EAAE,MAAM,CAAC;IACf,aAAa,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACvC,WAAW,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IACjD,eAAe,EAAE,eAAe,CAAC,CAAC,CAAC,CAAC;IACpC,QAAQ,EAAE,CACR,KAAK,EAAE,iBAAiB,GAAG;QAGzB,QAAQ,EAAE,QAAQ,CAAC,qBAAqB,CAAC,MAAM,CAAC,CAAC;QAGjD,MAAM,EAAE,CAAC,QAAQ,EAAE,KAAK,CAAC,SAAS,KAAK,KAAK,CAAC,SAAS,CAAC;QAGvD,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;KAC/B,KACE,KAAK,CAAC,YAAY,CAAC;CACzB,CAAC;AAYF,wBAAgB,mBAAmB,CAAC,CAAC,SAAS,KAAK,EAAE,EACnD,MAAM,EACN,mBAA4B,EAC5B,YAAmB,EACnB,eAAe,EACf,aAAa,EACb,WAAW,EACX,YAAY,EACZ,UAAU,EACV,QAAQ,EACR,KAAK,EACL,gBAAwB,EACxB,eAAuB,GACxB,EAAE,KAAK,CAAC,CAAC,CAAC,0EAiSV"} -\ No newline at end of file diff --git a/patches/react-native-tab-view/react-native-tab-view+4.3.0+003+fix-web-onTabSelect-on-mount.patch b/patches/react-native-tab-view/react-native-tab-view+4.3.2+003+fix-web-onTabSelect-on-mount.patch similarity index 88% rename from patches/react-native-tab-view/react-native-tab-view+4.3.0+003+fix-web-onTabSelect-on-mount.patch rename to patches/react-native-tab-view/react-native-tab-view+4.3.2+003+fix-web-onTabSelect-on-mount.patch index ff1df355c8df..37cd3b30b474 100644 --- a/patches/react-native-tab-view/react-native-tab-view+4.3.0+003+fix-web-onTabSelect-on-mount.patch +++ b/patches/react-native-tab-view/react-native-tab-view+4.3.2+003+fix-web-onTabSelect-on-mount.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/react-native-tab-view/lib/module/PanResponderAdapter.js b/node_modules/react-native-tab-view/lib/module/PanResponderAdapter.js -index 0000002..0000003 100644 +index fdb8c83..7ae690b 100644 --- a/node_modules/react-native-tab-view/lib/module/PanResponderAdapter.js +++ b/node_modules/react-native-tab-view/lib/module/PanResponderAdapter.js @@ -39,6 +39,7 @@ export function PanResponderAdapter({ @@ -10,14 +10,15 @@ index 0000002..0000003 100644 const swipeVelocityThreshold = 0.15; const swipeDistanceThreshold = layout.width / 1.75; const jumpToIndex = useLatestCallback((index, animate = animationEnabled) => { -@@ -94,5 +95,6 @@ export function PanResponderAdapter({ +@@ -94,6 +95,7 @@ export function PanResponderAdapter({ setPosition(null); // eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect setTranslateX(null); + hasCalledInitialTabSelectRef.current = false; return; } -@@ -110,5 +112,11 @@ export function PanResponderAdapter({ + +@@ -110,6 +112,12 @@ export function PanResponderAdapter({ outputRange: [-maxTranslate, 0], extrapolate: 'clamp' }), layoutDirection === 'rtl' ? -1 : 1)); @@ -29,3 +30,4 @@ index 0000002..0000003 100644 + } }, [layout.width, panX, routes.length, layoutDirection]); React.useEffect(() => { + if (keyboardDismissMode === 'auto') { diff --git a/patches/react-native-tab-view/react-native-tab-view+4.3.0+004+fix-native-onTabSelect-on-mount.patch b/patches/react-native-tab-view/react-native-tab-view+4.3.2+004+fix-native-onTabSelect-on-mount.patch similarity index 97% rename from patches/react-native-tab-view/react-native-tab-view+4.3.0+004+fix-native-onTabSelect-on-mount.patch rename to patches/react-native-tab-view/react-native-tab-view+4.3.2+004+fix-native-onTabSelect-on-mount.patch index 3ea408f3ca68..a7eed8165abe 100644 --- a/patches/react-native-tab-view/react-native-tab-view+4.3.0+004+fix-native-onTabSelect-on-mount.patch +++ b/patches/react-native-tab-view/react-native-tab-view+4.3.2+004+fix-native-onTabSelect-on-mount.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/react-native-tab-view/src/PagerViewAdapter.tsx b/node_modules/react-native-tab-view/src/PagerViewAdapter.tsx -index 0000001..01e208c 100644 +index d66b0cb..01e208c 100644 --- a/node_modules/react-native-tab-view/src/PagerViewAdapter.tsx +++ b/node_modules/react-native-tab-view/src/PagerViewAdapter.tsx @@ -57,14 +57,28 @@ export function PagerViewAdapter({ diff --git a/patches/react-native-tab-view/react-native-tab-view+4.3.0+005+fix-pager-scrollleft-91610.patch b/patches/react-native-tab-view/react-native-tab-view+4.3.2+005+fix-pager-scrollleft-91610.patch similarity index 92% rename from patches/react-native-tab-view/react-native-tab-view+4.3.0+005+fix-pager-scrollleft-91610.patch rename to patches/react-native-tab-view/react-native-tab-view+4.3.2+005+fix-pager-scrollleft-91610.patch index 6ec3f56eec6c..bbeb68f07d30 100644 --- a/patches/react-native-tab-view/react-native-tab-view+4.3.0+005+fix-pager-scrollleft-91610.patch +++ b/patches/react-native-tab-view/react-native-tab-view+4.3.2+005+fix-pager-scrollleft-91610.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/react-native-tab-view/lib/module/TabView.js b/node_modules/react-native-tab-view/lib/module/TabView.js -index 0000004..0000005 100644 +index 079ee72..2153b63 100644 --- a/node_modules/react-native-tab-view/lib/module/TabView.js +++ b/node_modules/react-native-tab-view/lib/module/TabView.js -@@ -41,6 +41,27 @@ +@@ -41,6 +41,27 @@ export function TabView({ height: 0, ...initialLayout }); @@ -30,7 +30,7 @@ index 0000004..0000005 100644 const jumpToIndex = index => { if (index !== navigationState.index) { onIndexChange(index); -@@ -66,6 +87,7 @@ +@@ -66,6 +87,7 @@ export function TabView({ ...sceneOptions?.[route.key] }])); return /*#__PURE__*/_jsx(View, { diff --git a/patches/react-navigation/@react-navigation+bottom-tabs+7.15.5+001+fix-failing-jest-by-disabling-esmodule.patch b/patches/react-navigation/@react-navigation+bottom-tabs+7.19.2+001+fix-failing-jest-by-disabling-esmodule.patch similarity index 100% rename from patches/react-navigation/@react-navigation+bottom-tabs+7.15.5+001+fix-failing-jest-by-disabling-esmodule.patch rename to patches/react-navigation/@react-navigation+bottom-tabs+7.19.2+001+fix-failing-jest-by-disabling-esmodule.patch diff --git a/patches/react-navigation/@react-navigation+core+7.21.12+001+fix-failing-jest-by-disabling-esmodule.patch b/patches/react-navigation/@react-navigation+core+7.22.1+001+fix-failing-jest-by-disabling-esmodule.patch similarity index 100% rename from patches/react-navigation/@react-navigation+core+7.21.12+001+fix-failing-jest-by-disabling-esmodule.patch rename to patches/react-navigation/@react-navigation+core+7.22.1+001+fix-failing-jest-by-disabling-esmodule.patch diff --git a/patches/react-navigation/@react-navigation+core+7.21.12+002+platform-navigation-stack-types.patch b/patches/react-navigation/@react-navigation+core+7.22.1+002+platform-navigation-stack-types.patch similarity index 99% rename from patches/react-navigation/@react-navigation+core+7.21.12+002+platform-navigation-stack-types.patch rename to patches/react-navigation/@react-navigation+core+7.22.1+002+platform-navigation-stack-types.patch index 65c6d6e0948a..94ab2b672599 100644 --- a/patches/react-navigation/@react-navigation+core+7.21.12+002+platform-navigation-stack-types.patch +++ b/patches/react-navigation/@react-navigation+core+7.22.1+002+platform-navigation-stack-types.patch @@ -57,7 +57,7 @@ index dfd9de7..0d14027 100644 // @ts-expect-error: we check for function but TS still complains typeof curr !== 'function' ? curr : curr({ diff --git a/node_modules/@react-navigation/core/lib/module/useNavigationBuilder.js b/node_modules/@react-navigation/core/lib/module/useNavigationBuilder.js -index 2ed8725..8147d8f 100644 +index f01bdd6..6427dab 100644 --- a/node_modules/@react-navigation/core/lib/module/useNavigationBuilder.js +++ b/node_modules/@react-navigation/core/lib/module/useNavigationBuilder.js @@ -167,7 +167,7 @@ const getStateFromParams = (params, type) => { @@ -79,7 +79,7 @@ index 2ed8725..8147d8f 100644 state, navigation, diff --git a/node_modules/@react-navigation/core/lib/typescript/src/types.d.ts b/node_modules/@react-navigation/core/lib/typescript/src/types.d.ts -index 0b4f4c2..4dc5941 100644 +index 23af668..feb6f47 100644 --- a/node_modules/@react-navigation/core/lib/typescript/src/types.d.ts +++ b/node_modules/@react-navigation/core/lib/typescript/src/types.d.ts @@ -109,7 +109,16 @@ declare global { @@ -161,7 +161,7 @@ index 5e37fe4..f0129f1 100644 dispatch(action: Readonly<{ type: string; diff --git a/node_modules/@react-navigation/core/lib/typescript/src/useNavigationBuilder.d.ts b/node_modules/@react-navigation/core/lib/typescript/src/useNavigationBuilder.d.ts -index 36566fb..fc4eb26 100644 +index ab8f81e..1819ea3 100644 --- a/node_modules/@react-navigation/core/lib/typescript/src/useNavigationBuilder.d.ts +++ b/node_modules/@react-navigation/core/lib/typescript/src/useNavigationBuilder.d.ts @@ -1,6 +1,6 @@ @@ -182,7 +182,7 @@ index 36566fb..fc4eb26 100644 navigation: { dispatch(action: Readonly<{ diff --git a/node_modules/@react-navigation/core/src/types.tsx b/node_modules/@react-navigation/core/src/types.tsx -index 68e52ae..4eb998a 100644 +index 580f300..f3c6ca9 100644 --- a/node_modules/@react-navigation/core/src/types.tsx +++ b/node_modules/@react-navigation/core/src/types.tsx @@ -157,6 +157,29 @@ declare global { @@ -362,7 +362,7 @@ index 320efcf..e78fa6d 100644 return optionsList.reduce( (acc, curr) => diff --git a/node_modules/@react-navigation/core/src/useNavigationBuilder.tsx b/node_modules/@react-navigation/core/src/useNavigationBuilder.tsx -index 9f0d412..bc976eb 100644 +index 16e44a5..2ad3322 100644 --- a/node_modules/@react-navigation/core/src/useNavigationBuilder.tsx +++ b/node_modules/@react-navigation/core/src/useNavigationBuilder.tsx @@ -27,6 +27,7 @@ import { NavigationStateContext } from './NavigationStateContext'; diff --git a/patches/react-navigation/@react-navigation+core+7.22.1+003+root-state-not-undefined.patch b/patches/react-navigation/@react-navigation+core+7.22.1+003+root-state-not-undefined.patch new file mode 100644 index 000000000000..15ea671710c6 --- /dev/null +++ b/patches/react-navigation/@react-navigation+core+7.22.1+003+root-state-not-undefined.patch @@ -0,0 +1,26 @@ +diff --git a/node_modules/@react-navigation/core/lib/typescript/src/types.d.ts b/node_modules/@react-navigation/core/lib/typescript/src/types.d.ts +index feb6f47..4dc5941 100644 +--- a/node_modules/@react-navigation/core/lib/typescript/src/types.d.ts ++++ b/node_modules/@react-navigation/core/lib/typescript/src/types.d.ts +@@ -720,7 +720,7 @@ export type NavigationContainerRef = NavigationHelpers = + /** + * Get the rehydrated navigation state of the navigation tree. + */ +- getRootState(): NavigationState | undefined; ++ getRootState(): NavigationState; + /** + * Get the currently focused navigation route. + */ diff --git a/patches/react-navigation/@react-navigation+elements+2.9.14+001+fix-failing-jest-by-disabling-esmodule.patch b/patches/react-navigation/@react-navigation+elements+2.9.43+001+fix-failing-jest-by-disabling-esmodule.patch similarity index 100% rename from patches/react-navigation/@react-navigation+elements+2.9.14+001+fix-failing-jest-by-disabling-esmodule.patch rename to patches/react-navigation/@react-navigation+elements+2.9.43+001+fix-failing-jest-by-disabling-esmodule.patch diff --git a/patches/react-navigation/@react-navigation+material-top-tabs+7.4.19+001+fix-failing-jest-by-disabling-esmodule.patch b/patches/react-navigation/@react-navigation+material-top-tabs+7.7.2+001+fix-failing-jest-by-disabling-esmodule.patch similarity index 100% rename from patches/react-navigation/@react-navigation+material-top-tabs+7.4.19+001+fix-failing-jest-by-disabling-esmodule.patch rename to patches/react-navigation/@react-navigation+material-top-tabs+7.7.2+001+fix-failing-jest-by-disabling-esmodule.patch diff --git a/patches/react-navigation/@react-navigation+native+7.1.33+003+increase-history-go-popstate-fallback-timeout.patch b/patches/react-navigation/@react-navigation+native+7.1.33+003+increase-history-go-popstate-fallback-timeout.patch deleted file mode 100644 index 4d7806ce3819..000000000000 --- a/patches/react-navigation/@react-navigation+native+7.1.33+003+increase-history-go-popstate-fallback-timeout.patch +++ /dev/null @@ -1,29 +0,0 @@ -diff --git a/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js b/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js ---- a/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js -+++ b/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js -@@ -189,10 +189,14 @@ - cb: done - }); - -- // If navigation didn't happen within 100ms, assume that it won't happen -+ // If navigation didn't happen within 1000ms, assume that it won't happen - // This may not be accurate, but hopefully it won't take so much time - // In Chrome, navigation seems to happen instantly in next microtask -- // But on Firefox, it seems to take much longer, around 50ms from our testing -+ // But on Firefox the traversal can take 500ms+ when the main thread is busy -+ // (measured up to ~900ms while rendering a newly pushed screen). A 100ms timeout -+ // routinely resolved before the traversal finished and removed the pending entry, -+ // so the late `popstate` was misclassified by `listen` as a user-initiated -+ // back/forward navigation and reset the app to a stale route - // We're using a hacky timeout since there doesn't seem to be way to know for sure - const timer = setTimeout(() => { - const foundIndex = pending.findIndex(it => it.ref === done); -@@ -201,7 +205,7 @@ - pending.splice(foundIndex, 1); - } - index = this.index; -- }, 100); -+ }, 1000); - const onPopState = () => { - // Fix createMemoryHistory.index variable's value - // as it may go out of sync when navigating in the browser. diff --git a/patches/react-navigation/@react-navigation+native+7.1.33+001+initial.patch b/patches/react-navigation/@react-navigation+native+7.4.1+001+initial.patch similarity index 76% rename from patches/react-navigation/@react-navigation+native+7.1.33+001+initial.patch rename to patches/react-navigation/@react-navigation+native+7.4.1+001+initial.patch index 6c845817d9b0..b16466d5d91c 100644 --- a/patches/react-navigation/@react-navigation+native+7.1.33+001+initial.patch +++ b/patches/react-navigation/@react-navigation+native+7.4.1+001+initial.patch @@ -1,12 +1,13 @@ diff --git a/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js b/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js -index 474db5e..4d94ed8 100644 +index 9034278..d639a23 100644 --- a/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js +++ b/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js -@@ -1,10 +1,27 @@ +@@ -1,11 +1,28 @@ "use strict"; import { nanoid } from 'nanoid/non-secure'; +import { findFocusedRouteKey } from "./findFocusedRouteKey.js"; + const getPathWithoutHash = path => path.split('#')[0] ?? ''; export function createMemoryHistory() { let index = 0; let items = []; @@ -30,19 +31,17 @@ index 474db5e..4d94ed8 100644 // Pending callbacks for `history.go(n)` // We might modify the callback stored if it was interrupted, so we have a ref to identify it const pending = []; -@@ -17,7 +34,10 @@ export function createMemoryHistory() { - it.cb = () => cb(true); +@@ -19,6 +36,9 @@ export function createMemoryHistory() { }); }; -- const history = { -+ return { + const history = { + get items() { + return items; + }, get index() { // We store an id in the state instead of an index // Index could get out of sync with in-memory values if page reloads -@@ -32,12 +52,13 @@ export function createMemoryHistory() { +@@ -33,7 +53,8 @@ export function createMemoryHistory() { return items[index]; }, backIndex({ @@ -50,15 +49,18 @@ index 474db5e..4d94ed8 100644 + path, + state }) { - // We need to find the index from the element before current to get closest path to go back to - for (let i = index - 1; i >= 0; i--) { - const item = items[i]; -- if (item.path === path) { -+ if (item.path === path && findFocusedRouteKey(item.state) === findFocusedRouteKey(state)) { + const pathWithoutHash = getPathWithoutHash(path); + +@@ -43,7 +64,7 @@ export function createMemoryHistory() { + if (item == null) { + continue; + } +- if (item.path === pathWithoutHash) { ++ if (item.path === pathWithoutHash && findFocusedRouteKey(item.state) === findFocusedRouteKey(state)) { return i; } } -@@ -77,7 +98,7 @@ export function createMemoryHistory() { +@@ -85,7 +106,7 @@ export function createMemoryHistory() { // Need to keep the hash part of the path if there was no previous history entry // or the previous history entry had the same path @@ -67,13 +69,6 @@ index 474db5e..4d94ed8 100644 const hash = pathWithHash.includes('#') ? '' : location.hash; if (!items.length || items.findIndex(item => item.id === id) < 0) { // There are two scenarios for creating an array with only one history record: -@@ -211,6 +232,5 @@ export function createMemoryHistory() { - return () => window.removeEventListener('popstate', onPopState); - } - }; -- return history; - } - //# sourceMappingURL=createMemoryHistory.js.map diff --git a/node_modules/@react-navigation/native/lib/module/findFocusedRouteKey.js b/node_modules/@react-navigation/native/lib/module/findFocusedRouteKey.js new file mode 100644 index 0000000..b996822 @@ -89,18 +84,18 @@ index 0000000..b996822 +} +//# sourceMappingURL=findFocusedRouteKey.js.map diff --git a/node_modules/@react-navigation/native/lib/module/useLinking.js b/node_modules/@react-navigation/native/lib/module/useLinking.js -index d30009f..0dab822 100644 +index af0cd61..fb06350 100644 --- a/node_modules/@react-navigation/native/lib/module/useLinking.js +++ b/node_modules/@react-navigation/native/lib/module/useLinking.js -@@ -4,6 +4,7 @@ import { findFocusedRoute, getActionFromState as getActionFromStateDefault, getP +@@ -4,6 +4,7 @@ import { CommonActions, findFocusedRoute, getActionFromState as getActionFromSta import isEqual from 'fast-deep-equal'; import * as React from 'react'; import { createMemoryHistory } from "./createMemoryHistory.js"; +import { findFocusedRouteKey } from "./findFocusedRouteKey.js"; import { ServerContext } from "./ServerContext.js"; + /** - * Find the matching navigation state that changed between 2 navigation states -@@ -45,6 +46,43 @@ export const series = cb => { +@@ -114,6 +115,43 @@ export const series = cb => { return callback; }; const linkingHandlers = []; @@ -144,17 +139,17 @@ index d30009f..0dab822 100644 export function useLinking(ref, { enabled = true, config, -@@ -256,6 +294,9 @@ export function useLinking(ref, { - // Otherwise it's likely a change triggered by `popstate` - path !== pendingPath) { - const historyDelta = (focusedState.history ? focusedState.history.length : focusedState.routes.length) - (previousFocusedState.history ? previousFocusedState.history.length : previousFocusedState.routes.length); +@@ -411,6 +449,9 @@ export function useLinking(ref, { + const historyDelta = getHistoryLength(focusedState) - getHistoryLength(previousFocusedState) - ( + // Subtract the delta already applied by the browser to sync only the remaining changes + pendingPopStateDelta ?? 0); + + // The historyDelta and historyDeltaByKeys may differ if the new state has an entry that didn't exist in previous state -+ const historyDeltaByKeys = getHistoryDeltaByKeys(focusedState, previousFocusedState); ++ const historyDeltaByKeys = getHistoryDeltaByKeys(focusedState, previousFocusedState) - (pendingPopStateDelta ?? 0); if (historyDelta > 0) { // If history length is increased, we should pushState // Note that path might not actually change here, for example, drawer open should pushState -@@ -267,7 +308,8 @@ export function useLinking(ref, { +@@ -422,7 +463,8 @@ export function useLinking(ref, { // If history length is decreased, i.e. entries were removed, we want to go back const nextIndex = history.backIndex({ @@ -164,15 +159,7 @@ index d30009f..0dab822 100644 }); const currentIndex = history.index; try { -@@ -276,11 +318,26 @@ export function useLinking(ref, { - history.get(nextIndex)) { - // An existing entry for this path exists and it's less than current index, go back to that - await history.go(nextIndex - currentIndex); -+ history.replace({ -+ path, -+ state -+ }); - } else { +@@ -435,7 +477,18 @@ export function useLinking(ref, { // We couldn't find an existing entry to go back to, so we'll go back by the delta // This won't be correct if multiple routes were pushed in one go before // Usually this shouldn't happen and this is a fallback for that @@ -192,7 +179,7 @@ index d30009f..0dab822 100644 } // Store the updated state as well as fix the path if incorrect -@@ -293,10 +350,22 @@ export function useLinking(ref, { +@@ -448,10 +501,26 @@ export function useLinking(ref, { } } else { // If history length is unchanged, we want to replaceState @@ -210,11 +197,15 @@ index d30009f..0dab822 100644 + state + }); + } else { -+ await history.go(-staleHistoryDiff); -+ history.push({ -+ path, -+ state -+ }); ++ try { ++ await history.go(-staleHistoryDiff); ++ history.push({ ++ path, ++ state ++ }); ++ } catch (e) { ++ // The navigation was interrupted ++ } + } } } else { diff --git a/patches/react-navigation/@react-navigation+native+7.1.33+002+fix-failing-jest-by-disabling-esmodule.patch b/patches/react-navigation/@react-navigation+native+7.4.1+002+fix-failing-jest-by-disabling-esmodule.patch similarity index 100% rename from patches/react-navigation/@react-navigation+native+7.1.33+002+fix-failing-jest-by-disabling-esmodule.patch rename to patches/react-navigation/@react-navigation+native+7.4.1+002+fix-failing-jest-by-disabling-esmodule.patch diff --git a/patches/react-navigation/@react-navigation+native-stack+7.14.5+002+fix-failing-jest-by-disabling-esmodule.patch b/patches/react-navigation/@react-navigation+native-stack+7.19.2+002+fix-failing-jest-by-disabling-esmodule.patch similarity index 100% rename from patches/react-navigation/@react-navigation+native-stack+7.14.5+002+fix-failing-jest-by-disabling-esmodule.patch rename to patches/react-navigation/@react-navigation+native-stack+7.19.2+002+fix-failing-jest-by-disabling-esmodule.patch diff --git a/patches/react-navigation/@react-navigation+stack+7.8.5+001+edge-drag-gesture.patch b/patches/react-navigation/@react-navigation+stack+7.11.2+001+edge-drag-gesture.patch similarity index 94% rename from patches/react-navigation/@react-navigation+stack+7.8.5+001+edge-drag-gesture.patch rename to patches/react-navigation/@react-navigation+stack+7.11.2+001+edge-drag-gesture.patch index 1bc7bf7ad90a..d05c705d1de3 100644 --- a/patches/react-navigation/@react-navigation+stack+7.8.5+001+edge-drag-gesture.patch +++ b/patches/react-navigation/@react-navigation+stack+7.11.2+001+edge-drag-gesture.patch @@ -67,10 +67,10 @@ index 0000000..9c7b703 +//# sourceMappingURL=edgeDragGestureMonitor.native.js.map \ No newline at end of file diff --git a/node_modules/@react-navigation/stack/lib/module/views/Stack/Card.js b/node_modules/@react-navigation/stack/lib/module/views/Stack/Card.js -index 0000000..0000000 100644 +index d737bbc..f6c06f0 100644 --- a/node_modules/@react-navigation/stack/lib/module/views/Stack/Card.js +++ b/node_modules/@react-navigation/stack/lib/module/views/Stack/Card.js -@@ -6,6 +6,7 @@ import * as React from 'react'; +@@ -6,6 +6,7 @@ import { Animated, Platform, StyleSheet, View } from 'react-native'; import useLatestCallback from 'use-latest-callback'; import { CardAnimationContext } from "../../utils/CardAnimationContext.js"; import { gestureActivationCriteria } from "../../utils/gestureActivationCriteria.js"; @@ -78,8 +78,7 @@ index 0000000..0000000 100644 import { getDistanceForDirection } from "../../utils/getDistanceForDirection.js"; import { getInvertedMultiplier } from "../../utils/getInvertedMultiplier.js"; import { getShadowStyle } from "../../utils/getShadowStyle.js"; -@@ -134,7 +135,9 @@ export function Card({ - if (animated) { +@@ -150,6 +151,8 @@ function Card({ onStartInteraction(); animation(gesture, { ...spec.config, @@ -88,9 +87,8 @@ index 0000000..0000000 100644 velocity, toValue, useNativeDriver, -@@ -143,7 +145,10 @@ export function Card({ - finished - }) => { +@@ -163,6 +166,9 @@ function Card({ + isAnimatingRef.current = false; onEndInteraction(); clearTimeout(pendingGestureCallbackRef.current); + @@ -100,7 +98,7 @@ index 0000000..0000000 100644 onFinish(); } diff --git a/node_modules/@react-navigation/stack/lib/module/views/Stack/CardStack.js b/node_modules/@react-navigation/stack/lib/module/views/Stack/CardStack.js -index 0000000..0000000 100644 +index a3c1efd..326f0c6 100644 --- a/node_modules/@react-navigation/stack/lib/module/views/Stack/CardStack.js +++ b/node_modules/@react-navigation/stack/lib/module/views/Stack/CardStack.js @@ -5,6 +5,7 @@ import * as React from 'react'; @@ -111,8 +109,7 @@ index 0000000..0000000 100644 import { findLastIndex } from "../../utils/findLastIndex.js"; import { getDistanceForDirection } from "../../utils/getDistanceForDirection.js"; import { getModalRouteKeys } from "../../utils/getModalRoutesKeys.js"; -@@ -261,7 +262,9 @@ export class CardStack extends React.Component { - }; +@@ -261,6 +262,8 @@ export class CardStack extends React.Component { } constructor(props) { super(props); diff --git a/patches/react-navigation/@react-navigation+stack+7.8.5+002+dontDetachScreen.patch b/patches/react-navigation/@react-navigation+stack+7.11.2+002+dontDetachScreen.patch similarity index 81% rename from patches/react-navigation/@react-navigation+stack+7.8.5+002+dontDetachScreen.patch rename to patches/react-navigation/@react-navigation+stack+7.11.2+002+dontDetachScreen.patch index 0b10a2777532..d48b0d12b11c 100644 --- a/patches/react-navigation/@react-navigation+stack+7.8.5+002+dontDetachScreen.patch +++ b/patches/react-navigation/@react-navigation+stack+7.11.2+002+dontDetachScreen.patch @@ -1,8 +1,20 @@ +diff --git a/node_modules/@react-navigation/stack/lib/module/views/Stack/CardA11yWrapper.js b/node_modules/@react-navigation/stack/lib/module/views/Stack/CardA11yWrapper.js +index 7750f21..d95ceb0 100644 +--- a/node_modules/@react-navigation/stack/lib/module/views/Stack/CardA11yWrapper.js ++++ b/node_modules/@react-navigation/stack/lib/module/views/Stack/CardA11yWrapper.js +@@ -23,6 +23,7 @@ export const CardA11yWrapper = /*#__PURE__*/React.forwardRef(({ + style: [StyleSheet.absoluteFill, { + // We use visibility on web + display: Platform.OS !== 'web' && isHidden ? 'none' : 'flex', ++ zIndex: Platform.OS === 'web' ? 'auto' : undefined, + // Hide unfocused screens when animation isn't enabled + // This is also necessary for a11y on web + // @ts-expect-error visibility is only available on web diff --git a/node_modules/@react-navigation/stack/lib/module/views/Stack/CardStack.js b/node_modules/@react-navigation/stack/lib/module/views/Stack/CardStack.js -index 0000000..0000000 100644 +index 326f0c6..381ca43 100644 --- a/node_modules/@react-navigation/stack/lib/module/views/Stack/CardStack.js +++ b/node_modules/@react-navigation/stack/lib/module/views/Stack/CardStack.js -@@ -427,6 +430,7 @@ export class CardStack extends React.Component { +@@ -430,10 +430,11 @@ export class CardStack extends React.Component { const isNextScreenTransparent = scenes[index + 1]?.descriptor.options.presentation === 'transparentModal'; const detachCurrentScreen = scenes[index + 1]?.descriptor.options.detachPreviousScreen !== false; const activityState = isPreloaded ? STATE_INACTIVE : activeStates[index]; @@ -10,16 +22,12 @@ index 0000000..0000000 100644 return /*#__PURE__*/_jsx(MaybeScreen, { style: [StyleSheet.absoluteFill], enabled: detachInactiveScreens, -@@ -430,7 +434,7 @@ export class CardStack extends React.Component { - return /*#__PURE__*/_jsx(MaybeScreen, { - style: [StyleSheet.absoluteFill], - enabled: detachInactiveScreens, - active: activityState, + active: shouldNotDetachScreen ? STATE_TRANSITIONING_OR_BELOW_TOP : activityState, freezeOnBlur: freezeOnBlur, shouldFreeze: activityState === STATE_INACTIVE && !isPreloaded, homeIndicatorHidden: autoHideHomeIndicator, -@@ -465,7 +469,7 @@ export class CardStack extends React.Component { +@@ -468,7 +469,7 @@ export class CardStack extends React.Component { onTransitionStart: onTransitionStart, onTransitionEnd: onTransitionEnd, isNextScreenTransparent: isNextScreenTransparent, @@ -28,15 +36,3 @@ index 0000000..0000000 100644 preloaded: isPreloaded }) }, route.key); -diff --git a/node_modules/@react-navigation/stack/lib/module/views/Stack/CardA11yWrapper.js b/node_modules/@react-navigation/stack/lib/module/views/Stack/CardA11yWrapper.js -index 0000000..0000000 100644 ---- a/node_modules/@react-navigation/stack/lib/module/views/Stack/CardA11yWrapper.js -+++ b/node_modules/@react-navigation/stack/lib/module/views/Stack/CardA11yWrapper.js -@@ -27,6 +27,7 @@ export const CardA11yWrapper = /*#__PURE__*/React.forwardRef(({ - overflow: active ? undefined : 'hidden', - // We use visibility on web - display: Platform.OS !== 'web' && isHidden ? 'none' : 'flex', -+ zIndex: Platform.OS === 'web' ? 'auto' : undefined, - // Hide unfocused screens when animation isn't enabled - // This is also necessary for a11y on web - // @ts-expect-error visibility is only available on web diff --git a/patches/react-navigation/@react-navigation+stack+7.8.5+004+fix-failing-jest-by-disabling-esmodule.patch b/patches/react-navigation/@react-navigation+stack+7.11.2+004+fix-failing-jest-by-disabling-esmodule.patch similarity index 100% rename from patches/react-navigation/@react-navigation+stack+7.8.5+004+fix-failing-jest-by-disabling-esmodule.patch rename to patches/react-navigation/@react-navigation+stack+7.11.2+004+fix-failing-jest-by-disabling-esmodule.patch diff --git a/patches/react-navigation/@react-navigation+stack+7.8.5+005+clip-inactive-card-inside-card.patch b/patches/react-navigation/@react-navigation+stack+7.8.5+005+clip-inactive-card-inside-card.patch deleted file mode 100644 index 244e9ecc8a13..000000000000 --- a/patches/react-navigation/@react-navigation+stack+7.8.5+005+clip-inactive-card-inside-card.patch +++ /dev/null @@ -1,48 +0,0 @@ -diff --git a/node_modules/@react-navigation/stack/lib/module/views/Stack/Card.js b/node_modules/@react-navigation/stack/lib/module/views/Stack/Card.js ---- a/node_modules/@react-navigation/stack/lib/module/views/Stack/Card.js -+++ b/node_modules/@react-navigation/stack/lib/module/views/Stack/Card.js -@@ -48,6 +48,7 @@ function Card({ - gestureVelocityImpact = GESTURE_VELOCITY_IMPACT, - overlay = defaultOverlay, - animated, -+ active, - interpolationIndex, - opening, - closing, -@@ -375,7 +376,11 @@ function Card({ - children: /*#__PURE__*/_jsxs(Animated.View, { - pointerEvents: "box-none", - needsOffscreenAlphaCompositing: hasOpacityStyle(cardStyle), -- style: [styles.container, cardStyle], -+ style: [styles.container, { -+ // Avoid unfocused larger pages increasing scroll area -+ // e.g. when a smaller screen is pushed over a larger one on web -+ overflow: active ? undefined : 'hidden' -+ }, cardStyle], - children: [shadowEnabled && shadowStyle && !isTransparent ? /*#__PURE__*/_jsx(Animated.View, { - pointerEvents: "none", - style: [styles.shadow, gestureDirection === 'horizontal' ? [styles.shadowHorizontal, styles.shadowStart] : gestureDirection === 'horizontal-inverted' ? [styles.shadowHorizontal, styles.shadowEnd] : gestureDirection === 'vertical' ? [styles.shadowVertical, styles.shadowTop] : [styles.shadowVertical, styles.shadowBottom], { -diff --git a/node_modules/@react-navigation/stack/lib/module/views/Stack/CardA11yWrapper.js b/node_modules/@react-navigation/stack/lib/module/views/Stack/CardA11yWrapper.js ---- a/node_modules/@react-navigation/stack/lib/module/views/Stack/CardA11yWrapper.js -+++ b/node_modules/@react-navigation/stack/lib/module/views/Stack/CardA11yWrapper.js -@@ -22,9 +22,6 @@ export const CardA11yWrapper = /*#__PURE__*/React.forwardRef(({ - "aria-hidden": !focused, - pointerEvents: (animated ? inert : !focused) ? 'none' : 'box-none', - style: [StyleSheet.absoluteFill, { -- // This is necessary to avoid unfocused larger pages increasing scroll area -- // The issue can be seen on the web when a smaller screen is pushed over a larger one -- overflow: active ? undefined : 'hidden', - // We use visibility on web - display: Platform.OS !== 'web' && isHidden ? 'none' : 'flex', - zIndex: Platform.OS === 'web' ? 'auto' : undefined, -diff --git a/node_modules/@react-navigation/stack/lib/module/views/Stack/CardContainer.js b/node_modules/@react-navigation/stack/lib/module/views/Stack/CardContainer.js ---- a/node_modules/@react-navigation/stack/lib/module/views/Stack/CardContainer.js -+++ b/node_modules/@react-navigation/stack/lib/module/views/Stack/CardContainer.js -@@ -198,6 +198,7 @@ function CardContainerInner({ - detachCurrentScreen: detachCurrentScreen, - children: /*#__PURE__*/_jsx(Card, { - animated: animated, -+ active: active, - interpolationIndex: interpolationIndex, - gestureDirection: gestureDirection, - layout: layout, diff --git a/patches/react-navigation/details.md b/patches/react-navigation/details.md index 955b43b2c86b..542721926812 100644 --- a/patches/react-navigation/details.md +++ b/patches/react-navigation/details.md @@ -1,14 +1,14 @@ # `@react-navigation` patches ### @react-navigation+package-name+7+fix-failing-jest-by-disabling-esmodule.patch -#### [@react-navigation+bottom-tabs+7.15.5+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+bottom-tabs+7.15.5+001+fix-failing-jest-by-disabling-esmodule.patch) -#### [@react-navigation+core+7.21.12+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+core+7.21.12+001+fix-failing-jest-by-disabling-esmodule.patch) -#### [@react-navigation+elements+2.9.14+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+elements+2.9.14+001+fix-failing-jest-by-disabling-esmodule.patch) -#### [@react-navigation+material-top-tabs+7.4.19+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+material-top-tabs+7.4.19+001+fix-failing-jest-by-disabling-esmodule.patch) -#### [@react-navigation+native-stack+7.14.5+002+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+native-stack+7.14.5+002+fix-failing-jest-by-disabling-esmodule.patch) -#### [@react-navigation+native+7.1.33+002+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+native+7.1.33+002+fix-failing-jest-by-disabling-esmodule.patch) +#### [@react-navigation+bottom-tabs+7.19.2+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+bottom-tabs+7.19.2+001+fix-failing-jest-by-disabling-esmodule.patch) +#### [@react-navigation+core+7.22.1+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+core+7.22.1+001+fix-failing-jest-by-disabling-esmodule.patch) +#### [@react-navigation+elements+2.9.43+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+elements+2.9.43+001+fix-failing-jest-by-disabling-esmodule.patch) +#### [@react-navigation+material-top-tabs+7.7.2+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+material-top-tabs+7.7.2+001+fix-failing-jest-by-disabling-esmodule.patch) +#### [@react-navigation+native-stack+7.19.2+002+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+native-stack+7.19.2+002+fix-failing-jest-by-disabling-esmodule.patch) +#### [@react-navigation+native+7.4.1+002+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+native+7.4.1+002+fix-failing-jest-by-disabling-esmodule.patch) #### [@react-navigation+routers+7.6.4+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+routers+7.6.4+001+fix-failing-jest-by-disabling-esmodule.patch) -#### [@react-navigation+stack+7.8.5+004+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+stack+7.8.5+004+fix-failing-jest-by-disabling-esmodule.patch) +#### [@react-navigation+stack+7.11.2+004+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+stack+7.11.2+004+fix-failing-jest-by-disabling-esmodule.patch) - Reason: Necessary to run Jest with the obligatory `--experimental-vm-modules` flag. Currently we transpile all the code to `commonjs`, but Jest looks up to individual `package.jsons` to see whether the package is `commonjs` or `ESModule`. That causes a conflict, which can be solved by removing `{"type":"module"}` from `lib/module/package.json`. This might be an issue with Jest, but it would require much more investigation. More: https://github.com/react-navigation/react-navigation/issues/12637 - Upstream PR/issue: https://github.com/react-navigation/react-navigation/issues/12637 @@ -16,65 +16,58 @@ - PR Introducing Patch: [#64155](https://github.com/Expensify/App/pull/64155) - PR Updating Patch: N/A -### [@react-navigation+core+7.21.12+002+platform-navigation-stack-types.patch](@react-navigation+core+7.21.12+002+platform-navigation-stack-types.patch) +### [@react-navigation+core+7.22.1+002+platform-navigation-stack-types.patch](@react-navigation+core+7.22.1+002+platform-navigation-stack-types.patch) - Reason: Enables passing custom `ScreenOptions` and adjusts typing to have everything fully type-checked and make sure only the proper (common) platform-specific options are passed - Upstream PR/issue: N/A - E/App issue: [#29948](https://github.com/Expensify/App/issues/29948) - PR Introducing Patch: [#37891](https://github.com/Expensify/App/pull/37891) -- PR Updating Patch: [#64155](https://github.com/Expensify/App/pull/64155), [#98097](https://github.com/Expensify/App/pull/98097) +- PR Updating Patch: [#64155](https://github.com/Expensify/App/pull/64155), [#98097](https://github.com/Expensify/App/pull/98097), [#101715](https://github.com/Expensify/App/issues/101715) +- Note: Re-ported onto 7.22.1. Every hunk applied without changes. +- Note: Core 7.22.0 made `useNavigationBuilder` return a `render` callback and deprecated the `NavigationContent` output it returned before. Our only builder call site, `createPlatformStackNavigatorComponent` (shared by `createRootStackNavigator`, `createRightModalNavigator`, `createSplitNavigator` and `createSearchFullscreenNavigator`), still uses `NavigationContent`, and the `convertCustomScreenOptions` argument this patch adds has no upstream equivalent, so switch to `render` and re-check both together when `NavigationContent` eventually disappears. -### [@react-navigation+native+7.1.33+001+initial.patch](@react-navigation+native+7.1.33+001+initial.patch) +### [@react-navigation+core+7.22.1+003+root-state-not-undefined.patch](@react-navigation+core+7.22.1+003+root-state-not-undefined.patch) + +- Reason: Keep `NavigationContainerRef.getRootState()` typed as `NavigationState` instead of `NavigationState | undefined`. Types only, no runtime change: the implementation already returned `undefined` before the container ref mounted in 7.21.12, the type just did not admit it. Around 120 call sites in `src/` alone (plus examples such as `navigationRef.current?.getRootState().routes` in the navigation tests) hand the result straight to helpers that require a state object, and they all run behind the app's readiness gate (`NavigationRoot`'s `onReady`, `navigationRef.isReady()`), so guarding each one is a separate refactor rather than part of an upgrade. +- Upstream PR/issue: https://github.com/react-navigation/react-navigation/commit/4e6eaaac6fad57d09ab5a828484b8ffeb4cfc6e5 (core 7.21.13, "fix return type of getRootState") +- E/App issue: [#101715](https://github.com/Expensify/App/issues/101715) +- PR Introducing Patch: N/A +- Note: Drop this patch once call sites handle the missing state explicitly (a guarded `getRootState()` helper in `src/libs/Navigation` would cover most of them). Until then, calling `getRootState()` before the container mounts logs react-navigation's `NOT_INITIALIZED_ERROR` and returns `undefined` at runtime while the type claims otherwise, same as before the upgrade. + +### [@react-navigation+native+7.4.1+001+initial.patch](@react-navigation+native+7.4.1+001+initial.patch) - Reason: Allows us to use some more advanced navigation actions without messing up the browser history - Upstream PR/issue: https://github.com/react-navigation/react-navigation/pull/12751 (`route.history` + `pushParams`, added upstream for this use case); originating issue https://github.com/react-navigation/react-navigation/issues/12460. On the v8 upgrade, evaluate adopting `pushParams` to shrink this patch. - E/App issue: [#21356](https://github.com/Expensify/App/issues/21356) - PR Introducing Patch: [#24165](https://github.com/Expensify/App/pull/24165) -- PR Updating Patch: [#32087](https://github.com/Expensify/App/pull/32087) [#42465](https://github.com/Expensify/App/pull/42465) [#64155](https://github.com/Expensify/App/pull/64155) +- PR Updating Patch: [#32087](https://github.com/Expensify/App/pull/32087) [#42465](https://github.com/Expensify/App/pull/42465) [#64155](https://github.com/Expensify/App/pull/64155) [#103346](https://github.com/Expensify/App/pull/103346) [#101715](https://github.com/Expensify/App/issues/101715) +- Note: Re-ported onto 7.4.1. `createMemoryHistory` and `useLinking` were refactored upstream (`getPathWithoutHash`, `getHistoryLength`, `pendingPopStateDelta`, `isPoppingLastEntry`), so the hunks were re-anchored instead of applied verbatim: `backIndex` keeps upstream's hash handling and adds the focused-route-key match, and `useLinking` keeps upstream's `pendingPopStateDelta` guard and `getHistoryLength` delta while re-adding `historyDeltaByKeys`/`getStaleHistoryDiff`. Two parts of the old patch became redundant and were dropped: the `findFocusedRouteKey` helper file now matches upstream's own module, and the extra `history.replace` right after `history.go(nextIndex - currentIndex)` duplicated the replace that upstream already runs at the end of the same `try` block. -### [@react-navigation+stack+7.8.5+001+edge-drag-gesture.patch](@react-navigation+stack+7.8.5+001+edge-drag-gesture.patch) +### [@react-navigation+stack+7.11.2+001+edge-drag-gesture.patch](@react-navigation+stack+7.11.2+001+edge-drag-gesture.patch) - Reason: Adds `edgeDragGestureMonitor` implementation - Upstream PR/issue: N/A - E/App issue: [#15849](https://github.com/Expensify/App/issues/15849) - PR Introducing Patch: [#18402](https://github.com/Expensify/App/pull/18402) -- PR Updating Patch: [#22678](https://github.com/Expensify/App/pull/22678) [#22437](https://github.com/Expensify/App/pull/22437) [#64155](https://github.com/Expensify/App/pull/64155) +- PR Updating Patch: [#22678](https://github.com/Expensify/App/pull/22678) [#22437](https://github.com/Expensify/App/pull/22437) [#64155](https://github.com/Expensify/App/pull/64155) [#101715](https://github.com/Expensify/App/issues/101715) +- Note: Re-ported onto 7.11.2. The added and removed lines are unchanged. Only the surrounding context moved, because 7.10.x wrapped the gesture animation in `animationIdRef`/`isAnimatingRef` guards. 7.11.2 did not touch `Card.js` or `CardStack.js`, so the patch body is identical to the 7.11.1 one. `resetExpectingTouchendWithDelay()` still runs after those guards, so a stale animation callback that returns early no longer resets the monitor. -### [@react-navigation+stack+7.8.5+002+dontDetachScreen.patch](@react-navigation+stack+7.8.5+002+dontDetachScreen.patch) +### [@react-navigation+stack+7.11.2+002+dontDetachScreen.patch](@react-navigation+stack+7.11.2+002+dontDetachScreen.patch) - Reason: Prevents the second screen in the stack from being detached too early. Additionally, setting `zIndex: Platform.OS === 'web' ? 'auto' : undefined` helps avoid issues in Safari where the home screen might be hidden due to `overflow: hidden`. - Upstream PR/issue: N/A - E/App issue: [#22372](https://github.com/Expensify/App/issues/22372) - PR Introducing Patch: [#22437](https://github.com/Expensify/App/pull/22437) -- PR Updating Patch: [#33280](https://github.com/Expensify/App/pull/33280) [#37421](https://github.com/Expensify/App/pull/37421) [#49539](https://github.com/Expensify/App/pull/49539) [#64155](https://github.com/Expensify/App/pull/64155) [#65119](https://github.com/Expensify/App/issues/65119) +- PR Updating Patch: [#33280](https://github.com/Expensify/App/pull/33280) [#37421](https://github.com/Expensify/App/pull/37421) [#49539](https://github.com/Expensify/App/pull/49539) [#64155](https://github.com/Expensify/App/pull/64155) [#65119](https://github.com/Expensify/App/issues/65119) [#101715](https://github.com/Expensify/App/issues/101715) - Note: Not fully covered by the public `detachPreviousScreen` option (this also forces `activityState`). v8 replaces `detachInactiveScreens`/`detachPreviousScreen`/`freezeOnBlur` with a single `inactiveBehavior` option — re-evaluate this patch then. +- Note: Re-ported onto 7.11.2. `STATE_TRANSITIONING_OR_BELOW_TOP` is now a module-level constant upstream, so the patch no longer declares it. 7.11.2 (https://github.com/react-navigation/react-navigation/pull/13255) removed the `overflow: active ? undefined : 'hidden'` line from `CardA11yWrapper`, so the `zIndex` hunk was re-anchored on the line above it. The added lines are unchanged. With that `overflow: hidden` gone from `CardA11yWrapper`, the Safari case the `zIndex` part was added for may no longer happen. That has not been verified, so the `zIndex` part stays. -### [@react-navigation+native+7.1.33+003+increase-history-go-popstate-fallback-timeout.patch](@react-navigation+native+7.1.33+003+increase-history-go-popstate-fallback-timeout.patch) +### Removed patches -- Reason: +- `@react-navigation+native+7.1.33+003+increase-history-go-popstate-fallback-timeout.patch` was dropped: upstream raised the `createMemoryHistory.go()` fallback timeout from 100ms to 1000ms in 7.3.18 (https://github.com/react-navigation/react-navigation/pull/13217), with the same Firefox rationale as our patch. Original context: [#94571](https://github.com/Expensify/App/issues/94571) / [#95980](https://github.com/Expensify/App/pull/95980), upstream issue https://github.com/react-navigation/react-navigation/issues/11145. - ``` - createMemoryHistory.go() waits for the browser's popstate after history.go(n) but gives up - after a 100ms fallback timer and deletes its pending record of the traversal. Firefox can - take 500ms+ to deliver the popstate when the main thread is busy (e.g. rendering the newly - created group chat report). Once the record is deleted, the late popstate is misclassified - by listen() as a user-initiated back/forward navigation and react-navigation resets state - to the previous route. Raising the timeout to 1000ms keeps the pending record alive until - the traversal actually settles. Fixes group/DM creation not opening the new report on - Firefox (#94571). - ``` + One behaviour difference is worth a Firefox regression check on #94571: when the timeout does fire, upstream removes its own `popstate` listener and rejects the `go()` promise if the browser hasn't landed on the expected entry (`window.history.state?.id !== targetId`), whereas it used to resolve. That check came from 7.3.8 (https://github.com/react-navigation/react-navigation/commit/788d7c565554d85fc7d3c3b9d1a7829e1847b8da), not from the timeout change. Rejecting skips the trailing `history.replace`, which upstream documents as deliberate: the entry we are on may not be the one we intended to land on. Both variants still deliver a late `popstate` to external listeners, so this only changes what happens to the history record in the >1s case. -- Upstream PR/issue: https://github.com/react-navigation/react-navigation/issues/11145 -- E/App issue: [#94571](https://github.com/Expensify/App/issues/94571) -- PR Introducing Patch: [#95980](https://github.com/Expensify/App/pull/95980) + `tests/unit/Navigation/createMemoryHistoryTest.ts` covered the old contract and now asserts the new one: a timed-out traversal rejects, and the in-time case points `window.history.state` at the target entry first, the way a real traversal does. -### [@react-navigation+stack+7.8.5+005+clip-inactive-card-inside-card.patch](@react-navigation+stack+7.8.5+005+clip-inactive-card-inside-card.patch) - -- Reason: On the web floating RHP each stacked card draws its own drop shadow through the interpolator's `cardStyle` (`getRHPExtendedCardFrame`). `CardA11yWrapper` puts `overflow: hidden` on every card that is not on top, and the wrapper is sized to the navigator frame, so it clips the shadow of the card under the focused one. Opening an expense over an expense report cut the report card's left and bottom shadow. The patch moves that clip from `CardA11yWrapper` to the `Animated.View` in `Card` that receives `cardStyle`. That view paints the shadow, and `overflow: hidden` does not clip an element's own shadow, so the shadow stays and the content is still clipped. `CardContainer` passes `active` down to `Card`. -- Upstream PR/issue: https://github.com/react-navigation/react-navigation/issues/13254, fix in https://github.com/react-navigation/react-navigation/pull/13255. Upstream declined an opt-out option and asked for this instead. The patch is that change applied to the compiled 7.8.5 output. -- E/App issue: N/A, the patch came out of the internal web floating-RHP styling work. Removing it is tracked in [#101715](https://github.com/Expensify/App/issues/101715), the react-navigation upgrade. -- PR Introducing Patch: [#101093](https://github.com/callstack-internal/Expensify-App/pull/101093) -- PR Updating Patch: N/A -- Note: Not gated to the RHP. Every stack now clips inactive cards one level lower, so the built-in `styles.shadow` edge view inside `Card` is clipped on inactive cards too. The active card covers that edge. -- Note: Upstream shipped the fix in `@react-navigation/stack` 7.11.2, first release containing https://github.com/react-navigation/react-navigation/pull/13255. Their shape differs from ours: `CardA11yWrapper` drops `overflow` completely, and `CardContainer` passes `pageOverflowEnabled: active && headerMode !== 'float' && presentation !== 'modal'`, so the clip lands in `CardContent`, whose `styles.card` still carries `overflow: 'hidden'`. Inactive cards stay clipped and the stacked RHP shadow survives, but the clip sits one level below ours, which leaves the built-in `styles.shadow` edge view unclipped on inactive cards. -- Note: Drop this patch by moving to `stack` 7.11.2 or newer, see #101715 above. It cannot be bumped on its own: `createStackNavigator` there reads `render` from `useNavigationBuilder`, which starts in `core` 7.22.1, and `elements` 2.9.43 plus every sibling declare `native ^7.4.1` as a peer, so npm rejects 7.11.2 next to our `native` 7.1.33. `stack+001+edge-drag-gesture` and `stack+002+dontDetachScreen` need hand-porting to 7.11.2 because it rewrote the gesture and animation lifecycle in `Card.js` and switched the gesture wrapper to `pointerEvents: "auto"`. +- `@react-navigation+stack+7.8.5+005+clip-inactive-card-inside-card.patch` was dropped: upstream shipped the same fix in `@react-navigation/stack` 7.11.2 (https://github.com/react-navigation/react-navigation/pull/13255, reported in https://github.com/react-navigation/react-navigation/issues/13254). The patch came from [#101093](https://github.com/callstack-internal/Expensify-App/pull/101093) to keep the shadow of the card under the focused one on the web floating RHP. Upstream drops `overflow` from `CardA11yWrapper` and clips inactive cards in `CardContent` through `pageOverflowEnabled: active && headerMode !== 'float' && presentation !== 'modal'`, so the stacked RHP shadow survives the same way. The clip sits one level below where our patch put it, so the built-in `styles.shadow` edge view inside `Card` is no longer clipped on inactive cards. The active card covers that edge. diff --git a/patches/sentry-browser/@sentry+browser+10.73.0.patch b/patches/sentry-browser/@sentry+browser+10.75.0.patch similarity index 100% rename from patches/sentry-browser/@sentry+browser+10.73.0.patch rename to patches/sentry-browser/@sentry+browser+10.75.0.patch diff --git a/patches/sentry-browser/details.md b/patches/sentry-browser/details.md index c93b65ba26d2..d77e09c97dc8 100644 --- a/patches/sentry-browser/details.md +++ b/patches/sentry-browser/details.md @@ -1,6 +1,6 @@ # `@sentry/browser` patches -### [@sentry+browser+10.73.0.patch](@sentry+browser+10.73.0.patch) +### [@sentry+browser+10.75.0.patch](@sentry+browser+10.75.0.patch) Reason: improve developer experience by adding `request-id` to spans related to API calls. This takes request-id from response headers and write it to the span related to that response. With that information it is easier to connect backend request / logs with data retrieved from Sentry. - Upstream PR/issue: N/A diff --git a/patches/sentry-core/@sentry+core+10.73.0+001+initial.patch b/patches/sentry-core/@sentry+core+10.75.0+001+initial.patch similarity index 100% rename from patches/sentry-core/@sentry+core+10.73.0+001+initial.patch rename to patches/sentry-core/@sentry+core+10.75.0+001+initial.patch diff --git a/patches/sentry-core/details.md b/patches/sentry-core/details.md index 4639dcb13add..c08ddd227f0d 100644 --- a/patches/sentry-core/details.md +++ b/patches/sentry-core/details.md @@ -1,6 +1,6 @@ # `@sentry/core` patches -### [@sentry+core+10.73.0+001+initial.patch](@sentry+core+10.73.0+001+initial.patch) +### [@sentry+core+10.75.0+001+initial.patch](@sentry+core+10.75.0+001+initial.patch) - Reason: Enhances the `htmlTreeAsString` function to support `data-sentry-label` attributes for better element identification in Sentry spans. The patch: - Always includes `data-sentry-label` in the list of checked attributes for each DOM element diff --git a/patches/sentry-react-native/@sentry+react-native+8.25.0.patch b/patches/sentry-react-native/@sentry+react-native+8.27.0.patch similarity index 98% rename from patches/sentry-react-native/@sentry+react-native+8.25.0.patch rename to patches/sentry-react-native/@sentry+react-native+8.27.0.patch index 55ec959b4a5e..2c20acf17fd3 100644 --- a/patches/sentry-react-native/@sentry+react-native+8.25.0.patch +++ b/patches/sentry-react-native/@sentry+react-native+8.27.0.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@sentry/react-native/scripts/sentry_utils.rb b/node_modules/@sentry/react-native/scripts/sentry_utils.rb -index 8d6e899..ed1c088 100644 +index 8c16524..47d1f92 100644 --- a/node_modules/@sentry/react-native/scripts/sentry_utils.rb +++ b/node_modules/@sentry/react-native/scripts/sentry_utils.rb @@ -1,8 +1,24 @@ diff --git a/patches/sentry-react-native/details.md b/patches/sentry-react-native/details.md index ded6d2fbd54b..dc65c65735e5 100644 --- a/patches/sentry-react-native/details.md +++ b/patches/sentry-react-native/details.md @@ -1,6 +1,6 @@ # `@sentry/react-native` patches -### [@sentry+react-native+8.25.0.patch](@sentry+react-native+8.25.0.patch) +### [@sentry+react-native+8.27.0.patch](@sentry+react-native+8.27.0.patch) - Reason: Fixes React Native path resolution in the Sentry Ruby script during iOS builds. The original implementation failed to locate `react-native/package.json` in our hybrid app setup. The patch changes the resolution strategy to first try resolving from the installation root (similar to react-native-svg and react-native-reanimated), and falls back to ENV variables with support for both node_modules conventions. - Upstream PR/issue: N/A (specific to our hybrid app environment) diff --git a/patches/whatwg-fetch/details.md b/patches/whatwg-fetch/details.md deleted file mode 100644 index 26f043b4e76a..000000000000 --- a/patches/whatwg-fetch/details.md +++ /dev/null @@ -1,13 +0,0 @@ -# `whatwg-patches` patches - -### [whatwg-fetch+3.6.2+001+use-microtask-queue-for-xhr-events.patch](whatwg-fetch+3.6.2+001+use-microtask-queue-for-xhr-events.patch) - -- Reason: - - ``` - This patch replaces setTimeout(0) with queueMicrotask for XHR event handlers within the whatwg-fetch polyfill. - ``` - -- Upstream PR/issue: https://github.com/JakeChampion/fetch/issues/1484 -- E/App issue: https://github.com/Expensify/App/issues/90309 -- PR introducing patch: https://github.com/Expensify/App/pull/94399 diff --git a/patches/whatwg-fetch/whatwg-fetch+3.6.2+001+use-microtask-queue-for-xhr-events.patch b/patches/whatwg-fetch/whatwg-fetch+3.6.2+001+use-microtask-queue-for-xhr-events.patch deleted file mode 100644 index 3785dcfe07e0..000000000000 --- a/patches/whatwg-fetch/whatwg-fetch+3.6.2+001+use-microtask-queue-for-xhr-events.patch +++ /dev/null @@ -1,80 +0,0 @@ -diff --git a/node_modules/whatwg-fetch/dist/fetch.umd.js b/node_modules/whatwg-fetch/dist/fetch.umd.js -index 8c3269e..b3bad8f 100644 ---- a/node_modules/whatwg-fetch/dist/fetch.umd.js -+++ b/node_modules/whatwg-fetch/dist/fetch.umd.js -@@ -525,27 +525,27 @@ - }; - options.url = 'responseURL' in xhr ? xhr.responseURL : options.headers.get('X-Request-URL'); - var body = 'response' in xhr ? xhr.response : xhr.responseText; -- setTimeout(function() { -+ queueMicrotask(function() { - resolve(new Response(body, options)); -- }, 0); -+ }); - }; - - xhr.onerror = function() { -- setTimeout(function() { -+ queueMicrotask(function() { - reject(new TypeError('Network request failed')); -- }, 0); -+ }); - }; - - xhr.ontimeout = function() { -- setTimeout(function() { -+ queueMicrotask(function() { - reject(new TypeError('Network request failed')); -- }, 0); -+ }); - }; - - xhr.onabort = function() { -- setTimeout(function() { -+ queueMicrotask(function() { - reject(new exports.DOMException('Aborted', 'AbortError')); -- }, 0); -+ }); - }; - - function fixUrl(url) { -diff --git a/node_modules/whatwg-fetch/fetch.js b/node_modules/whatwg-fetch/fetch.js -index 5050a24..f70dbe6 100644 ---- a/node_modules/whatwg-fetch/fetch.js -+++ b/node_modules/whatwg-fetch/fetch.js -@@ -519,27 +519,27 @@ export function fetch(input, init) { - } - options.url = 'responseURL' in xhr ? xhr.responseURL : options.headers.get('X-Request-URL') - var body = 'response' in xhr ? xhr.response : xhr.responseText -- setTimeout(function() { -+ queueMicrotask(function() { - resolve(new Response(body, options)) -- }, 0) -+ }) - } - - xhr.onerror = function() { -- setTimeout(function() { -+ queueMicrotask(function() { - reject(new TypeError('Network request failed')) -- }, 0) -+ }) - } - - xhr.ontimeout = function() { -- setTimeout(function() { -+ queueMicrotask(function() { - reject(new TypeError('Network request failed')) -- }, 0) -+ }) - } - - xhr.onabort = function() { -- setTimeout(function() { -+ queueMicrotask(function() { - reject(new DOMException('Aborted', 'AbortError')) -- }, 0) -+ }) - } - - function fixUrl(url) { diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 1942b24fdff7..da41fff20baf 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -149,7 +149,6 @@ const actionableIgnoredScrapeStatuses: number[] = [434]; const cardHiddenFromSearchStates: number[] = [2, 4]; const selectableOnboardingChoices = { - JOIN_WORKSPACE: 'newDotJoinWorkspace', MANAGE_TEAM: 'newDotManageTeam', EMPLOYER: 'newDotEmployer', TRACK_BUSINESS: 'newDotTrackWorkspace', @@ -614,6 +613,23 @@ const CONST = { MAX_LENGTH: 83, }, + GENERATED_RULE: { + PROMPT_MAX_LENGTH: 6000, + STATE: { + RULE: 'rule', + UNSUPPORTED: 'unsupported', + MULTIPLE_RULES: 'multipleRules', + UNINTELLIGIBLE: 'unintelligible', + FAILED: 'failed', + }, + RULE_TYPE: { + REQUIRE_FIELDS: 'requireFields', + FLAG_FOR_REVIEW: 'flagForReview', + RESTRICT_CARD_SPEND: 'restrictCardSpend', + EXPENSE_DEFAULTS: 'expenseDefaults', + }, + }, + EXPORT_DOWNLOAD: { STATE: { PREPARING: 'preparing', @@ -1159,6 +1175,7 @@ const CONST = { MULTIPLE_APPROVERS: 'multipleApprovers', GLOBAL_REIMBURSEMENTS: 'globalReimbursements', GLOBAL_REIMBURSEMENT_FX: 'globalReimbursementFX', + WISE_REIMBURSEMENTS: 'wiseReimbursements', DEFAULT_LETTER_AVATARS: 'defaultLetterAvatars', TRAVEL_CODING_SYNC: 'travelCodingSync', CONCIERGE_RESPOND_IN_THREAD: 'conciergeRespondInThread', @@ -1941,6 +1958,7 @@ const CONST = { UPDATE_DEFAULT_TITLE_ENFORCED: 'POLICYCHANGELOG_UPDATE_DEFAULT_TITLE_ENFORCED', UPDATE_DISABLED_FIELDS: 'POLICYCHANGELOG_UPDATE_DISABLED_FIELDS', UPDATE_EMPLOYEE: 'POLICYCHANGELOG_UPDATE_EMPLOYEE', + UPDATE_EMPLOYEE_DISPLAY_NAME: 'POLICYCHANGELOG_UPDATE_EMPLOYEE_DISPLAY_NAME', UPDATE_EXPENSIFY_CARD_RULE: 'POLICYCHANGELOG_UPDATE_EXPENSIFY_CARD_RULE', UPDATE_FIELD: 'POLICYCHANGELOG_UPDATE_FIELD', UPDATE_ADDRESS: 'POLICYCHANGELOG_UPDATE_ADDRESS', @@ -2031,6 +2049,7 @@ const CONST = { AUTOSCROLL_TO_TOP_THRESHOLD: 250, LINKED_MESSAGE_OFFSET: 40, MAX_GROUPING_TIME: 300000, + MAX_SYSTEM_MESSAGE_COLLAPSE_TIME: 24 * 60 * 60 * 1000, }, CANCEL_PAYMENT_REASONS: { ADMIN: 'CANCEL_REASON_ADMIN', @@ -2370,6 +2389,8 @@ const CONST = { MFA_SCENARIO: 'mfa_scenario', MFA_ERROR_REASON: 'mfa_error_reason', BUILD_TYPE: 'build_type', + /** Written by Sentry's `thirdPartyErrorFilterIntegration` on events whose every frame is foreign to our bundle, read by the noise filters. */ + THIRD_PARTY_CODE: 'third_party_code', }, EXPENSE_ERROR_TYPE: { REPORT_CREATION_FAILED: 'report_creation_failed', @@ -2839,7 +2860,6 @@ const CONST = { }, ERROR_TYPE: { SOCKET: 'Expensify\\Auth\\Error\\Socket', - SESSION_MISMATCH: 'Expensify\\Error\\Auth\\AccountMismatch', }, ERROR_TITLE: { SOCKET: 'Issue connecting to database', @@ -3942,6 +3962,7 @@ const CONST = { AUTO_SYNC: 'autoSync', SYNC_REIMBURSED_REPORTS: 'syncReimbursedReports', BILL_PAYMENT_ACCOUNT_ID: 'billPaymentAccountID', + FX_EXPENSE_ACCOUNT_ID: 'fxExpenseAccountID', SYNC_EXPENSIFY_CARD_SETTLEMENTS: 'syncExpensifyCardSettlements', SETTLEMENTS_BANK_ACCOUNT_ID: 'settlementsBankAccountID', SYNC_TRAVEL_BILLING_SETTLEMENTS: 'syncTravelInvoicingSettlements', @@ -4047,7 +4068,7 @@ const CONST = { }, ZOHO_BOOKS_CONFIG: { - ORGANIZATION_ID: 'organizationID', + SUBSIDIARY_ID: 'subsidiaryID', ENABLE_NEW_CATEGORIES: 'enableNewCategories', SYNC_TAX_RATES: 'syncTaxRates', EXPORTER: 'exporter', @@ -4430,10 +4451,6 @@ const CONST = { MAX_RECENT_REPORTS_TO_SHOW: 5, MAX_RECENT_ATTENDEES: 40, - // Lets consumers tell a payment failure apart from other errors on a report preview. Numeric so it keeps - // sorting below microsecond keys in getLatestErrorMessageField, which picks the highest key. - PAY_FAILURE_PREVIEW_ERROR_KEY: 0, - // This will guranatee that the quantity input will not exceed 9,007,199,254,740,991 (Number.MAX_SAFE_INTEGER). QUANTITY_MAX_LENGTH: 12, // This is the transactionID used when going through the create expense flow so that it mimics a real transaction (like the edit flow) @@ -7402,6 +7419,12 @@ const CONST = { REPORT_FIELD_TITLE_FIELD_ID: 'text_title', + /** How many report fields are shown side by side in the report view on a wide layout */ + REPORT_FIELDS_PER_ROW: 3, + + /** Below this many options, a report field list is short enough to scan without a search input */ + REPORT_FIELD_LIST_SEARCH_THRESHOLD: 8, + MOBILE_PAGINATION_SIZE: 15, WEB_PAGINATION_SIZE: 30, @@ -7421,9 +7444,6 @@ const CONST = { // Returned when a user tries to add a work email that is a domain-controlled login for an existing account, so we can show a specific error message instead of the generic blocking screen subtitle WORK_DOMAIN_CONTROLLED_ERROR: '401 work domain is controlled', - - // Returned when an already validated public-domain account tries to add a work email, so we can show a takeover-protection error message instead of the generic blocking screen subtitle - WORK_EMAIL_VALIDATED_PUBLIC_DOMAIN_ERROR: '403 Forbidden', REIMBURSEMENT_ACCOUNT: { DEFAULT_DATA: { achData: { @@ -9412,9 +9432,6 @@ const CONST = { REVIEW_WORKSPACE_SETTINGS: 'reviewWorkspaceSettings', INVITE_ACCOUNTANT: 'inviteAccountant', ADD_EXPENSE_APPROVALS: 'addExpenseApprovals', - ADD_WORK_EMAIL: 'addWorkEmail', - VALIDATE_EMAIL: 'validateEmail', - JOIN_WORKSPACE: 'joinWorkspace', }, MODAL_EVENTS: { @@ -9711,9 +9728,11 @@ const CONST = { REPORT_ACTION_ITEM_THREAD: 'Report-ReportActionItemThread', THREAD_DIVIDER: 'Report-ThreadDivider', REPORT_ACTION_ITEM: 'Report-ReportActionItem', + COLLAPSED_SYSTEM_MESSAGES: 'Report-CollapsedSystemMessages', MODERATION_BUTTON: 'Report-ModerationButton', MONEY_REQUEST_REPORT_ACTIONS_LIST_SELECT_ALL: 'MoneyRequestReportActionsList-SelectAll', MONEY_REQUEST_REPORT_TRANSACTION_ITEM: 'MoneyRequestReportTransactionItem', + REPORT_FIELD_DELETE_BUTTON: 'Report-ReportFieldDeleteButton', REPORT_ACTION_AVATAR: 'Report-ReportActionAvatar', PARTICIPANTS_ROW: 'Report-ParticipantsRow', ROOM_MEMBERS_ROW: 'Report-RoomMembersRow', @@ -9915,6 +9934,9 @@ const CONST = { MAP_VIEW: { COMPASS: 'compass', }, + NEW_CHAT: { + IMPORT_CONTACTS_ITEM: 'NewChat-ImportContacts', + }, MONEY_REQUEST: { AMOUNT_NEXT_BUTTON: 'MoneyRequest-AmountNextButton', AMOUNT_PAY_BUTTON: 'MoneyRequest-AmountPayButton', @@ -10129,12 +10151,14 @@ const CONST = { MERCHANT_RULE_ITEM: 'WorkspaceRules-MerchantRuleItem', REQUIRE_FIELDS_RULE_ITEM: 'WorkspaceRules-RequireFieldsRuleItem', REQUIRE_FIELDS_RULE_SAVE: 'WorkspaceRules-RequireFieldsRuleSave', + REQUIRE_FIELDS_RULE_DESCRIBE: 'WorkspaceRules-RequireFieldsRuleDescribe', REQUIRE_FIELDS_RULE_DELETE: 'WorkspaceRules-RequireFieldsRuleDelete', REQUIRE_FIELDS_RULE_CATEGORY: 'WorkspaceRules-RequireFieldsRuleCategory', REQUIRE_FIELDS_RULE_FIELD_TOGGLE: 'WorkspaceRules-RequireFieldsRuleFieldToggle', REQUIRE_FIELDS_RULE_DIRECTION_TOGGLE: 'WorkspaceRules-RequireFieldsRuleDirectionToggle', FLAG_FOR_REVIEW_RULE_ITEM: 'WorkspaceRules-FlagForReviewRuleItem', FLAG_FOR_REVIEW_RULE_SAVE: 'WorkspaceRules-FlagForReviewRuleSave', + FLAG_FOR_REVIEW_RULE_DESCRIBE: 'WorkspaceRules-FlagForReviewRuleDescribe', FLAG_FOR_REVIEW_RULE_DELETE: 'WorkspaceRules-FlagForReviewRuleDelete', FLAG_FOR_REVIEW_RULE_CATEGORY: 'WorkspaceRules-FlagForReviewRuleCategory', FLAG_FOR_REVIEW_RULE_AMOUNT: 'WorkspaceRules-FlagForReviewRuleAmount', @@ -10147,12 +10171,14 @@ const CONST = { IMPORT_MERCHANT_RULES: 'WorkspaceRules-ImportMerchantRules', MERCHANT_RULE_SECTION_ITEM: 'WorkspaceRules-MerchantRuleSectionItem', MERCHANT_RULE_SAVE: 'WorkspaceRules-MerchantRuleSave', + MERCHANT_RULE_DESCRIBE: 'WorkspaceRules-MerchantRuleDescribe', MERCHANT_RULE_PREVIEW_MATCHES: 'WorkspaceRules-MerchantRulePreviewMatches', MERCHANT_RULE_DELETE: 'WorkspaceRules-MerchantRuleDelete', CATEGORY_SELECTOR: 'WorkspaceRules-CategorySelector', CURRENCY_SELECTOR: 'WorkspaceRules-CurrencySelector', SPEND_RULE_SECTION_ITEM: 'WorkspaceRules-SpendRuleSectionItem', SPEND_RULE_SAVE: 'WorkspaceRules-SpendRuleSave', + SPEND_RULE_DESCRIBE: 'WorkspaceRules-SpendRuleDescribe', SPEND_RULE_DELETE: 'WorkspaceRules-SpendRuleDelete', SPEND_RULE_RESTRICTION_TYPE: 'WorkspaceRules-SpendRuleRestrictionType', AGENT_RULE_ITEM: 'WorkspaceRules-AgentRuleItem', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index b03fd4aec992..67aed9f0ba9c 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -496,6 +496,9 @@ const ONYXKEYS = { /** Is loading agent rule suggestions for the add-rule Suggestions tab? */ IS_LOADING_AGENT_TEMPLATES: 'isLoadingAgentTemplates', + /** The rule generated from an admin's description */ + GENERATED_RULE: 'generatedRule', + /** Set when we are loading fresh subscription/billing data from the server */ IS_LOADING_SUBSCRIPTION_DATA: 'isLoadingSubscriptionData', @@ -679,6 +682,9 @@ const ONYXKEYS = { /** Stores iframe link to verify 3DS flow for subscription */ VERIFY_3DS_SUBSCRIPTION: 'verify3dsSubscription', + /** Screen name that initiated the current 3DS verification, so only that screen reacts to the link by navigating */ + VERIFY_3DS_SUBSCRIPTION_SOURCE: 'verify3dsSubscriptionSource', + /** Holds the checks used while transferring the ownership of the workspace */ POLICY_OWNERSHIP_CHANGE_CHECKS: 'policyOwnershipChangeChecks', @@ -693,6 +699,9 @@ const ONYXKEYS = { /** Indicates whether ClearOutstandingBalance is pending */ SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING: 'subscriptionRetryBillingStatusPending', + /** Indicates whether a VerifySetupIntent request is in flight (gates the Authenticate payment button) */ + SUBSCRIPTION_VERIFY_SETUP_INTENT_PENDING: 'subscriptionVerifySetupIntentPending', + /** Stores info during review duplicates flow */ REVIEW_DUPLICATES: 'reviewDuplicates', @@ -1158,6 +1167,8 @@ const ONYXKEYS = { WORKSPACE_DESCRIPTION_FORM: 'workspaceDescriptionForm', WORKSPACE_MEMBER_CUSTOM_FIELD_FORM: 'WorkspaceMemberCustomFieldForm', WORKSPACE_MEMBER_CUSTOM_FIELD_FORM_DRAFT: 'WorkspaceMemberCustomFieldFormDraft', + WORKSPACE_MEMBER_DISPLAY_NAME_FORM: 'WorkspaceMemberDisplayNameForm', + WORKSPACE_MEMBER_DISPLAY_NAME_FORM_DRAFT: 'WorkspaceMemberDisplayNameFormDraft', WORKSPACE_DESCRIPTION_FORM_DRAFT: 'workspaceDescriptionFormDraft', WORKSPACE_CLIENT_ID_FORM: 'workspaceClientIDForm', WORKSPACE_CLIENT_ID_FORM_DRAFT: 'workspaceClientIDFormDraft', @@ -1401,6 +1412,8 @@ const ONYXKEYS = { EDIT_AGENT_PROMPT_FORM_DRAFT: 'editAgentPromptFormDraft', ADD_AGENT_RULE_FORM: 'addAgentRuleForm', ADD_AGENT_RULE_FORM_DRAFT: 'addAgentRuleFormDraft', + NEW_RULE_PROMPT_FORM: 'newRulePromptForm', + NEW_RULE_PROMPT_FORM_DRAFT: 'newRulePromptFormDraft', EDIT_AGENT_RULE_FORM: 'editAgentRuleForm', EDIT_AGENT_RULE_FORM_DRAFT: 'editAgentRuleFormDraft', RILLET_CREDENTIALS_FORM: 'rilletCredentialsForm', @@ -1491,6 +1504,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.WORKSPACE_DESCRIPTION_FORM]: FormTypes.WorkspaceDescriptionForm; [ONYXKEYS.FORMS.WORKSPACE_CLIENT_ID_FORM]: FormTypes.WorkspaceClientIDForm; [ONYXKEYS.FORMS.WORKSPACE_MEMBER_CUSTOM_FIELD_FORM]: FormTypes.WorkspaceMemberCustomFieldsForm; + [ONYXKEYS.FORMS.WORKSPACE_MEMBER_DISPLAY_NAME_FORM]: FormTypes.DisplayNameForm; [ONYXKEYS.FORMS.WALLET_ADDITIONAL_DETAILS]: FormTypes.AdditionalDetailStepForm; [ONYXKEYS.FORMS.POLICY_TAG_NAME_FORM]: FormTypes.PolicyTagNameForm; [ONYXKEYS.FORMS.WORKSPACE_NEW_TAX_FORM]: FormTypes.WorkspaceNewTaxForm; @@ -1565,6 +1579,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.EDIT_AGENT_NAME_FORM]: FormTypes.EditAgentNameForm; [ONYXKEYS.FORMS.EDIT_AGENT_PROMPT_FORM]: FormTypes.EditAgentPromptForm; [ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM]: FormTypes.AddAgentRuleForm; + [ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM]: FormTypes.NewRulePromptForm; [ONYXKEYS.FORMS.EDIT_AGENT_RULE_FORM]: FormTypes.EditAgentRuleForm; [ONYXKEYS.FORMS.RILLET_CREDENTIALS_FORM]: FormTypes.RilletCredentialsForm; [ONYXKEYS.FORMS.DUALENTRY_CREDENTIALS_FORM]: FormTypes.DualEntryCredentialsForm; @@ -1814,6 +1829,7 @@ type OnyxValuesMapping = { [ONYXKEYS.IS_LOADING_BULK_CHANGE_APPROVER_PAGE]: boolean; [ONYXKEYS.IS_LOADING_POLICY_CODING_RULES_PREVIEW]: boolean; [ONYXKEYS.IS_LOADING_AGENT_RULE_SUGGESTIONS]: boolean; + [ONYXKEYS.GENERATED_RULE]: OnyxTypes.GeneratedRule; [ONYXKEYS.IS_LOADING_AGENT_TEMPLATES]: boolean; [ONYXKEYS.IS_LOADING_REPORT_DATA]: boolean; [ONYXKEYS.IS_SEARCH_FILTERS_CARD_DATA_LOADED]: boolean; @@ -1846,6 +1862,7 @@ type OnyxValuesMapping = { [ONYXKEYS.MY_DOMAIN_SECURITY_GROUPS]: Record; [ONYXKEYS.RAM_ONLY_DOMAIN_MEMBERS_SELECTED_FOR_MOVE]: string[]; [ONYXKEYS.VERIFY_3DS_SUBSCRIPTION]: string; + [ONYXKEYS.VERIFY_3DS_SUBSCRIPTION_SOURCE]: string; [ONYXKEYS.PREFERRED_THEME]: ValueOf; [ONYXKEYS.SIGN_IN_HIGH_CONTRAST_INTENT]: boolean; [ONYXKEYS.MAPBOX_ACCESS_TOKEN]: OnyxTypes.MapboxAccessToken; @@ -1893,6 +1910,7 @@ type OnyxValuesMapping = { [ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED]: boolean; [ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL]: boolean; [ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING]: boolean; + [ONYXKEYS.SUBSCRIPTION_VERIFY_SETUP_INTENT_PENDING]: boolean; [ONYXKEYS.NVP_TRAVEL_SETTINGS]: OnyxTypes.TravelSettings; [ONYXKEYS.REVIEW_DUPLICATES]: OnyxTypes.ReviewDuplicates; [ONYXKEYS.ADD_NEW_COMPANY_CARD]: OnyxTypes.AddNewCompanyCardFeed; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 3f7300a0957d..509a6266186e 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -127,8 +127,6 @@ const DYNAMIC_ROUTES = { SCREENS.WORKSPACE.TRAVEL, SCREENS.WORKSPACES_ADD_DOMAIN, ], - getRoute: (isJoinWorkspaceTask?: boolean) => getUrlWithParams('verify-account', {isJoinWorkspaceTask: isJoinWorkspaceTask ? 'true' : undefined}), - queryParams: ['isJoinWorkspaceTask'], }, CONTACT_METHODS: { path: 'contact-methods', @@ -237,13 +235,22 @@ const DYNAMIC_ROUTES = { }), queryParams: ['setupType', 'isNonUSDSetup'], }, + CARD_AUTHENTICATION: { + path: 'card-authentication', + entryScreens: [ + SCREENS.SETTINGS.SUBSCRIPTION.ROOT, + SCREENS.SETTINGS.SUBSCRIPTION.ADD_PAYMENT_CARD, + SCREENS.SAVE_THE_WORLD.ADD_PAYMENT_CARD, + SCREENS.WORKSPACE.DYNAMIC_OWNER_CHANGE_CHECK, + ], + }, OWNER_SELECTOR: { path: 'owner-selector', entryScreens: [SCREENS.WORKSPACE_CONFIRMATION.DYNAMIC_ROOT], }, WORKSPACE_CONFIRMATION: { path: 'workspace/confirmation', - entryScreens: [SCREENS.HOME, SCREENS.INBOX, SCREENS.REPORT, SCREENS.SEARCH.ROOT, SCREENS.WORKSPACES_LIST, SCREENS.SETTINGS.ROOT], + entryScreens: ['*'], }, WORKSPACE_CONFIRMATION_CURRENCY: { path: 'currency', @@ -283,7 +290,12 @@ const DYNAMIC_ROUTES = { }, PAYMENT_CARD_CURRENCY_SELECTOR: { path: 'payment-card-currency', - entryScreens: [SCREENS.SETTINGS.SUBSCRIPTION.CHANGE_BILLING_CURRENCY, SCREENS.SETTINGS.SUBSCRIPTION.ADD_PAYMENT_CARD, SCREENS.WORKSPACE.DYNAMIC_OWNER_CHANGE_CHECK], + entryScreens: [ + SCREENS.SETTINGS.SUBSCRIPTION.CHANGE_BILLING_CURRENCY, + SCREENS.SETTINGS.SUBSCRIPTION.ADD_PAYMENT_CARD, + SCREENS.SAVE_THE_WORLD.ADD_PAYMENT_CARD, + SCREENS.WORKSPACE.DYNAMIC_OWNER_CHANGE_CHECK, + ], }, REPORT_SETTINGS_NAME: { path: 'settings/name', @@ -674,8 +686,15 @@ const DYNAMIC_ROUTES = { PROFILE: { path: 'a/:accountID', entryScreens: ['*'], - getRoute: (accountID?: number, login?: string) => getUrlWithParams(`a/${accountID}`, {login}), - queryParams: ['login'], + getRoute: (accountID?: number, login?: string, policyID?: string) => getUrlWithParams(`a/${accountID}`, {login, policyID}), + queryParams: ['login', 'policyID'], + }, + PROFILE_DISPLAY_NAME: { + // `accountID` is carried as `memberAccountID` so it never collides with the `accountID` of the + // profile this is opened from. + path: 'display-name/:memberAccountID/:policyID', + entryScreens: [SCREENS.DYNAMIC_PROFILE], + getRoute: (memberAccountID: number, policyID: string) => `display-name/${memberAccountID}/${policyID}` as const, }, PROFILE_AVATAR: { path: 'avatar/:accountID', @@ -1026,6 +1045,10 @@ const DYNAMIC_ROUTES = { getRoute: (state = '', label = '') => `state${state ? `?state=${encodeURIComponent(state)}` : ''}${label ? `${state ? '&' : '?'}label=${encodeURIComponent(label)}` : ''}`, queryParams: ['state', 'label'], }, + ENABLE_PAYMENTS_CONFIRM_VALIDATE_CODE: { + path: 'wallet-confirm-validate-code', + entryScreens: [SCREENS.IOU_SEND.ENABLE_PAYMENTS, SCREENS.ENABLE_PAYMENTS_ROOT], + }, SETTINGS_CATEGORY_SETTINGS: { path: 'category-settings/:categoryName', entryScreens: [SCREENS.SETTINGS_CATEGORIES.SETTINGS_CATEGORIES_ROOT], @@ -4422,8 +4445,7 @@ const ROUTES = { ONBOARDING_PRIVATE_DOMAIN: { route: 'onboarding/private-domain', - getRoute: (backTo?: string, isJoinWorkspaceTask = false, reportID?: string) => - getUrlWithParams(getUrlWithBackToParam(`onboarding/private-domain`, backTo), {isJoinWorkspaceTask: isJoinWorkspaceTask ? 'true' : undefined, reportID}), + getRoute: (backTo?: string) => getUrlWithBackToParam(`onboarding/private-domain`, backTo), }, ONBOARDING_EMPLOYEES: { route: 'onboarding/employees', @@ -4448,22 +4470,17 @@ const ROUTES = { ONBOARDING_WORKSPACES: { route: 'onboarding/join-workspaces', - getRoute: (backTo?: string, isJoinWorkspaceTask = false, shouldCreateJoinWorkspaceTaskOnExit = false) => - getUrlWithParams(getUrlWithBackToParam(`onboarding/join-workspaces`, backTo), { - isJoinWorkspaceTask: isJoinWorkspaceTask ? 'true' : undefined, - shouldCreateJoinWorkspaceTaskOnExit: shouldCreateJoinWorkspaceTaskOnExit ? 'true' : undefined, - }), + getRoute: (backTo?: string) => getUrlWithBackToParam(`onboarding/join-workspaces`, backTo), }, ONBOARDING_WORK_EMAIL: { route: 'onboarding/work-email', - getRoute: (isJoinWorkspaceTask = false, reportID?: string) => getUrlWithParams('onboarding/work-email', {isJoinWorkspaceTask: isJoinWorkspaceTask ? 'true' : undefined, reportID}), + getRoute: () => 'onboarding/work-email' as const, }, ONBOARDING_WORK_EMAIL_VALIDATION: { route: 'onboarding/work-email-validation', - getRoute: (isJoinWorkspaceTask = false, reportID?: string) => - getUrlWithParams('onboarding/work-email-validation', {isJoinWorkspaceTask: isJoinWorkspaceTask ? 'true' : undefined, reportID}), + getRoute: () => 'onboarding/work-email-validation' as const, }, ONBOARDING_PERSONAL_TRACK_GOAL: { route: 'onboarding/personaltrackcase', @@ -4657,16 +4674,18 @@ const ROUTES = { }, POLICY_ACCOUNTING_NETSUITE_TOKEN_INPUT: { route: 'workspaces/:policyID/accounting/netsuite/token-input/:subPage', - getRoute: (policyID: string | undefined, subPage: string, authType?: string) => { + getRoute: (policyID: string | undefined, subPage: string, authType?: string, isMigration?: boolean) => { if (!policyID) { Log.warn('Invalid policyID is used to build the POLICY_ACCOUNTING_NETSUITE_TOKEN_INPUT route'); } - return `workspaces/${policyID}/accounting/netsuite/token-input/${subPage}${authType ? `?authType=${authType}` : ''}` as const; + const query = [authType ? `authType=${authType}` : '', isMigration ? `isMigration=true` : ''].filter(Boolean).join('&'); + return `workspaces/${policyID}/accounting/netsuite/token-input/${subPage}${query ? `?${query}` : ''}` as const; }, }, POLICY_ACCOUNTING_NETSUITE_SETUP: { route: 'workspaces/:policyID/accounting/netsuite/setup/:accountID?', - getRoute: (policyID: string, accountID: string) => `workspaces/${policyID}/accounting/netsuite/setup/${accountID}` as const, + getRoute: (policyID: string, accountID: string, isMigration?: boolean) => + `workspaces/${policyID}/accounting/netsuite/setup/${accountID}${isMigration ? '?isMigration=true' : ''}` as const, }, POLICY_ACCOUNTING_NETSUITE_IMPORT: { route: 'workspaces/:policyID/accounting/netsuite/import', @@ -5168,6 +5187,10 @@ const ROUTES = { route: 'workspaces/:policyID/accounting/dualentry/advanced/bill-payment-account', getRoute: (policyID: string) => `workspaces/${policyID}/accounting/dualentry/advanced/bill-payment-account` as const, }, + POLICY_ACCOUNTING_DUALENTRY_FX_EXPENSE_ACCOUNT: { + route: 'workspaces/:policyID/accounting/dualentry/advanced/fx-expense-account', + getRoute: (policyID: string) => `workspaces/${policyID}/accounting/dualentry/advanced/fx-expense-account` as const, + }, POLICY_ACCOUNTING_DUALENTRY_EXPENSIFY_CARD_SETTLEMENT_ACCOUNT: { route: 'workspaces/:policyID/accounting/dualentry/advanced/expensify-card-settlement-account', getRoute: (policyID: string) => `workspaces/${policyID}/accounting/dualentry/advanced/expensify-card-settlement-account` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 1871d9a129b5..178028659a39 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -19,6 +19,7 @@ const SCREENS = { INSIGHTS: 'Insights', REPORT: 'Report', DYNAMIC_PROFILE_AVATAR: 'Dynamic_Profile_Avatar', + DYNAMIC_CARD_AUTHENTICATION: 'Dynamic_Card_Authentication', WORKSPACE_AVATAR: 'WorkspaceAvatar', WORKSPACE_DOCUMENT: 'WorkspaceDocument', REPORT_AVATAR: 'ReportAvatar', @@ -186,6 +187,7 @@ const SCREENS = { TRANSACTIONS_IMPORTED: 'Settings_Wallet_Transactions_Imported', ENABLE_PAYMENTS: 'Settings_Wallet_EnablePayments', ENABLE_PAYMENTS_CONFIRM_VALIDATE_CODE: 'Settings_Wallet_EnablePayments_ConfirmValidateCode', + DYNAMIC_ENABLE_PAYMENTS_CONFIRM_VALIDATE_CODE: 'Dynamic_Settings_Wallet_EnablePayments_ConfirmValidateCode', CARD_ACTIVATE: 'Settings_Wallet_Card_Activate', CARD_ADD_TO_DIGITAL_WALLET: 'Settings_Wallet_Card_Add_To_Digital_Wallet', CARD_ADDED_TO_WALLET: 'Settings_Wallet_Card_Added_To_Wallet', @@ -735,6 +737,7 @@ const SCREENS = { DUALENTRY_ADVANCED: 'Policy_Accounting_DualEntry_Advanced', DUALENTRY_EXPORT_METHOD: 'Policy_Accounting_DualEntry_Export_Method', DUALENTRY_BILL_PAYMENT_ACCOUNT: 'Policy_Accounting_DualEntry_Bill_Payment_Account', + DUALENTRY_FX_EXPENSE_ACCOUNT: 'Policy_Accounting_DualEntry_Fx_Expense_Account', DUALENTRY_EXPENSIFY_CARD_SETTLEMENT_ACCOUNT: 'Policy_Accounting_DualEntry_Expensify_Card_Settlement_Account', DUALENTRY_TRAVEL_BILLING_SETTLEMENT_ACCOUNT: 'Policy_Accounting_DualEntry_Travel_Invoicing_Settlement_Account', DUALENTRY_TRAVEL_BILLING_PAYABLE_ACCOUNT: 'Policy_Accounting_DualEntry_Travel_Invoicing_Payable_Account', @@ -1135,6 +1138,7 @@ const SCREENS = { SIGN_IN_ROOT: 'SignIn_Root', DETAILS_ROOT: 'Details_Root', DYNAMIC_PROFILE: 'Dynamic_Profile_Root', + DYNAMIC_PROFILE_DISPLAY_NAME: 'Dynamic_Profile_Display_Name', AUTO_SUBMIT_ROOT: 'AutoSubmit_Modal_Root', DYNAMIC_CHANGE_POLICY_EDUCATIONAL_ROOT: 'DynamicChangePolicyEducational_Root', REPORT_DESCRIPTION_ROOT: 'Report_Description_Root', diff --git a/src/components/AddPaymentCard/PaymentCardForm.tsx b/src/components/AddPaymentCard/PaymentCardForm.tsx index f1aa4344e869..1af3e11778f1 100644 --- a/src/components/AddPaymentCard/PaymentCardForm.tsx +++ b/src/components/AddPaymentCard/PaymentCardForm.tsx @@ -9,8 +9,11 @@ import type {AnimatedTextInputRef} from '@components/RNTextInput'; import StateSelector from '@components/StateSelector'; import TextInput from '@components/TextInput'; +import useClearedAddCardDraftCurrency from '@hooks/useClearedAddCardDraftCurrency'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; +import usePreferredCurrency from '@hooks/usePreferredCurrency'; import useThemeStyles from '@hooks/useThemeStyles'; import {getFieldRequiredErrors, isValidAddress, isValidDebitCard, isValidExpirationDate, isValidNameOnCard, isValidPaymentZipCode, isValidSecurityCode} from '@libs/ValidationUtils'; @@ -126,6 +129,13 @@ function PaymentCardForm({ }: PaymentCardFormProps) { const styles = useThemeStyles(); const [data, metadata] = useOnyx(ONYXKEYS.FORMS.ADD_PAYMENT_CARD_FORM); + const preferredCurrency = usePreferredCurrency(); + const {isBetaEnabled} = usePermissions(); + // EUR is only a selectable billing currency behind CONST.BETAS.EUR_BILLING; without it, default to a currency the + // selector actually offers so a EUR-locale user can't silently submit a EUR card and get pushed onto the SCA/3DS path. + const defaultCurrency = preferredCurrency === CONST.PAYMENT_CARD_CURRENCY.EUR && !isBetaEnabled(CONST.BETAS.EUR_BILLING) ? CONST.PAYMENT_CARD_CURRENCY.USD : preferredCurrency; + + const hasClearedDraftCurrency = useClearedAddCardDraftCurrency(); const {translate} = useLocalize(); const label = CARD_LABELS[isDebitCard ? CARD_TYPES.DEBIT_CARD : CARD_TYPES.PAYMENT_CARD]; @@ -243,7 +253,7 @@ function PaymentCardForm({ setCardNumber(validCardNumber); }, []); - if (!shouldShowPaymentCardForm || isLoadingOnyxValue(metadata)) { + if (!shouldShowPaymentCardForm || isLoadingOnyxValue(metadata) || !hasClearedDraftCurrency) { return null; } @@ -356,7 +366,7 @@ function PaymentCardForm({ {!!showCurrencyField && ( diff --git a/src/components/ApproverSelectionList.tsx b/src/components/ApproverSelectionList.tsx index 833e9a64baec..784eab25857d 100644 --- a/src/components/ApproverSelectionList.tsx +++ b/src/components/ApproverSelectionList.tsx @@ -3,6 +3,7 @@ import useInitialSelection from '@hooks/useInitialSelection'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useShouldFooterBeInsideList from '@hooks/useShouldFooterBeInsideList'; import useThemeStyles from '@hooks/useThemeStyles'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; @@ -25,6 +26,7 @@ import type {ListItem} from './SelectionList/types'; import BlockingView from './BlockingViews/BlockingView'; import FullPageNotFoundView from './BlockingViews/FullPageNotFoundView'; +import CollapsibleHeaderOnKeyboard from './CollapsibleHeaderOnKeyboard'; import HeaderWithBackButton from './HeaderWithBackButton'; import ScreenWrapper from './ScreenWrapper'; import SelectionList from './SelectionList'; @@ -85,6 +87,7 @@ function ApproverSelectionList({ const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); const shouldShowTextInput = shouldShowTextInputProp ?? allApprovers?.length >= CONST.STANDARD_LIST_ITEM_LIMIT; const lazyIllustrations = useMemoizedLazyIllustrations(['TurtleInShell']); + const shouldFooterBeInsideList = useShouldFooterBeInsideList(); const handleSearchChange = useCallback( (term: string) => { @@ -169,11 +172,13 @@ function ApproverSelectionList({ onLinkPress={goBackFromInvalidPolicy} addBottomSafeAreaPadding > - - {subtitle} + + + {subtitle} + void; }; -function AttachmentViewVideo({source, isHovered = false, shouldUseSharedVideoElement = false, duration = 0, reportID, onTap}: AttachmentViewVideoProps) { +function AttachmentViewVideo({source, fileName, isHovered = false, shouldUseSharedVideoElement = false, duration = 0, reportID, onTap}: AttachmentViewVideoProps) { const {shouldUseNarrowLayout} = useResponsiveLayout(); const styles = useThemeStyles(); return ( source.startsWith(prefix))} isHovered={isHovered} duration={duration} diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx index 60a4e0dfbf60..d05894808357 100644 --- a/src/components/AvatarWithDisplayName.tsx +++ b/src/components/AvatarWithDisplayName.tsx @@ -203,7 +203,7 @@ function AvatarWithDisplayName({ const title = getReportName(report, getReportNameFromNames(derivedReportNames, report?.reportID)); const isParentReportArchived = useReportIsArchived(report?.parentReportID); const subtitle = getChatRoomSubtitle(report, policy, conciergeReportID, translate, rules, true, isReportArchived); - const parentNavigationSubtitleData = getParentNavigationSubtitle(report, policy, conciergeReportID, translate, derivedParentReportName, isParentReportArchived); + const parentNavigationSubtitleData = getParentNavigationSubtitle(report, policy, conciergeReportID, translate, formatPhoneNumber, derivedParentReportName, isParentReportArchived); const isMoneyRequestOrReport = isMoneyRequestReport(report) || isMoneyRequest(report) || isTrackExpenseReport(report) || isInvoiceReport(report); const ownerPersonalDetails = getPersonalDetailsForAccountIDs(report?.ownerAccountID ? [report.ownerAccountID] : [], personalDetails); const displayNamesWithTooltips = getDisplayNamesWithTooltips(Object.values(ownerPersonalDetails), false, localeCompare, formatPhoneNumber, translate); diff --git a/src/components/CardAuthenticationView/index.native.tsx b/src/components/CardAuthenticationView/index.native.tsx new file mode 100644 index 000000000000..a5373247ff80 --- /dev/null +++ b/src/components/CardAuthenticationView/index.native.tsx @@ -0,0 +1,5 @@ +function CardAuthenticationView() { + return null; +} + +export default CardAuthenticationView; diff --git a/src/components/CardAuthenticationView/index.tsx b/src/components/CardAuthenticationView/index.tsx new file mode 100644 index 000000000000..7e20e46c87f1 --- /dev/null +++ b/src/components/CardAuthenticationView/index.tsx @@ -0,0 +1,64 @@ +import LoadingIndicator from '@components/LoadingIndicator'; + +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONFIG from '@src/CONFIG'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import React, {useEffect, useState} from 'react'; +import {View} from 'react-native'; + +const SECURE_ORIGIN = new URL(CONFIG.EXPENSIFY.SECURE_EXPENSIFY_URL).origin; + +type CardAuthenticationViewProps = { + /** Called when the iframe reports the 3DS challenge finished, regardless of outcome — the message carries no success/failure flag. */ + onAuthenticationComplete: () => void; + + /** Called after onAuthenticationComplete to close the surface owning this view. */ + onClose: () => void; +}; + +function CardAuthenticationView({onAuthenticationComplete, onClose}: CardAuthenticationViewProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const [authenticationLink] = useOnyx(ONYXKEYS.VERIFY_3DS_SUBSCRIPTION); + const [isLoading, setIsLoading] = useState(true); + + const handleSCAAuthentication = (event: MessageEvent) => { + if (event.origin !== SECURE_ORIGIN) { + return; + } + if (event.data !== CONST.SCA_AUTHENTICATION_COMPLETE) { + return; + } + onAuthenticationComplete(); + onClose(); + }; + + useEffect(() => { + window.addEventListener('message', handleSCAAuthentication); + return () => { + window.removeEventListener('message', handleSCAAuthentication); + }; + }, [handleSCAAuthentication]); + + return ( + +