From feea6cdb39da28fe7367c3232b9c16da66b6ff58 Mon Sep 17 00:00:00 2001 From: Alexey Kureev Date: Thu, 4 Dec 2025 18:01:52 +0100 Subject: [PATCH 001/233] proposal: new editing mechanism for small screens --- .../BaseReportActionContextMenu.tsx | 6 +- .../report/ContextMenu/ContextMenuActions.tsx | 27 ++++++- .../home/report/PureReportActionItem.tsx | 13 ++-- .../ComposerWithSuggestions.tsx | 74 ++++++++++++++++--- .../ReportActionCompose.tsx | 66 ++++++++++++++++- src/pages/home/report/ReportActionsList.tsx | 27 ++++++- 6 files changed, 191 insertions(+), 22 deletions(-) diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx index 9729112c2bd2..db545a58f4f5 100755 --- a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx +++ b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx @@ -210,8 +210,10 @@ function BaseReportActionContextMenu({ const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, {canBeMissing: true}); const [moneyRequestPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${moneyRequestReport?.policyID}`, {canBeMissing: true}); const {transactions} = useTransactionsAndViolationsForReport(childReport?.reportID); - const [tryNewDot] = useOnyx(ONYXKEYS.NVP_TRY_NEW_DOT, {canBeMissing: false}); + const [tryNewDot] = useOnyx(ONYXKEYS.NVP_TRY_NEW_DOT, {canBeMissing: true}); const isTryNewDotNVPDismissed = !!tryNewDot?.classicRedirect?.dismissed; + const [allDraftMessages] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS, {canBeMissing: true}); + const reportDrafts = originalReportID ? allDraftMessages?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${originalReportID}`] : undefined; const isMoneyRequest = useMemo(() => ReportUtilsIsMoneyRequest(childReport), [childReport]); const isTrackExpenseReport = ReportUtilsIsTrackExpenseReport(childReport); @@ -360,6 +362,8 @@ function BaseReportActionContextMenu({ reportID, report, draftMessage, + allDraftMessages: reportDrafts, + shouldUseNarrowLayout, selection, close: () => setShouldKeepOpen(false), transitionActionSheetState: actionSheetAwareScrollViewContext.transitionActionSheetState, diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx index 750b5e4f8bcd..bc670aa274b0 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx @@ -145,7 +145,19 @@ import { import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ROUTES from '@src/ROUTES'; -import type {Beta, Card, Download as DownloadOnyx, OnyxInputOrEntry, Policy, PolicyTagLists, ReportAction, ReportActionReactions, Report as ReportType, Transaction} from '@src/types/onyx'; +import type { + Beta, + Card, + Download as DownloadOnyx, + OnyxInputOrEntry, + Policy, + PolicyTagLists, + ReportAction, + ReportActionReactions, + ReportActionsDrafts, + Report as ReportType, + Transaction, +} from '@src/types/onyx'; import type IconAsset from '@src/types/utils/IconAsset'; import KeyboardUtils from '@src/utils/keyboard'; import type {ContextMenuAnchor} from './ReportActionContextMenu'; @@ -202,6 +214,8 @@ type ContextMenuActionPayload = { reportID: string | undefined; report: OnyxEntry; draftMessage: string; + allDraftMessages?: ReportActionsDrafts; + shouldUseNarrowLayout: boolean; selection: string; close: () => void; transitionActionSheetState: (params: {type: string; payload?: Record}) => void; @@ -366,7 +380,7 @@ const ContextMenuActions: ContextMenuAction[] = [ icon: Expensicons.Pencil, shouldShow: ({type, reportAction, isArchivedRoom, isChronosReport, moneyRequestAction}) => type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && (canEditReportAction(reportAction) || canEditReportAction(moneyRequestAction)) && !isArchivedRoom && !isChronosReport, - onPress: (closePopover, {reportID, reportAction, draftMessage, moneyRequestAction}) => { + onPress: (closePopover, {reportID, reportAction, allDraftMessages, shouldUseNarrowLayout, draftMessage, moneyRequestAction}) => { if (isMoneyRequestAction(reportAction) || isMoneyRequestAction(moneyRequestAction)) { const editExpense = () => { const childReportID = reportAction?.childReportID; @@ -382,6 +396,15 @@ const ContextMenuActions: ContextMenuAction[] = [ } const editAction = () => { if (!draftMessage) { + if (shouldUseNarrowLayout && allDraftMessages) { + for (const actionID of Object.keys(allDraftMessages)) { + if (actionID === reportAction.reportActionID) { + continue; + } + deleteReportActionDraft(reportID, reportAction); + } + } + saveReportActionDraft(reportID, reportAction, Parser.htmlToMarkdown(getActionHtml(reportAction))); } else { deleteReportActionDraft(reportID, reportAction); diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx index b96c247d4afe..234b0d123c9d 100644 --- a/src/pages/home/report/PureReportActionItem.tsx +++ b/src/pages/home/report/PureReportActionItem.tsx @@ -54,6 +54,7 @@ import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import type {OnyxDataWithErrors} from '@libs/ErrorUtils'; import {getLatestErrorMessageField, isReceiptError} from '@libs/ErrorUtils'; import focusComposerWithDelay from '@libs/focusComposerWithDelay'; +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import {isReportMessageAttachment} from '@libs/isReportMessageAttachment'; import Navigation from '@libs/Navigation/Navigation'; import Permissions from '@libs/Permissions'; @@ -496,10 +497,12 @@ function PureReportActionItem({ const prevDraftMessage = usePrevious(draftMessage); const isReportActionLinked = linkedReportActionID && action.reportActionID && linkedReportActionID === action.reportActionID; const [isReportActionActive, setIsReportActionActive] = useState(!!isReportActionLinked); + const isNarrowLayout = getIsNarrowLayout(); const isActionableWhisper = isActionableMentionWhisper(action) || isActionableMentionInviteToSubmitExpenseConfirmWhisper(action) || isActionableTrackExpense(action) || isActionableReportMentionWhisper(action); const isReportArchived = useReportIsArchived(reportID); const isOriginalReportArchived = useReportIsArchived(originalReportID); + const isInlineEditing = !isNarrowLayout && draftMessage !== undefined; const highlightedBackgroundColorIfNeeded = useMemo( // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing @@ -1484,7 +1487,7 @@ function PureReportActionItem({ - {draftMessage === undefined ? ( + {!isInlineEditing ? ( {content}; } @@ -1630,7 +1633,7 @@ function PureReportActionItem({ return ( { setIsReportActionActive(false); @@ -1775,7 +1778,7 @@ function PureReportActionItem({ isArchivedRoom={isArchivedRoom} displayAsGroup={displayAsGroup} disabledActions={disabledActions} - isVisible={hovered && draftMessage === undefined && !hasErrors} + isVisible={hovered && (!draftMessage || isNarrowLayout) && !hasErrors} isThreadReportParentAction={isThreadReportParentAction} draftMessage={draftMessage} isChronosReport={isChronosReport} diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 86f27500cc9f..8a305d84e341 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -239,15 +239,57 @@ function ComposerWithSuggestions({ const cursorPositionValue = useSharedValue({x: 0, y: 0}); const tag = useSharedValue(-1); const [draftComment = ''] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, {canBeMissing: true}); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + + const [allActionDrafts] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS, {canBeMissing: true}); + const activeInlineEdit = useMemo(() => { + if (!shouldUseNarrowLayout) { + return null; + } + + const reportDrafts = allActionDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`]; + + if (!reportDrafts) { + return null; + } + + const entry = Object.entries(reportDrafts).find(([, d]) => d?.message); + + if (!entry) { + return null; + } + + const [reportActionID, draft] = entry; + + return { + reportActionID, + message: draft?.message ?? '', + }; + }, [allActionDrafts, reportID, shouldUseNarrowLayout]); + + const initialValue = shouldUseNarrowLayout ? (activeInlineEdit?.message ?? draftComment) : draftComment; + const [value, setValue] = useState(() => { - if (draftComment) { - emojisPresentBefore.current = extractEmojis(draftComment); + if (initialValue) { + emojisPresentBefore.current = extractEmojis(initialValue); } - return draftComment; + return initialValue; }); const commentRef = useRef(value); + useEffect(() => { + if (!shouldUseNarrowLayout) { + return; + } + + const nextValue = activeInlineEdit?.message ?? draftComment ?? ''; + + emojisPresentBefore.current = extractEmojis(nextValue); + setValue(nextValue); + commentRef.current = nextValue; + }, [activeInlineEdit, draftComment, shouldUseNarrowLayout]); + const [modal] = useOnyx(ONYXKEYS.MODAL, {canBeMissing: true}); const [preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE] = useOnyx(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, {selector: getPreferredSkinToneIndex, canBeMissing: true}); const [editFocused] = useOnyx(ONYXKEYS.INPUT_FOCUSED, {canBeMissing: true}); @@ -257,7 +299,6 @@ function ComposerWithSuggestions({ lastTextRef.current = value; }, [value]); - const {shouldUseNarrowLayout} = useResponsiveLayout(); const maxComposerLines = shouldUseNarrowLayout ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; const shouldAutoFocus = (shouldFocusInputOnScreenFocus || !!draftComment) && !modal?.isVisible && shouldShowComposeInput && areAllModalsHidden() && isFocused && !didHideComposerInput; @@ -300,10 +341,13 @@ function ComposerWithSuggestions({ const debouncedSaveReportComment = useMemo( () => lodashDebounce((selectedReportID: string, newComment: string | null) => { + if (shouldUseNarrowLayout) { + return; + } saveReportDraftComment(selectedReportID, newComment); isCommentPendingSaved.current = false; }, 1000), - [], + [shouldUseNarrowLayout], ); useEffect(() => { @@ -427,15 +471,25 @@ function ComposerWithSuggestions({ } commentRef.current = newCommentConverted; + if (shouldUseNarrowLayout) { + const editingReportActionID = activeInlineEdit?.reportActionID; + + if (editingReportActionID) { + saveReportActionDraft(reportID, {reportActionID: editingReportActionID} as OnyxTypes.ReportAction, newCommentConverted); + } + + if (newCommentConverted) { + debouncedBroadcastUserIsTyping(reportID); + } + return; + } + if (shouldDebounceSaveComment) { isCommentPendingSaved.current = true; debouncedSaveReportComment(reportID, newCommentConverted); } else { saveReportDraftComment(reportID, newCommentConverted); } - if (newCommentConverted) { - debouncedBroadcastUserIsTyping(reportID); - } }, [ findNewlyAddedChars, @@ -446,6 +500,8 @@ function ComposerWithSuggestions({ suggestionsRef, raiseIsScrollLikelyLayoutTriggered, debouncedSaveReportComment, + activeInlineEdit, + shouldUseNarrowLayout, selection?.end, selection?.start, ], @@ -883,7 +939,7 @@ function ComposerWithSuggestions({ resetKeyboardInput={resetKeyboardInput} /> - {isValidReportIDFromPath(reportID) && ( + {isValidReportIDFromPath(reportID) && !shouldUseNarrowLayout && ( { + const reportDrafts = allActionDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`]; + + if (!reportDrafts) { + return null; + } + + const entry = Object.entries(reportDrafts).find(([, draft]) => draft?.message); + + if (!entry) { + return null; + } + + const [reportActionID, draft] = entry; + + return { + reportActionID, + message: draft.message, + }; + }, [allActionDrafts, reportID]); const shouldFocusComposerOnScreenFocus = shouldFocusInputOnScreenFocus || !!draftComment; @@ -169,8 +191,10 @@ function ReportActionCompose({ const {isScrollLayoutTriggered, raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); + const effectiveDraft = shouldUseNarrowLayout ? activeInlineDraft?.message : draftComment; + const [isCommentEmpty, setIsCommentEmpty] = useState(() => { - return !draftComment || !!draftComment.match(CONST.REGEX.EMPTY_COMMENT); + return !effectiveDraft || !!effectiveDraft.match(CONST.REGEX.EMPTY_COMMENT); }); /** @@ -217,6 +241,25 @@ function ReportActionCompose({ canBeMissing: true, }); + const [reportActionDrafts] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`, {canBeMissing: true}); + + const editingReportActionID = useMemo(() => { + if (!reportActionDrafts) { + return null; + } + + const entry = Object.entries(reportActionDrafts).find(([, draft]) => draft?.message); + return entry?.[0] ?? null; + }, [reportActionDrafts]); + + const editingReportAction = useMemo(() => { + if (!editingReportActionID || !reportActions) { + return null; + } + + return reportActions[editingReportActionID] ?? null; + }, [editingReportActionID, reportActions]); + const personalDetail = useCurrentUserPersonalDetails(); const iouAction = reportActions ? Object.values(reportActions).find((action) => isMoneyRequestAction(action)) : null; @@ -321,6 +364,18 @@ function ReportActionCompose({ (newComment: string) => { const newCommentTrimmed = newComment.trim(); + if (!newCommentTrimmed && !attachmentFileRef.current) { + return; + } + + const isEditingInline = shouldUseNarrowLayout && !!editingReportAction; + + if (isEditingInline && !attachmentFileRef.current) { + editReportComment(report, editingReportAction, ancestors, newCommentTrimmed, undefined, false); + deleteReportActionDraft(reportID, editingReportAction); + return; + } + if (attachmentFileRef.current) { addAttachmentWithComment(transactionThreadReportID ?? reportID, reportID, ancestors, attachmentFileRef.current, newCommentTrimmed, personalDetail.timezone, true); attachmentFileRef.current = null; @@ -338,7 +393,7 @@ function ReportActionCompose({ onSubmit(newCommentTrimmed); } }, - [onSubmit, ancestors, reportID, personalDetail.timezone, transactionThreadReportID], + [reportID, ancestors, transactionThreadReportID, personalDetail.timezone, onSubmit, editingReportAction, shouldUseNarrowLayout], ); const onTriggerAttachmentPicker = useCallback(() => { @@ -366,6 +421,11 @@ function ReportActionCompose({ onComposerFocus?.(); }, [onComposerFocus]); + useEffect(() => { + const valueToCheck = shouldUseNarrowLayout ? activeInlineDraft?.message : draftComment; + setIsCommentEmpty(!valueToCheck || !!valueToCheck.match(CONST.REGEX.EMPTY_COMMENT)); + }, [activeInlineDraft, draftComment, shouldUseNarrowLayout]); + useEffect(() => { if (hasExceededMaxTaskTitleLength) { setExceededMaxLength(CONST.TITLE_CHARACTER_LIMIT); diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 8bcad4613b84..9c48158e29aa 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -189,7 +189,7 @@ function ReportActionsList({ const [draftMessage] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}`, {canBeMissing: true}); const [emojiReactions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}`, {canBeMissing: true}); const [userBillingFundID] = useOnyx(ONYXKEYS.NVP_BILLING_FUND_ID, {canBeMissing: true}); - const [tryNewDot] = useOnyx(ONYXKEYS.NVP_TRY_NEW_DOT, {canBeMissing: false}); + const [tryNewDot] = useOnyx(ONYXKEYS.NVP_TRY_NEW_DOT, {canBeMissing: true}); const isTryNewDotNVPDismissed = !!tryNewDot?.classicRedirect?.dismissed; const [isScrollToBottomEnabled, setIsScrollToBottomEnabled] = useState(false); const [actionIdToHighlight, setActionIdToHighlight] = useState(''); @@ -687,12 +687,33 @@ function ReportActionsList({ return isExpenseReport(report) || isIOUReport(report) || isInvoiceReport(report); }, [parentReportAction, report, sortedVisibleReportActions]); + const activeMobileEditActionID = useMemo(() => { + if (!shouldUseNarrowLayout || !draftMessage) { + return null; + } + + for (const reportDrafts of Object.values(draftMessage)) { + if (!reportDrafts) { + continue; + } + + for (const [actionID, draft] of Object.entries(reportDrafts)) { + if (draft?.message) { + return actionID; + } + } + } + + return null; + }, [shouldUseNarrowLayout, draftMessage]); + const renderItem = useCallback( ({item: reportAction, index}: ListRenderItemInfo) => { const originalReportID = getOriginalReportID(report.reportID, reportAction); const reportDraftMessages = draftMessage?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${originalReportID}`]; const matchingDraftMessage = reportDraftMessages?.[reportAction.reportActionID]; - const matchingDraftMessageString = matchingDraftMessage?.message; + const matchingDraftMessageString = + shouldUseNarrowLayout && activeMobileEditActionID && activeMobileEditActionID !== reportAction.reportActionID ? undefined : matchingDraftMessage?.message; const actionEmojiReactions = emojiReactions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportAction.reportActionID}`]; const transactionID = isMoneyRequestAction(reportAction) && getOriginalMessage(reportAction)?.IOUTransactionID; @@ -760,6 +781,8 @@ function ReportActionsList({ userBillingFundID, isTryNewDotNVPDismissed, isReportArchived, + activeMobileEditActionID, + shouldUseNarrowLayout, ], ); From 111bbdff8d004dbf29f08d25413427dfbbf7e51c Mon Sep 17 00:00:00 2001 From: Alexey Kureev Date: Thu, 4 Dec 2025 18:09:09 +0100 Subject: [PATCH 002/233] fix eslint issues --- .../ComposerWithSuggestions/ComposerWithSuggestions.tsx | 5 +++-- .../home/report/ReportActionCompose/ReportActionCompose.tsx | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 8a305d84e341..c4689a4f50db 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -288,7 +288,7 @@ function ComposerWithSuggestions({ emojisPresentBefore.current = extractEmojis(nextValue); setValue(nextValue); commentRef.current = nextValue; - }, [activeInlineEdit, draftComment, shouldUseNarrowLayout]); + }, [activeInlineEdit?.message, activeInlineEdit?.reportActionID, draftComment, shouldUseNarrowLayout]); const [modal] = useOnyx(ONYXKEYS.MODAL, {canBeMissing: true}); const [preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE] = useOnyx(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, {selector: getPreferredSkinToneIndex, canBeMissing: true}); @@ -500,7 +500,8 @@ function ComposerWithSuggestions({ suggestionsRef, raiseIsScrollLikelyLayoutTriggered, debouncedSaveReportComment, - activeInlineEdit, + activeInlineEdit?.message, + activeInlineEdit?.reportActionID, shouldUseNarrowLayout, selection?.end, selection?.start, diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index 6c96362551f4..ae8aebe4cfac 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -424,7 +424,7 @@ function ReportActionCompose({ useEffect(() => { const valueToCheck = shouldUseNarrowLayout ? activeInlineDraft?.message : draftComment; setIsCommentEmpty(!valueToCheck || !!valueToCheck.match(CONST.REGEX.EMPTY_COMMENT)); - }, [activeInlineDraft, draftComment, shouldUseNarrowLayout]); + }, [activeInlineDraft?.message, draftComment, shouldUseNarrowLayout]); useEffect(() => { if (hasExceededMaxTaskTitleLength) { From 2e190d8878676a12e235622a1030d28381256a40 Mon Sep 17 00:00:00 2001 From: Alexey Kureev Date: Thu, 4 Dec 2025 18:16:52 +0100 Subject: [PATCH 003/233] fix eslint warnings --- src/pages/home/report/PureReportActionItem.tsx | 3 ++- .../ComposerWithSuggestions/ComposerWithSuggestions.tsx | 1 - .../home/report/ReportActionCompose/ReportActionCompose.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx index 234b0d123c9d..a389fb5a657b 100644 --- a/src/pages/home/report/PureReportActionItem.tsx +++ b/src/pages/home/report/PureReportActionItem.tsx @@ -14,7 +14,6 @@ import DisplayNames from '@components/DisplayNames'; import Hoverable from '@components/Hoverable'; import MentionReportContext from '@components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext'; import Icon from '@components/Icon'; -import {Eye} from '@components/Icon/Expensicons'; import InlineSystemMessage from '@components/InlineSystemMessage'; import KYCWall from '@components/KYCWall'; import {KYCWallContext} from '@components/KYCWall/KYCWallContext'; @@ -40,6 +39,7 @@ import Text from '@components/Text'; import TextLink from '@components/TextLink'; import UnreadActionIndicator from '@components/UnreadActionIndicator'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import usePreferredPolicy from '@hooks/usePreferredPolicy'; import usePrevious from '@hooks/usePrevious'; @@ -503,6 +503,7 @@ function PureReportActionItem({ const isReportArchived = useReportIsArchived(reportID); const isOriginalReportArchived = useReportIsArchived(originalReportID); const isInlineEditing = !isNarrowLayout && draftMessage !== undefined; + const {Eye} = useMemoizedLazyExpensifyIcons(['Eye']); const highlightedBackgroundColorIfNeeded = useMemo( // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index c4689a4f50db..8f659f5e9ac9 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -500,7 +500,6 @@ function ComposerWithSuggestions({ suggestionsRef, raiseIsScrollLikelyLayoutTriggered, debouncedSaveReportComment, - activeInlineEdit?.message, activeInlineEdit?.reportActionID, shouldUseNarrowLayout, selection?.end, diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index ae8aebe4cfac..836ee4568d42 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -393,7 +393,7 @@ function ReportActionCompose({ onSubmit(newCommentTrimmed); } }, - [reportID, ancestors, transactionThreadReportID, personalDetail.timezone, onSubmit, editingReportAction, shouldUseNarrowLayout], + [reportID, ancestors, transactionThreadReportID, personalDetail.timezone, onSubmit, report, editingReportAction, shouldUseNarrowLayout], ); const onTriggerAttachmentPicker = useCallback(() => { From 3f5ea983dba680fc0f7ffdbb099b023050977f4d Mon Sep 17 00:00:00 2001 From: Alexey Kureev Date: Thu, 4 Dec 2025 18:24:45 +0100 Subject: [PATCH 004/233] stub deprecation error --- src/pages/home/report/ContextMenu/ContextMenuActions.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx index bc670aa274b0..a4a4ee3b0d89 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx @@ -650,6 +650,7 @@ const ContextMenuActions: ContextMenuAction[] = [ const taskPreviewMessage = getTaskCreatedMessage(reportAction, childReport, true); Clipboard.setString(taskPreviewMessage); } else if (isMemberChangeAction(reportAction)) { + // @eslint-disable-next-line const logMessage = getMemberChangeMessageFragment(reportAction, getReportName).html ?? ''; setClipboardMessage(logMessage); } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_NAME) { From 69abc8dbecee14fc4887dc9946a4faf972e4f6d8 Mon Sep 17 00:00:00 2001 From: Alexey Kureev Date: Thu, 4 Dec 2025 18:45:50 +0100 Subject: [PATCH 005/233] fix typo --- src/pages/home/report/ContextMenu/ContextMenuActions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx index a4a4ee3b0d89..83c970ec6d11 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx @@ -650,7 +650,7 @@ const ContextMenuActions: ContextMenuAction[] = [ const taskPreviewMessage = getTaskCreatedMessage(reportAction, childReport, true); Clipboard.setString(taskPreviewMessage); } else if (isMemberChangeAction(reportAction)) { - // @eslint-disable-next-line + // eslint-disable-next-line const logMessage = getMemberChangeMessageFragment(reportAction, getReportName).html ?? ''; setClipboardMessage(logMessage); } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_NAME) { From 576520b230a7d7b0165605a27eef191b9519ddfc Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 12 Feb 2026 21:32:47 +0000 Subject: [PATCH 006/233] fix: do not invert conditions --- .../inbox/report/PureReportActionItem.tsx | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/pages/inbox/report/PureReportActionItem.tsx b/src/pages/inbox/report/PureReportActionItem.tsx index 53a754d7c931..80f71c63b2f6 100644 --- a/src/pages/inbox/report/PureReportActionItem.tsx +++ b/src/pages/inbox/report/PureReportActionItem.tsx @@ -578,7 +578,7 @@ function PureReportActionItem({ isActionableMentionWhisper(action) || isActionableMentionInviteToSubmitExpenseConfirmWhisper(action) || isActionableTrackExpense(action) || isActionableReportMentionWhisper(action); const isReportArchived = useReportIsArchived(reportID); const isOriginalReportArchived = useReportIsArchived(originalReportID); - const isInlineEditing = !isNarrowLayout && draftMessage !== undefined; + const isEditingInline = !isNarrowLayout && draftMessage !== undefined; const isHarvestCreatedExpenseReport = isHarvestCreatedExpenseReportUtils(reportNameValuePairsOrigin, reportNameValuePairsOriginalID); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Eye'] as const); @@ -1783,7 +1783,21 @@ function PureReportActionItem({ - {!isInlineEditing ? ( + {isEditingInline ? ( + + ) : ( )} - ) : ( - )} From 3fea5218344e27a3f3863437cfba8a2bf3e01404 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 13 Feb 2026 08:53:13 +0000 Subject: [PATCH 007/233] refactor: extraact message edit button --- .../MessageEditCancelButton.tsx | 48 ++++++++++++++ .../ReportActionCompose.tsx | 55 +++++++++------- .../ReportActionCompose/useDeleteDraft.ts | 64 +++++++++++++++++++ .../report/ReportActionItemMessageEdit.tsx | 56 ++-------------- 4 files changed, 148 insertions(+), 75 deletions(-) create mode 100644 src/pages/inbox/report/ReportActionCompose/MessageEditCancelButton.tsx create mode 100644 src/pages/inbox/report/ReportActionCompose/useDeleteDraft.ts diff --git a/src/pages/inbox/report/ReportActionCompose/MessageEditCancelButton.tsx b/src/pages/inbox/report/ReportActionCompose/MessageEditCancelButton.tsx new file mode 100644 index 000000000000..4dc175d61dbd --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/MessageEditCancelButton.tsx @@ -0,0 +1,48 @@ +import {View} from 'react-native'; +import Icon from '@components/Icon'; +import {PressableWithFeedback} from '@components/Pressable'; +import Tooltip from '@components/Tooltip'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; + +type MessageEditCancelButtonProps = { + onCancel: () => void; +}; + +function MessageEditCancelButton({onCancel}: MessageEditCancelButtonProps) { + const styles = useThemeStyles(); + const theme = useTheme(); + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Close']); + + const closeButtonStyles = [styles.composerSizeButton, {marginVertical: styles.composerSizeButton.marginHorizontal}]; + + return ( + + + e.preventDefault()} + sentryLabel={CONST.SENTRY_LABEL.REPORT.REPORT_ACTION_ITEM_MESSAGE_EDIT_CANCEL_BUTTON} + > + + + + + ); +} + +export default MessageEditCancelButton; diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index d8e1ea2ca764..7e0f87a8dd2e 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -77,8 +77,10 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; import ComposerWithSuggestions from './ComposerWithSuggestions'; import type {ComposerRef, ComposerWithSuggestionsProps} from './ComposerWithSuggestions/ComposerWithSuggestions'; +import MessageEditCancelButton from './MessageEditCancelButton'; import SendButton from './SendButton'; import useAttachmentUploadValidation from './useAttachmentUploadValidation'; +import useDeleteDraft from './useDeleteDraft'; type SuggestionsRef = { resetSuggestions: () => void; @@ -545,6 +547,7 @@ function ReportActionCompose({ }); }, [isSendDisabled, debouncedValidate, isComposerFullSize, reportID, composerRefShared]); + const deleteDraft = useDeleteDraft({reportID, reportAction: editingReportAction, index: 0, isFocused}); onSubmitAction = handleSendMessage; const emojiPositionValues = useMemo( @@ -628,30 +631,34 @@ function ReportActionCompose({ ]} > {PDFValidationComponent} - validateAttachments({files})} - reportID={reportID} - report={report} - currentUserPersonalDetails={currentUserPersonalDetails} - reportParticipantIDs={reportParticipantIDs} - isFullComposerAvailable={isFullComposerAvailable} - isComposerFullSize={isComposerFullSize} - disabled={isBlockedFromConcierge} - setMenuVisibility={setMenuVisibility} - isMenuVisible={isMenuVisible} - onTriggerAttachmentPicker={onTriggerAttachmentPicker} - raiseIsScrollLikelyLayoutTriggered={raiseIsScrollLayoutTriggered} - onAddActionPressed={onAddActionPressed} - onItemSelected={onItemSelected} - onCanceledAttachmentPicker={() => { - if (!shouldFocusComposerOnScreenFocus) { - return; - } - focus(); - }} - actionButtonRef={actionButtonRef} - shouldDisableAttachmentItem={!!exceededMaxLength} - /> + {isEditingInline ? ( + + ) : ( + validateAttachments({files})} + reportID={reportID} + report={report} + currentUserPersonalDetails={currentUserPersonalDetails} + reportParticipantIDs={reportParticipantIDs} + isFullComposerAvailable={isFullComposerAvailable} + isComposerFullSize={isComposerFullSize} + disabled={isBlockedFromConcierge} + setMenuVisibility={setMenuVisibility} + isMenuVisible={isMenuVisible} + onTriggerAttachmentPicker={onTriggerAttachmentPicker} + raiseIsScrollLikelyLayoutTriggered={raiseIsScrollLayoutTriggered} + onAddActionPressed={onAddActionPressed} + onItemSelected={onItemSelected} + onCanceledAttachmentPicker={() => { + if (!shouldFocusComposerOnScreenFocus) { + return; + } + focus(); + }} + actionButtonRef={actionButtonRef} + shouldDisableAttachmentItem={!!exceededMaxLength} + /> + )} { composerRef.current = ref; diff --git a/src/pages/inbox/report/ReportActionCompose/useDeleteDraft.ts b/src/pages/inbox/report/ReportActionCompose/useDeleteDraft.ts new file mode 100644 index 000000000000..c14457aee8fa --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/useDeleteDraft.ts @@ -0,0 +1,64 @@ +import {useEffect, useRef} from 'react'; +import {InteractionManager} from 'react-native'; +import useReportScrollManager from '@hooks/useReportScrollManager'; +import {isActive as isEmojiPickerActive} from '@libs/actions/EmojiPickerAction'; +import {deleteReportActionDraft} from '@libs/actions/Report'; +import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; +import * as ReportActionContextMenu from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import type * as OnyxTypes from '@src/types/onyx'; +import KeyboardUtils from '@src/utils/keyboard'; + +type UseDeleteDraftProps = { + reportID: string | undefined; + reportAction: OnyxTypes.ReportAction | null | undefined; + index: number; + isFocused: boolean; +}; + +/** + * Delete the draft of the comment being edited. This will take the comment out of "edit mode" with the old content. + */ +function useDeleteDraft({reportID, reportAction, index, isFocused}: UseDeleteDraftProps) { + const reportScrollManager = useReportScrollManager(); + const isFocusedRef = useRef(isFocused); + + useEffect(() => { + // required for keeping last state of isFocused variable + isFocusedRef.current = isFocused; + }, [isFocused]); + + // We consider the report action active if it's focused, its emoji picker is open or its context menu is open + function isActive(): boolean { + if (!reportAction) { + return false; + } + + return isFocusedRef.current || isEmojiPickerActive(reportAction.reportActionID) || ReportActionContextMenu.isActiveReportAction(reportAction.reportActionID); + } + + function deleteDraft(): void { + if (!reportAction) { + return; + } + + deleteReportActionDraft(reportID, reportAction); + + if (isActive()) { + ReportActionComposeFocusManager.clear(true); + // Wait for report action compose re-mounting on mWeb + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => ReportActionComposeFocusManager.focus()); + } + + // Scroll to the last comment after editing to make sure the whole comment is clearly visible in the report. + if (index === 0) { + KeyboardUtils.dismiss().then(() => { + reportScrollManager.scrollToIndex(index, false); + }); + } + } + + return deleteDraft; +} + +export default useDeleteDraft; diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index d1a8807a564d..450155fdb5e8 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -33,7 +33,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {setShouldShowComposeInput} from '@libs/actions/Composer'; import {clearActive, isActive as isEmojiPickerActive, isEmojiPickerVisible} from '@libs/actions/EmojiPickerAction'; import {composerFocusKeepFocusOn} from '@libs/actions/InputFocus'; -import {deleteReportActionDraft, editReportComment, saveReportActionDraft} from '@libs/actions/Report'; +import {editReportComment, saveReportActionDraft} from '@libs/actions/Report'; import {isMobileChrome} from '@libs/Browser'; import {canSkipTriggerHotkeys, insertText} from '@libs/ComposerUtils'; import DomUtils from '@libs/DomUtils'; @@ -53,12 +53,13 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; // eslint-disable-next-line no-restricted-imports import findNodeHandle from '@src/utils/findNodeHandle'; -import KeyboardUtils from '@src/utils/keyboard'; import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu'; import getCursorPosition from './ReportActionCompose/getCursorPosition'; import getScrollPosition from './ReportActionCompose/getScrollPosition'; +import MessageEditCancelButton from './ReportActionCompose/MessageEditCancelButton'; import type {SuggestionsRef} from './ReportActionCompose/ReportActionCompose'; import Suggestions from './ReportActionCompose/Suggestions'; +import useDeleteDraft from './ReportActionCompose/useDeleteDraft'; import shouldUseEmojiPickerSelection from './shouldUseEmojiPickerSelection'; type ReportActionItemMessageEditProps = { @@ -188,12 +189,6 @@ function ReportActionItemMessageEdit({ [], ); - // We consider the report action active if it's focused, its emoji picker is open or its context menu is open - const isActive = useCallback( - () => isFocusedRef.current || isEmojiPickerActive(action.reportActionID) || ReportActionContextMenu.isActiveReportAction(action.reportActionID), - [action.reportActionID], - ); - /** * Focus the composer text input * @param shouldDelay - Impose delay before focusing the composer @@ -276,26 +271,7 @@ function ReportActionItemMessageEdit({ // eslint-disable-next-line react-hooks/exhaustive-deps -- run this only when language is changed }, [action.reportActionID, preferredLocale]); - /** - * Delete the draft of the comment being edited. This will take the comment out of "edit mode" with the old content. - */ - const deleteDraft = useCallback(() => { - deleteReportActionDraft(reportID, action); - - if (isActive()) { - ReportActionComposeFocusManager.clear(true); - // Wait for report action compose re-mounting on mWeb - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => ReportActionComposeFocusManager.focus()); - } - - // Scroll to the last comment after editing to make sure the whole comment is clearly visible in the report. - if (index === 0) { - KeyboardUtils.dismiss().then(() => { - reportScrollManager.scrollToIndex(index, false); - }); - } - }, [action, index, reportID, reportScrollManager, isActive]); + const deleteDraft = useDeleteDraft({reportID, reportAction: action, index, isFocused}); /** * Save the draft of the comment to be the new comment message. This will take the comment out of "edit mode" with @@ -477,8 +453,6 @@ function ReportActionItemMessageEdit({ } }, [isFocused, hideSuggestionMenu]); - const closeButtonStyles = [styles.composerSizeButton, {marginVertical: styles.composerSizeButton.marginHorizontal}]; - return ( <> - - - e.preventDefault()} - sentryLabel={CONST.SENTRY_LABEL.REPORT.REPORT_ACTION_ITEM_MESSAGE_EDIT_CANCEL_BUTTON} - > - - - - + Date: Fri, 13 Feb 2026 11:29:28 +0000 Subject: [PATCH 008/233] refactor: add proper types for `Composer` and `ComposerWithSuggestions` refs --- .../Composer/implementation/index.native.tsx | 16 +-- .../Composer/implementation/index.tsx | 90 +++++++------ src/components/Composer/types.ts | 8 +- .../ComposerWithSuggestions.tsx | 122 ++++++++++-------- .../ComposerWithSuggestions/index.e2e.tsx | 5 +- 5 files changed, 132 insertions(+), 109 deletions(-) diff --git a/src/components/Composer/implementation/index.native.tsx b/src/components/Composer/implementation/index.native.tsx index 6251fd7d97d7..722e13cab437 100644 --- a/src/components/Composer/implementation/index.native.tsx +++ b/src/components/Composer/implementation/index.native.tsx @@ -3,7 +3,7 @@ import mimeDb from 'mime-db'; import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import type {NativeSyntheticEvent, TextInputChangeEvent, TextInputPasteEventData} from 'react-native'; import {StyleSheet} from 'react-native'; -import type {ComposerProps} from '@components/Composer/types'; +import type {ComposerProps, ComposerRef} from '@components/Composer/types'; import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; import useMarkdownStyle from '@hooks/useMarkdownStyle'; @@ -36,7 +36,7 @@ function Composer({ ref, ...props }: ComposerProps) { - const textInput = useRef(null); + const textInputRef = useRef(null); const textContainsOnlyEmojis = useMemo(() => containsOnlyEmojis(Parser.htmlToText(Parser.replace(value ?? ''))), [value]); const theme = useTheme(); const markdownStyle = useMarkdownStyle(textContainsOnlyEmojis, !isGroupPolicyReport ? excludeReportMentionStyle : excludeNoStyles); @@ -44,7 +44,7 @@ function Composer({ const StyleUtils = useStyleUtils(); useEffect(() => { - if (!textInput.current || !textInput.current.setSelection || !selection || isComposerFullSize) { + if (!textInputRef.current || !textInputRef.current.setSelection || !selection || isComposerFullSize) { return; } @@ -53,8 +53,8 @@ function Composer({ // (see https://github.com/Expensify/App/pull/50520#discussion_r1861960311 for more context) const timeoutID = setTimeout(() => { // We are setting selection twice to trigger a scroll to the cursor on toggling to smaller composer size. - textInput.current?.setSelection((selection.start || 1) - 1, selection.start); - textInput.current?.setSelection(selection.start, selection.start); + textInputRef.current?.setSelection((selection.start || 1) - 1, selection.start); + textInputRef.current?.setSelection(selection.start, selection.start); }, 0); return () => clearTimeout(timeoutID); @@ -67,8 +67,8 @@ function Composer({ * @param {Element} el */ const setTextInputRef = useCallback((el: AnimatedMarkdownTextInputRef | null) => { - textInput.current = el; - if (typeof ref !== 'function' || textInput.current === null) { + textInputRef.current = el; + if (typeof ref !== 'function' || textInputRef.current === null) { return; } @@ -76,7 +76,7 @@ function Composer({ // get a ref to the inner textInput element e.g. if we do // this.textInput = el} /> this will not // return a ref to the component, but rather the HTML element by default - ref(textInput.current); + ref(textInputRef.current as ComposerRef); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/src/components/Composer/implementation/index.tsx b/src/components/Composer/implementation/index.tsx index 7d181775ed4e..301b74f2cacb 100755 --- a/src/components/Composer/implementation/index.tsx +++ b/src/components/Composer/implementation/index.tsx @@ -5,7 +5,7 @@ import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, use // eslint-disable-next-line no-restricted-imports import type {TextInputKeyPressEvent, TextInputSelectionChangeEvent} from 'react-native'; import {DeviceEventEmitter, StyleSheet} from 'react-native'; -import type {ComposerProps} from '@components/Composer/types'; +import type {ComposerProps, ComposerRef} from '@components/Composer/types'; import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; import useHtmlPaste from '@hooks/useHtmlPaste'; @@ -57,7 +57,7 @@ function Composer({ const styles = useThemeStyles(); const markdownStyle = useMarkdownStyle(textContainsOnlyEmojis, !isGroupPolicyReport ? excludeReportMentionStyle : excludeNoStyles); const StyleUtils = useStyleUtils(); - const textInput = useRef(null); + const textInputRef = useRef(null); const [selection, setSelection] = useState< | { start: number; @@ -72,7 +72,7 @@ function Composer({ }); const [isRendered, setIsRendered] = useState(false); - const isScrollBarVisible = useIsScrollBarVisible(textInput, value ?? ''); + const isScrollBarVisible = useIsScrollBarVisible(textInputRef, value ?? ''); const [prevScroll, setPrevScroll] = useState(); const [prevHeight, setPrevHeight] = useState(); const isReportFlatListScrolling = useRef(false); @@ -94,13 +94,13 @@ function Composer({ const range = sel.getRangeAt(0).cloneRange(); range.collapse(true); const rect = range.getClientRects()[0] || range.startContainer.parentElement?.getClientRects()[0]; - const containerRect = textInput.current?.getBoundingClientRect(); + const containerRect = textInputRef.current?.getBoundingClientRect(); let x = 0; let y = 0; if (rect && containerRect) { x = rect.left - containerRect.left; - y = rect.top - containerRect.top + (textInput?.current?.scrollTop ?? 0) - rect.height / 2; + y = rect.top - containerRect.top + (textInputRef?.current?.scrollTop ?? 0) - rect.height / 2; } const selectionValue = { @@ -131,14 +131,14 @@ function Composer({ const handlePaste = useCallback( (event: ClipboardEvent) => { const isVisible = checkComposerVisibility(); - const isFocused = textInput.current?.isFocused(); + const isFocused = textInputRef.current?.isFocused(); const isContenteditableDivFocused = document.activeElement?.nodeName === 'DIV' && document.activeElement?.hasAttribute('contenteditable'); if (!(isVisible || isFocused)) { return true; } - if (textInput.current !== event.target && !(isContenteditableDivFocused && !event.clipboardData?.files.length)) { + if (textInputRef.current !== event.target && !(isContenteditableDivFocused && !event.clipboardData?.files.length)) { const eventTarget = event.target as HTMLInputElement | HTMLTextAreaElement | null; // To make sure the composer does not capture paste events from other inputs, we check where the event originated // If it did originate in another input, we return early to prevent the composer from handling the paste @@ -147,7 +147,7 @@ function Composer({ return true; } - textInput.current?.focus(); + textInputRef.current?.focus(); } event.preventDefault(); @@ -202,19 +202,19 @@ function Composer({ ); useEffect(() => { - if (!textInput.current) { + if (!textInputRef.current) { return; } const debouncedSetPrevScroll = lodashDebounce(() => { - if (!textInput.current) { + if (!textInputRef.current) { return; } - setPrevScroll(textInput.current.scrollTop); + setPrevScroll(textInputRef.current.scrollTop); }, 100); - textInput.current.addEventListener('scroll', debouncedSetPrevScroll); + textInputRef.current.addEventListener('scroll', debouncedSetPrevScroll); return () => { - textInput.current?.removeEventListener('scroll', debouncedSetPrevScroll); + textInputRef.current?.removeEventListener('scroll', debouncedSetPrevScroll); }; }, []); @@ -235,40 +235,40 @@ function Composer({ // When the composer has no scrollable content, the stopPropagation will prevent the inverted wheel event handler on the Chat body // which defaults to the browser wheel behavior. This causes the chat body to scroll in the opposite direction creating jerky behavior. - if (textInput.current && textInput.current.scrollHeight <= textInput.current.clientHeight) { + if (textInputRef.current && textInputRef.current.scrollHeight <= textInputRef.current.clientHeight) { return; } e.stopPropagation(); }; - textInput.current?.addEventListener('wheel', handleWheel, {passive: false}); + textInputRef.current?.addEventListener('wheel', handleWheel, {passive: false}); return () => { - textInput.current?.removeEventListener('wheel', handleWheel); + textInputRef.current?.removeEventListener('wheel', handleWheel); }; }, []); useEffect(() => { - if (!textInput.current || prevScroll === undefined || prevHeight === undefined) { + if (!textInputRef.current || prevScroll === undefined || prevHeight === undefined) { return; } - textInput.current.scrollTop = prevScroll + prevHeight - textInput.current.clientHeight; + textInputRef.current.scrollTop = prevScroll + prevHeight - textInputRef.current.clientHeight; // eslint-disable-next-line react-hooks/exhaustive-deps }, [isComposerFullSize]); const isActive = useIsFocused(); - useHtmlPaste(textInput, handlePaste, isActive); + useHtmlPaste(textInputRef, handlePaste, isActive); useEffect(() => { setIsRendered(true); }, []); const clear = useCallback(() => { - if (!textInput.current) { + if (!textInputRef.current) { return; } - const currentText = textInput.current.value; - textInput.current.clear(); + const currentText = textInputRef.current.value; + textInputRef.current.clear(); // We need to reset the selection to 0,0 manually after clearing the text input on web const selectionEvent = { @@ -285,24 +285,30 @@ function Composer({ onClear(currentText); }, [onClear, onSelectionChange]); - useImperativeHandle(ref, () => { - const textInputRef = textInput.current; - if (!textInputRef) { - throw new Error('textInputRef is not available. This should never happen and indicates a developer error.'); - } - - return { - ...textInputRef, - // Overwrite clear with our custom implementation, which mimics how the native TextInput's clear method works - clear, - // We have to redefine these methods as they are inherited by prototype chain and are not accessible directly - blur: () => textInputRef.blur(), - focus: () => textInputRef.focus(), - get scrollTop() { - return textInputRef.scrollTop; - }, - }; - }, [clear]); + useImperativeHandle( + ref, + () => + new Proxy( + {}, + { + get: (_target, prop) => { + if (prop === 'clear') { + return clear; + } + if (prop === 'blur') { + return () => textInputRef.current?.blur(); + } + if (prop === 'focus') { + return () => textInputRef.current?.focus(); + } + if (prop === 'scrollTop') { + return textInputRef.current?.scrollTop; + } + return textInputRef.current?.[prop as keyof AnimatedMarkdownTextInputRef]; + }, + }, + ) as ComposerRef, + ); const handleKeyPress = useCallback( (e: TextInputKeyPressEvent) => { @@ -343,9 +349,7 @@ function Composer({ autoComplete="off" autoCorrect={!isMobileSafari()} placeholderTextColor={theme.placeholderText} - ref={(el) => { - textInput.current = el; - }} + ref={textInputRef} selection={selection} style={[inputStyleMemo]} markdownStyle={markdownStyle} diff --git a/src/components/Composer/types.ts b/src/components/Composer/types.ts index 3824aac67445..7fc8708eb9aa 100644 --- a/src/components/Composer/types.ts +++ b/src/components/Composer/types.ts @@ -1,5 +1,5 @@ import type {Ref} from 'react'; -import type {StyleProp, TextInput, TextInputProps, TextInputSelectionChangeEvent, TextStyle} from 'react-native'; +import type {StyleProp, TextInputProps, TextInputSelectionChangeEvent, TextStyle} from 'react-native'; import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import type {FileObject} from '@src/types/utils/Attachment'; @@ -15,6 +15,8 @@ type CustomSelectionChangeEvent = TextInputSelectionChangeEvent & { positionY?: number; }; +type ComposerRef = AnimatedMarkdownTextInputRef & HTMLInputElement & HTMLTextAreaElement; + type ComposerProps = Omit & ForwardedFSClassProps & { /** Indicate whether input is multiline */ @@ -74,7 +76,7 @@ type ComposerProps = Omit & isGroupPolicyReport?: boolean; /** Ref exposing imperative methods on the underlying text input */ - ref?: Ref; + ref?: Ref; }; -export type {TextSelection, ComposerProps, CustomSelectionChangeEvent}; +export type {TextSelection, ComposerProps, CustomSelectionChangeEvent, ComposerRef}; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index faaf1a691cec..34824fd4acf9 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -2,7 +2,7 @@ import {useIsFocused, useNavigation, useRoute} from '@react-navigation/native'; import lodashDebounce from 'lodash/debounce'; import type {Ref, RefObject} from 'react'; import React, {memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; -import type {BlurEvent, LayoutChangeEvent, MeasureInWindowOnSuccessCallback, TextInput, TextInputContentSizeChangeEvent, TextInputKeyPressEvent, TextInputScrollEvent} from 'react-native'; +import type {BlurEvent, LayoutChangeEvent, MeasureInWindowOnSuccessCallback, TextInputContentSizeChangeEvent, TextInputKeyPressEvent, TextInputScrollEvent} from 'react-native'; import {DeviceEventEmitter, InteractionManager, NativeModules, StyleSheet, View} from 'react-native'; import {useFocusedInputHandler} from 'react-native-keyboard-controller'; import type {OnyxEntry} from 'react-native-onyx'; @@ -10,7 +10,7 @@ import {useAnimatedRef, useSharedValue} from 'react-native-reanimated'; import type {Emoji} from '@assets/emojis/types'; import type {MeasureParentContainerAndCursorCallback} from '@components/AutoCompleteSuggestions/types'; import Composer from '@components/Composer'; -import type {CustomSelectionChangeEvent, TextSelection} from '@components/Composer/types'; +import type {ComposerRef, CustomSelectionChangeEvent, TextSelection} from '@components/Composer/types'; import {useWideRHPState} from '@components/WideRHPContextProvider'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useKeyboardState from '@hooks/useKeyboardState'; @@ -64,6 +64,26 @@ type SyncSelection = { type NewlyAddedChars = {startIndex: number; endIndex: number; diff: string}; +type ComposerWithSuggestionsRef = ComposerRef & { + /** Focus the composer */ + focus: (shouldDelay?: boolean) => void; + + /** Replace the selection with text */ + replaceSelectionWithText: OnEmojiSelected; + + /** Get the current text of the composer */ + getCurrentText: () => string; + + /** + * Calling clear will immediately clear the input on the UI thread (its a worklet). + * Once the composer ahs cleared onCleared will be called with the value that was cleared. + */ + clearWorklet: () => void; + + /** Reset the height of the composer */ + resetHeight: () => void; +}; + type ComposerWithSuggestionsProps = Partial & ForwardedFSClassProps & { /** Report ID */ @@ -142,7 +162,7 @@ type ComposerWithSuggestionsProps = Partial & didHideComposerInput?: boolean; /** Reference to the outer element */ - ref?: Ref; + ref?: Ref; }; type SwitchToCurrentReportProps = { @@ -150,26 +170,6 @@ type SwitchToCurrentReportProps = { reportToCopyDraftTo: string; callback: () => void; }; - -type ComposerRef = { - blur: () => void; - focus: (shouldDelay?: boolean) => void; - replaceSelectionWithText: OnEmojiSelected; - getCurrentText: () => string; - isFocused: () => boolean; - - /** - * Calling clear will immediately clear the input on the UI thread (its a worklet). - * Once the composer ahs cleared onCleared will be called with the value that was cleared. - */ - clearWorklet: () => void; - - /** - * Reset the height of the composer. - */ - resetHeight: () => void; -}; - const {RNTextInputReset} = NativeModules; const isIOSNative = getPlatform() === CONST.PLATFORM.IOS; @@ -334,7 +334,7 @@ function ComposerWithSuggestions({ const [composerHeightAfterClear, setDefaultComposerHeight] = useState(null); const emptyComposerHeightRef = useRef(null); - const textInputRef = useRef(null); + const composerRef = useRef(null); const syncSelectionWithOnChangeTextRef = useRef(null); @@ -353,10 +353,10 @@ function ComposerWithSuggestions({ /** * Set the TextInput Ref */ - const setTextInputRef = useCallback( - (el: TextInput) => { + const setComposerRef = useCallback( + (el: ComposerRef) => { ReportActionComposeFocusManager.composerRef.current = el; - textInputRef.current = el; + composerRef.current = el; if (typeof animatedRef === 'function') { animatedRef(el); } @@ -650,7 +650,7 @@ function ComposerWithSuggestions({ InteractionManager.runAfterInteractions(() => { // note: this implementation is only available on non-web RN, thus the wrapping // 'if' block contains a redundant (since the ref is only used on iOS) platform check - textInputRef.current?.setSelection(positionSnapshot, positionSnapshot); + composerRef.current?.setSelection(positionSnapshot, positionSnapshot); }); } }, @@ -661,7 +661,7 @@ function ComposerWithSuggestions({ (e: CustomSelectionChangeEvent) => { setSelection(e.nativeEvent.selection); - if (!textInputRef.current?.isFocused()) { + if (!composerRef.current?.isFocused()) { return; } suggestionsRef.current?.onSelectionChange?.(e); @@ -695,7 +695,7 @@ function ComposerWithSuggestions({ const focus = useCallback((shouldDelay = false) => { // If we're stacked above another RHP, wait for the transition to complete before focusing. const delay = shouldDelayAutoFocusRef.current ? CONST.ANIMATED_TRANSITION : CONST.COMPOSER_FOCUS_DELAY; - focusComposerWithDelay(textInputRef.current, delay)(shouldDelay); + focusComposerWithDelay(composerRef.current, delay)(shouldDelay); }, []); /** @@ -782,10 +782,10 @@ function ComposerWithSuggestions({ ); const blur = useCallback(() => { - if (!textInputRef.current) { + if (!composerRef.current) { return; } - textInputRef.current.blur(); + composerRef.current.blur(); }, []); const clearWorklet = useCallback(() => { @@ -810,7 +810,7 @@ function ComposerWithSuggestions({ const unsubscribeNavigationFocus = navigation.addListener('focus', () => { addKeyDownPressListener(focusComposerOnKeyPress); // The report isn't unmounted and can be focused again after going back from another report so we should update the composerRef again - ReportActionComposeFocusManager.composerRef.current = textInputRef.current; + ReportActionComposeFocusManager.composerRef.current = composerRef.current; setUpComposeFocusManager(); }); addKeyDownPressListener(focusComposerOnKeyPress); @@ -838,7 +838,7 @@ function ComposerWithSuggestions({ // We want to blur the input immediately when a screen is out of focus. if (!isFocused) { - textInputRef.current?.blur(); + composerRef.current?.blur(); return; } @@ -863,22 +863,40 @@ function ComposerWithSuggestions({ useEffect(() => { // Scrolls the composer to the bottom and sets the selection to the end, so that longer drafts are easier to edit - updateMultilineInputRange(textInputRef.current, !!shouldAutoFocus); + updateMultilineInputRange(composerRef.current, !!shouldAutoFocus); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useImperativeHandle( ref, - () => ({ - blur, - focus, - replaceSelectionWithText, - isFocused: () => !!textInputRef.current?.isFocused(), - getCurrentText, - clearWorklet, - resetHeight, - }), - [blur, focus, replaceSelectionWithText, clearWorklet, resetHeight, getCurrentText], + () => + new Proxy( + {}, + { + get: (_target, prop) => { + if (prop === 'blur') { + return blur; + } + if (prop === 'focus') { + return focus; + } + if (prop === 'replaceSelectionWithText') { + return replaceSelectionWithText; + } + if (prop === 'getCurrentText') { + return getCurrentText; + } + if (prop === 'clearWorklet') { + return clearWorklet; + } + if (prop === 'resetHeight') { + return resetHeight; + } + + return composerRef.current?.[prop as keyof ComposerRef]; + }, + }, + ) as ComposerWithSuggestionsRef, ); useEffect(() => { @@ -897,7 +915,7 @@ function ComposerWithSuggestions({ useEffect(() => { // We use the tag to store the native ID of the text input. Later, we use it in onSelectionChange to pick up the proper text input data. - tag.set(findNodeHandle(textInputRef.current) ?? -1); + tag.set(findNodeHandle(composerRef.current) ?? -1); }, [tag]); useFocusedInputHandler( @@ -917,7 +935,7 @@ function ComposerWithSuggestions({ ); const measureParentContainerAndReportCursor = useCallback( (callback: MeasureParentContainerAndCursorCallback) => { - const {scrollValue} = getScrollPosition({mobileInputScrollPosition, textInputRef}); + const {scrollValue} = getScrollPosition({mobileInputScrollPosition, textInputRef: composerRef}); const {x: xPosition, y: yPosition} = getCursorPosition({positionOnMobile: cursorPositionValue.get(), positionOnWeb: selection}); measureParentContainer((x, y, width, height) => { callback({ @@ -974,7 +992,7 @@ function ComposerWithSuggestions({ } queueMicrotask(() => { - textInputRef.current?.setSelection?.(endOfSuggestionSelection, endOfSuggestionSelection); + composerRef.current?.setSelection?.(endOfSuggestionSelection, endOfSuggestionSelection); }); }, []); @@ -992,7 +1010,7 @@ function ComposerWithSuggestions({ // So we must also prevent the TextInput's immediate `autoFocus` and rely on our delayed manual focus instead. autoFocus={!!shouldAutoFocus && !shouldDelayAutoFocus} multiline - ref={setTextInputRef} + ref={setComposerRef} placeholder={inputPlaceholder} placeholderTextColor={theme.placeholderText} onChangeText={onChangeText} @@ -1008,7 +1026,7 @@ function ComposerWithSuggestions({ onBlur={onBlur} onClick={setShouldBlockSuggestionCalcToFalse} onPasteFile={(files) => { - textInputRef.current?.blur(); + composerRef.current?.blur(); onPasteFile(files); }} onClear={onClear} @@ -1030,7 +1048,7 @@ function ComposerWithSuggestions({ (null); + const textInputRef = useRef(null); const hasFocusBeenRequested = useRef(false); const onLayout = useCallback((event: LayoutChangeEvent) => { const testConfig = E2EClient.getCurrentActiveTestConfig(); From 47582d8099024656dfca8c95fb613c4521034372 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 13 Feb 2026 11:29:56 +0000 Subject: [PATCH 009/233] refactor: simplfiy logic around max comment length validation --- src/hooks/useHandleExceedMaxCommentLength.ts | 18 -------- .../useHandleExceedMaxTaskTitleLength.ts | 16 ------- .../useDebouncedCommentMaxLengthValidation.ts | 45 +++++++++++++++++++ 3 files changed, 45 insertions(+), 34 deletions(-) delete mode 100644 src/hooks/useHandleExceedMaxCommentLength.ts delete mode 100644 src/hooks/useHandleExceedMaxTaskTitleLength.ts create mode 100644 src/pages/inbox/report/ReportActionCompose/useDebouncedCommentMaxLengthValidation.ts diff --git a/src/hooks/useHandleExceedMaxCommentLength.ts b/src/hooks/useHandleExceedMaxCommentLength.ts deleted file mode 100644 index cec96b4ca8f6..000000000000 --- a/src/hooks/useHandleExceedMaxCommentLength.ts +++ /dev/null @@ -1,18 +0,0 @@ -import {useCallback, useState} from 'react'; -import {getCommentLength} from '@libs/ReportUtils'; -import type {ParsingDetails} from '@libs/ReportUtils'; -import CONST from '@src/CONST'; - -const useHandleExceedMaxCommentLength = () => { - const [hasExceededMaxCommentLength, setHasExceededMaxCommentLength] = useState(false); - - const validateCommentMaxLength = useCallback((value: string, parsingDetails?: ParsingDetails) => { - const exceeded = getCommentLength(value, parsingDetails) > CONST.MAX_COMMENT_LENGTH; - setHasExceededMaxCommentLength(exceeded); - return !exceeded; - }, []); - - return {hasExceededMaxCommentLength, validateCommentMaxLength, setHasExceededMaxCommentLength}; -}; - -export default useHandleExceedMaxCommentLength; diff --git a/src/hooks/useHandleExceedMaxTaskTitleLength.ts b/src/hooks/useHandleExceedMaxTaskTitleLength.ts deleted file mode 100644 index 75acd73280a4..000000000000 --- a/src/hooks/useHandleExceedMaxTaskTitleLength.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {useCallback, useState} from 'react'; -import CONST from '@src/CONST'; - -const useHandleExceedMaxTaskTitleLength = () => { - const [hasExceededMaxTaskTitleLength, setHasExceededMaxTitleLength] = useState(false); - - const validateTaskTitleMaxLength = useCallback((title: string) => { - const exceeded = title ? title.length > CONST.TITLE_CHARACTER_LIMIT : false; - setHasExceededMaxTitleLength(exceeded); - return !exceeded; - }, []); - - return {hasExceededMaxTaskTitleLength, validateTaskTitleMaxLength, setHasExceededMaxTitleLength}; -}; - -export default useHandleExceedMaxTaskTitleLength; diff --git a/src/pages/inbox/report/ReportActionCompose/useDebouncedCommentMaxLengthValidation.ts b/src/pages/inbox/report/ReportActionCompose/useDebouncedCommentMaxLengthValidation.ts new file mode 100644 index 000000000000..e83d9a678f82 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/useDebouncedCommentMaxLengthValidation.ts @@ -0,0 +1,45 @@ +import lodashDebounce from 'lodash/debounce'; +import {useState} from 'react'; +import {getCommentLength} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; + +type UseDebouncedCommentValidationProps = { + reportID: string | undefined; + isEditing?: boolean; +}; + +function useDebouncedCommentMaxLengthValidation({reportID, isEditing = false}: UseDebouncedCommentValidationProps) { + const [exceededMaxLength, setExceededMaxLength] = useState(null); + const [isTaskTitle, setIsTaskTitle] = useState(false); + + /** + * Updates the composer when the comment length is exceeded + * Shows red borders and prevents the comment from being sent + */ + function validateMaxLength(value: string) { + const taskCommentMatch = value?.match(CONST.REGEX.TASK_TITLE_WITH_OPTIONAL_SHORT_MENTION); + + if (!isEditing && taskCommentMatch) { + const title = taskCommentMatch?.[3] ? taskCommentMatch[3].trim().replaceAll('\n', ' ') : ''; + const exceeded = title ? title.length > CONST.TITLE_CHARACTER_LIMIT : false; + + setIsTaskTitle(exceeded); + setExceededMaxLength(exceeded ? CONST.TITLE_CHARACTER_LIMIT : null); + + return !exceeded; + } + + const exceeded = getCommentLength(value, {reportID}) > CONST.MAX_COMMENT_LENGTH; + + setIsTaskTitle(false); + setExceededMaxLength(exceeded ? CONST.MAX_COMMENT_LENGTH : null); + + return !exceeded; + } + + const debouncedCommentMaxLengthValidation = lodashDebounce(validateMaxLength, CONST.TIMING.COMMENT_LENGTH_DEBOUNCE_TIME, {leading: true}); + + return {debouncedCommentMaxLengthValidation, exceededMaxLength, isTaskTitle, isExceedingMaxLength: !!exceededMaxLength}; +} + +export default useDebouncedCommentMaxLengthValidation; From 44918d343eacdf39d882c5573e76219f1a598fa0 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 13 Feb 2026 11:30:04 +0000 Subject: [PATCH 010/233] fix: missing `import React` --- .../inbox/report/ReportActionCompose/MessageEditCancelButton.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/inbox/report/ReportActionCompose/MessageEditCancelButton.tsx b/src/pages/inbox/report/ReportActionCompose/MessageEditCancelButton.tsx index 4dc175d61dbd..12bdb74b7c4c 100644 --- a/src/pages/inbox/report/ReportActionCompose/MessageEditCancelButton.tsx +++ b/src/pages/inbox/report/ReportActionCompose/MessageEditCancelButton.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import {View} from 'react-native'; import Icon from '@components/Icon'; import {PressableWithFeedback} from '@components/Pressable'; From ee5ae8b6d5773068a937a60d2100e1309f4e72fd Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 13 Feb 2026 11:30:32 +0000 Subject: [PATCH 011/233] feat: extract comment length validation and use new hook --- .../ReportActionCompose.tsx | 120 ++++++-------- .../report/ReportActionCompose/SendButton.tsx | 33 +++- .../ReportActionCompose/useDeleteDraft.ts | 64 -------- .../ReportActionCompose/useEditMessage.ts | 122 +++++++++++++++ .../report/ReportActionItemMessageEdit.tsx | 148 +++++------------- 5 files changed, 231 insertions(+), 256 deletions(-) delete mode 100644 src/pages/inbox/report/ReportActionCompose/useDeleteDraft.ts create mode 100644 src/pages/inbox/report/ReportActionCompose/useEditMessage.ts diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 7e0f87a8dd2e..d452dee8d98a 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -1,4 +1,3 @@ -import lodashDebounce from 'lodash/debounce'; import noop from 'lodash/noop'; import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {BlurEvent, MeasureInWindowOnSuccessCallback, TextInputSelectionChangeEvent} from 'react-native'; @@ -20,8 +19,6 @@ import {usePersonalDetails} from '@components/OnyxListItemProvider'; import useAgentZeroStatusIndicator from '@hooks/useAgentZeroStatusIndicator'; import useAncestors from '@hooks/useAncestors'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useHandleExceedMaxCommentLength from '@hooks/useHandleExceedMaxCommentLength'; -import useHandleExceedMaxTaskTitleLength from '@hooks/useHandleExceedMaxTaskTitleLength'; import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTriggered'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -36,7 +33,6 @@ import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import ComposerFocusManager from '@libs/ComposerFocusManager'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import DomUtils from '@libs/DomUtils'; -import draftMessageVideoAttributeCache from '@libs/DraftMessageVideoAttributeCache'; import FS from '@libs/Fullstory'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Performance from '@libs/Performance'; @@ -66,7 +62,7 @@ import AgentZeroProcessingRequestIndicator from '@pages/inbox/report/AgentZeroPr import ParticipantLocalTime from '@pages/inbox/report/ParticipantLocalTime'; import ReportTypingIndicator from '@pages/inbox/report/ReportTypingIndicator'; import {hideEmojiPicker, isActive as isActiveEmojiPickerAction, isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; -import {addAttachmentWithComment, deleteReportActionDraft, editReportComment, setIsComposerFullSize} from '@userActions/Report'; +import {addAttachmentWithComment, setIsComposerFullSize} from '@userActions/Report'; import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -76,11 +72,12 @@ import type {FileObject} from '@src/types/utils/Attachment'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; import ComposerWithSuggestions from './ComposerWithSuggestions'; -import type {ComposerRef, ComposerWithSuggestionsProps} from './ComposerWithSuggestions/ComposerWithSuggestions'; +import type {ComposerWithSuggestionsProps, ComposerWithSuggestionsRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; import MessageEditCancelButton from './MessageEditCancelButton'; import SendButton from './SendButton'; import useAttachmentUploadValidation from './useAttachmentUploadValidation'; -import useDeleteDraft from './useDeleteDraft'; +import useDebouncedCommentMaxLengthValidation from './useDebouncedCommentMaxLengthValidation'; +import useEditMessage from './useEditMessage'; type SuggestionsRef = { resetSuggestions: () => void; @@ -150,7 +147,6 @@ function ReportActionCompose({ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth, isMediumScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); const {isOffline} = useNetwork(); - const {email} = useCurrentUserPersonalDetails(); const actionButtonRef = useRef(null); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const personalDetails = usePersonalDetails(); @@ -214,18 +210,10 @@ function ReportActionCompose({ const [isMenuVisible, setMenuVisibility] = useState(false); const [isAttachmentPreviewActive, setIsAttachmentPreviewActive] = useState(false); - /** - * Updates the composer when the comment length is exceeded - * Shows red borders and prevents the comment from being sent - */ - const {hasExceededMaxCommentLength, validateCommentMaxLength, setHasExceededMaxCommentLength} = useHandleExceedMaxCommentLength(); - const {hasExceededMaxTaskTitleLength, validateTaskTitleMaxLength, setHasExceededMaxTitleLength} = useHandleExceedMaxTaskTitleLength(); - const [exceededMaxLength, setExceededMaxLength] = useState(null); - const icons = useMemoizedLazyExpensifyIcons(['MessageInABottle']); const suggestionsRef = useRef(null); - const composerRef = useRef(null); + const composerRef = useRef(null); const reportParticipantIDs = useMemo( () => Object.keys(report?.participants ?? {}) @@ -259,14 +247,15 @@ function ReportActionCompose({ const [reportActionDrafts] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`, {canBeMissing: true}); - const editingReportActionID = useMemo(() => { - if (!reportActionDrafts) { - return null; + const [editingReportActionID, setEditingReportActionID] = useState(null); + useEffect(() => { + if (!reportActionDrafts || editingReportActionID) { + return; } const entry = Object.entries(reportActionDrafts).find(([, draft]) => draft?.message); - return entry?.[0] ?? null; - }, [reportActionDrafts]); + setEditingReportActionID(entry?.[0] ?? null); + }, [editingReportActionID, reportActionDrafts]); const editingReportAction = useMemo(() => { if (!editingReportActionID || !reportActions) { @@ -276,7 +265,7 @@ function ReportActionCompose({ return reportActions[editingReportActionID] ?? null; }, [editingReportActionID, reportActions]); - const isEditingInline = shouldUseNarrowLayout && !!editingReportAction; + const isEditing = shouldUseNarrowLayout && !!editingReportAction; const personalDetail = useCurrentUserPersonalDetails(); @@ -379,6 +368,15 @@ function ReportActionCompose({ ComposerFocusManager.setReadyToFocus(); }, [updateShouldShowSuggestionMenuToFalse]); + const {debouncedCommentMaxLengthValidation, exceededMaxLength, isExceedingMaxLength, isTaskTitle} = useDebouncedCommentMaxLengthValidation({reportID}); + + const {publishDraft, deleteDraft} = useEditMessage({reportID, reportAction: editingReportAction, index: 0, isFocused, debouncedCommentMaxLengthValidation, composerRef}); + + const deleteDraftMessage = useCallback(() => { + deleteDraft(); + setEditingReportActionID(null); + }, [deleteDraft]); + /** * Add a new comment to this chat */ @@ -390,9 +388,10 @@ function ReportActionCompose({ return; } - if (isEditingInline && !attachmentFileRef.current) { - editReportComment(report, editingReportAction, ancestors, newCommentTrimmed, isReportArchived, false, email ?? '', Object.fromEntries(draftMessageVideoAttributeCache)); - deleteReportActionDraft(reportID, editingReportAction); + if (isEditing && !attachmentFileRef.current) { + publishDraft(newCommentTrimmed); + deleteDraft(); + setEditingReportActionID(null); return; } @@ -427,16 +426,15 @@ function ReportActionCompose({ } }, [ - isEditingInline, + isEditing, isConciergeChat, - report, - editingReportAction, - ancestors, - isReportArchived, - email, - reportID, + publishDraft, + deleteDraft, kickoffWaitingIndicator, transactionThreadReport, + report, + reportID, + ancestors, currentUserPersonalDetails.accountID, personalDetail.timezone, isInSidePanel, @@ -474,16 +472,6 @@ function ReportActionCompose({ setIsCommentEmpty(!valueToCheck || !!valueToCheck.match(CONST.REGEX.EMPTY_COMMENT)); }, [activeInlineDraft?.message, draftComment, shouldUseNarrowLayout]); - useEffect(() => { - if (hasExceededMaxTaskTitleLength) { - setExceededMaxLength(CONST.TITLE_CHARACTER_LIMIT); - } else if (hasExceededMaxCommentLength) { - setExceededMaxLength(CONST.MAX_COMMENT_LENGTH); - } else { - setExceededMaxLength(null); - } - }, [hasExceededMaxTaskTitleLength, hasExceededMaxCommentLength]); - // We are returning a callback here as we want to invoke the method on unmount only useEffect( () => () => { @@ -504,30 +492,15 @@ function ReportActionCompose({ const hasReportRecipient = !isEmptyObject(reportRecipient); - const isSendDisabled = isCommentEmpty || isBlockedFromConcierge || !!exceededMaxLength; - - const validateMaxLength = useCallback( - (value: string) => { - const taskCommentMatch = value?.match(CONST.REGEX.TASK_TITLE_WITH_OPTIONAL_SHORT_MENTION); - if (taskCommentMatch) { - const title = taskCommentMatch?.[3] ? taskCommentMatch[3].trim().replaceAll('\n', ' ') : ''; - setHasExceededMaxCommentLength(false); - return validateTaskTitleMaxLength(title); - } - setHasExceededMaxTitleLength(false); - return validateCommentMaxLength(value, {reportID}); - }, - [setHasExceededMaxCommentLength, setHasExceededMaxTitleLength, validateTaskTitleMaxLength, validateCommentMaxLength, reportID], - ); - - const debouncedValidate = useMemo(() => lodashDebounce(validateMaxLength, CONST.TIMING.COMMENT_LENGTH_DEBOUNCE_TIME, {leading: true}), [validateMaxLength]); + const isNewCommentEmpty = isCommentEmpty && !isEditing; + const isSendDisabled = !isEditing && (isBlockedFromConcierge || isExceedingMaxLength || isNewCommentEmpty); // Note: using JS refs is not well supported in reanimated, thus we need to store the function in a shared value // useSharedValue on web doesn't support functions, so we need to wrap it in an object. - const composerRefShared = useSharedValue>({}); + const composerRefShared = useSharedValue>({}); const handleSendMessage = useCallback(() => { - if (isSendDisabled || !debouncedValidate.flush()) { + if (isSendDisabled || !debouncedCommentMaxLengthValidation.flush()) { return; } @@ -545,9 +518,7 @@ function ReportActionCompose({ clearWorklet?.(); }); - }, [isSendDisabled, debouncedValidate, isComposerFullSize, reportID, composerRefShared]); - - const deleteDraft = useDeleteDraft({reportID, reportAction: editingReportAction, index: 0, isFocused}); + }, [isSendDisabled, debouncedCommentMaxLengthValidation, isComposerFullSize, reportID, composerRefShared]); onSubmitAction = handleSendMessage; const emojiPositionValues = useMemo( @@ -585,9 +556,9 @@ function ReportActionCompose({ if (value.length === 0 && isComposerFullSize) { setIsComposerFullSize(reportID, false); } - debouncedValidate(value); + debouncedCommentMaxLengthValidation(value); }, - [isComposerFullSize, reportID, debouncedValidate], + [isComposerFullSize, reportID, debouncedCommentMaxLengthValidation], ); const {validateAttachments, onReceiptDropped, PDFValidationComponent, ErrorModal} = useAttachmentUploadValidation({ @@ -627,12 +598,12 @@ function ReportActionCompose({ styles.flexRow, styles.chatItemComposeBox, isComposerFullSize && styles.chatItemFullComposeBox, - !!exceededMaxLength && styles.borderColorDanger, + isExceedingMaxLength && styles.borderColorDanger, ]} > {PDFValidationComponent} - {isEditingInline ? ( - + {isEditing ? ( + ) : ( validateAttachments({files})} @@ -656,7 +627,7 @@ function ReportActionCompose({ focus(); }} actionButtonRef={actionButtonRef} - shouldDisableAttachmentItem={!!exceededMaxLength} + shouldDisableAttachmentItem={isExceedingMaxLength} /> )} )} {ErrorModal} @@ -752,7 +724,7 @@ function ReportActionCompose({ {!!exceededMaxLength && ( )} @@ -769,4 +741,4 @@ function ReportActionCompose({ export default memo(ReportActionCompose); export {onSubmitAction}; -export type {SuggestionsRef, ComposerRef, ReportActionComposeProps}; +export type {SuggestionsRef, ReportActionComposeProps}; diff --git a/src/pages/inbox/report/ReportActionCompose/SendButton.tsx b/src/pages/inbox/report/ReportActionCompose/SendButton.tsx index f283195e4e57..e8a3a48ce4a6 100644 --- a/src/pages/inbox/report/ReportActionCompose/SendButton.tsx +++ b/src/pages/inbox/report/ReportActionCompose/SendButton.tsx @@ -15,11 +15,14 @@ type SendButtonProps = { /** Whether the button is disabled */ isDisabled: boolean; + /** Whether the button is in editing mode */ + isEditing?: boolean; + /** Handle clicking on send button */ - handleSendMessage: () => void; + onSend: () => void; }; -function SendButton({isDisabled: isDisabledProp, handleSendMessage}: SendButtonProps) { +function SendButton({isDisabled: isDisabledProp, onSend, isEditing = false}: SendButtonProps) { const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -28,10 +31,13 @@ function SendButton({isDisabled: isDisabledProp, handleSendMessage}: SendButtonP const {isSmallScreenWidth} = useResponsiveLayout(); const Tap = Gesture.Tap() .onEnd(() => { - handleSendMessage(); + onSend(); }) .runOnJS(true); + const label = isEditing ? translate('common.saveChanges') : translate('common.send'); + const sentryLabel = isEditing ? CONST.SENTRY_LABEL.REPORT.REPORT_ACTION_ITEM_MESSAGE_EDIT_SAVE_BUTTON : CONST.SENTRY_LABEL.REPORT.SEND_BUTTON; + return ( - + [ styles.chatItemSubmitButton, @@ -62,7 +68,7 @@ function SendButton({isDisabled: isDisabledProp, handleSendMessage}: SendButtonP // On Android when TalkBack is enabled, only the parent element should be accessible, otherwise the button will not work. accessible={false} focusable={false} - sentryLabel={CONST.SENTRY_LABEL.REPORT.SEND_BUTTON} + sentryLabel={sentryLabel} > {({pressed}) => ( + + {/* + e.preventDefault()} + > + + + */} ); diff --git a/src/pages/inbox/report/ReportActionCompose/useDeleteDraft.ts b/src/pages/inbox/report/ReportActionCompose/useDeleteDraft.ts deleted file mode 100644 index c14457aee8fa..000000000000 --- a/src/pages/inbox/report/ReportActionCompose/useDeleteDraft.ts +++ /dev/null @@ -1,64 +0,0 @@ -import {useEffect, useRef} from 'react'; -import {InteractionManager} from 'react-native'; -import useReportScrollManager from '@hooks/useReportScrollManager'; -import {isActive as isEmojiPickerActive} from '@libs/actions/EmojiPickerAction'; -import {deleteReportActionDraft} from '@libs/actions/Report'; -import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; -import * as ReportActionContextMenu from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; -import type * as OnyxTypes from '@src/types/onyx'; -import KeyboardUtils from '@src/utils/keyboard'; - -type UseDeleteDraftProps = { - reportID: string | undefined; - reportAction: OnyxTypes.ReportAction | null | undefined; - index: number; - isFocused: boolean; -}; - -/** - * Delete the draft of the comment being edited. This will take the comment out of "edit mode" with the old content. - */ -function useDeleteDraft({reportID, reportAction, index, isFocused}: UseDeleteDraftProps) { - const reportScrollManager = useReportScrollManager(); - const isFocusedRef = useRef(isFocused); - - useEffect(() => { - // required for keeping last state of isFocused variable - isFocusedRef.current = isFocused; - }, [isFocused]); - - // We consider the report action active if it's focused, its emoji picker is open or its context menu is open - function isActive(): boolean { - if (!reportAction) { - return false; - } - - return isFocusedRef.current || isEmojiPickerActive(reportAction.reportActionID) || ReportActionContextMenu.isActiveReportAction(reportAction.reportActionID); - } - - function deleteDraft(): void { - if (!reportAction) { - return; - } - - deleteReportActionDraft(reportID, reportAction); - - if (isActive()) { - ReportActionComposeFocusManager.clear(true); - // Wait for report action compose re-mounting on mWeb - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => ReportActionComposeFocusManager.focus()); - } - - // Scroll to the last comment after editing to make sure the whole comment is clearly visible in the report. - if (index === 0) { - KeyboardUtils.dismiss().then(() => { - reportScrollManager.scrollToIndex(index, false); - }); - } - } - - return deleteDraft; -} - -export default useDeleteDraft; diff --git a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts new file mode 100644 index 000000000000..124d7c125383 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts @@ -0,0 +1,122 @@ +// eslint-disable-next-line lodash/import-scope +import type {DebouncedFuncLeading} from 'lodash'; +import type React from 'react'; +import {useEffect, useRef} from 'react'; +import {InteractionManager} from 'react-native'; +import type {ComposerRef} from '@components/Composer/types'; +import useAncestors from '@hooks/useAncestors'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useOnyx from '@hooks/useOnyx'; +import useReportIsArchived from '@hooks/useReportIsArchived'; +import useReportScrollManager from '@hooks/useReportScrollManager'; +import {isActive as isEmojiPickerActive} from '@libs/actions/EmojiPickerAction'; +import {deleteReportActionDraft, editReportComment} from '@libs/actions/Report'; +import draftMessageVideoAttributeCache from '@libs/DraftMessageVideoAttributeCache'; +import focusEditAfterCancelDelete from '@libs/focusEditAfterCancelDelete'; +import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; +import {getOriginalReportID} from '@libs/ReportUtils'; +import * as ReportActionContextMenu from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; +import KeyboardUtils from '@src/utils/keyboard'; + +type UseEditMessageProps = { + reportID: string | undefined; + originalReportID?: string; + reportAction: OnyxTypes.ReportAction | null | undefined; + index: number; + isFocused: boolean; + debouncedCommentMaxLengthValidation: DebouncedFuncLeading<(value: string) => boolean>; + composerRef: React.RefObject; +}; + +/** + * Delete the draft of the comment being edited. This will take the comment out of "edit mode" with the old content. + */ +function useEditMessage({reportID, originalReportID, reportAction, index, isFocused, debouncedCommentMaxLengthValidation, composerRef}: UseEditMessageProps) { + const reportScrollManager = useReportScrollManager(); + const isFocusedRef = useRef(isFocused); + + const {email} = useCurrentUserPersonalDetails(); + const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${originalReportID}`, {canBeMissing: true}); + const originalParentReportID = getOriginalReportID(originalReportID, reportAction, reportActions); + const [originalReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${originalReportID}`, {canBeMissing: true}); + const isOriginalReportArchived = useReportIsArchived(originalReportID); + const isOriginalParentReportArchived = useReportIsArchived(originalParentReportID); + const ancestors = useAncestors(originalReport); + + useEffect(() => { + // required for keeping last state of isFocused variable + isFocusedRef.current = isFocused; + }, [isFocused]); + + // We consider the report action active if it's focused, its emoji picker is open or its context menu is open + function isActive(): boolean { + if (!reportAction) { + return false; + } + + return isFocusedRef.current || isEmojiPickerActive(reportAction.reportActionID) || ReportActionContextMenu.isActiveReportAction(reportAction.reportActionID); + } + + function deleteDraft(): void { + if (!reportAction) { + return; + } + + deleteReportActionDraft(reportID, reportAction); + + if (isActive()) { + ReportActionComposeFocusManager.clear(true); + // Wait for report action compose re-mounting on mWeb + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => ReportActionComposeFocusManager.focus()); + } + + // Scroll to the last comment after editing to make sure the whole comment is clearly visible in the report. + if (index === 0) { + KeyboardUtils.dismiss().then(() => { + reportScrollManager.scrollToIndex(index, false); + }); + } + } + + /** + * Save the draft of the comment to be the new comment message. This will take the comment out of "edit mode" with + * the new content. + */ + function publishDraft(draftMessage: string) { + if (!reportAction) { + return; + } + + // Do nothing if draft exceed the character limit + if (!debouncedCommentMaxLengthValidation.flush()) { + return; + } + + const trimmedNewDraft = draftMessage.trim(); + + // When user tries to save the empty message, it will delete it. Prompt the user to confirm deleting. + if (!trimmedNewDraft) { + composerRef.current?.blur(); + ReportActionContextMenu.showDeleteModal(originalReportID ?? reportID, reportAction, true, deleteDraft, () => focusEditAfterCancelDelete(composerRef.current)); + return; + } + editReportComment( + originalReport, + reportAction, + ancestors, + trimmedNewDraft, + isOriginalReportArchived, + isOriginalParentReportArchived, + email ?? '', + Object.fromEntries(draftMessageVideoAttributeCache), + ); + deleteDraft(); + } + + return {publishDraft, deleteDraft}; +} + +export default useEditMessage; diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index 450155fdb5e8..26d2edef554a 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -1,39 +1,29 @@ import lodashDebounce from 'lodash/debounce'; -import type {ForwardedRef} from 'react'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {InteractionManager, View} from 'react-native'; -import type {BlurEvent, MeasureInWindowOnSuccessCallback, TextInput, TextInputKeyPressEvent, TextInputScrollEvent} from 'react-native'; +import type {BlurEvent, MeasureInWindowOnSuccessCallback, TextInputKeyPressEvent, TextInputScrollEvent} from 'react-native'; import {useFocusedInputHandler} from 'react-native-keyboard-controller'; import {useSharedValue} from 'react-native-reanimated'; import type {Emoji} from '@assets/emojis/types'; import type {MeasureParentContainerAndCursorCallback} from '@components/AutoCompleteSuggestions/types'; import Composer from '@components/Composer'; -import type {TextSelection} from '@components/Composer/types'; +import type {ComposerRef, TextSelection} from '@components/Composer/types'; import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; import ExceededCommentLength from '@components/ExceededCommentLength'; -import Icon from '@components/Icon'; -import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; -import Tooltip from '@components/Tooltip'; -import useAncestors from '@hooks/useAncestors'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useHandleExceedMaxCommentLength from '@hooks/useHandleExceedMaxCommentLength'; import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTriggered'; import useKeyboardState from '@hooks/useKeyboardState'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePrevious from '@hooks/usePrevious'; -import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportScrollManager from '@hooks/useReportScrollManager'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useScrollBlocker from '@hooks/useScrollBlocker'; import useStyleUtils from '@hooks/useStyleUtils'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {setShouldShowComposeInput} from '@libs/actions/Composer'; import {clearActive, isActive as isEmojiPickerActive, isEmojiPickerVisible} from '@libs/actions/EmojiPickerAction'; import {composerFocusKeepFocusOn} from '@libs/actions/InputFocus'; -import {editReportComment, saveReportActionDraft} from '@libs/actions/Report'; +import {saveReportActionDraft} from '@libs/actions/Report'; import {isMobileChrome} from '@libs/Browser'; import {canSkipTriggerHotkeys, insertText} from '@libs/ComposerUtils'; import DomUtils from '@libs/DomUtils'; @@ -41,12 +31,10 @@ import draftMessageVideoAttributeCache from '@libs/DraftMessageVideoAttributeCac import {extractEmojis, getZWNJCursorOffset, insertZWNJBetweenDigitAndEmoji, replaceAndExtractEmojis} from '@libs/EmojiUtils'; import focusComposerWithDelay from '@libs/focusComposerWithDelay'; import type {Selection} from '@libs/focusComposerWithDelay/types'; -import focusEditAfterCancelDelete from '@libs/focusEditAfterCancelDelete'; import Parser from '@libs/Parser'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import reportActionItemEventHandler from '@libs/ReportActionItemEventHandler'; import {getReportActionHtml, isDeletedAction} from '@libs/ReportActionsUtils'; -import {getOriginalReportID} from '@libs/ReportUtils'; import setShouldShowComposeInputKeyboardAware from '@libs/setShouldShowComposeInputKeyboardAware'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -58,8 +46,10 @@ import getCursorPosition from './ReportActionCompose/getCursorPosition'; import getScrollPosition from './ReportActionCompose/getScrollPosition'; import MessageEditCancelButton from './ReportActionCompose/MessageEditCancelButton'; import type {SuggestionsRef} from './ReportActionCompose/ReportActionCompose'; +import SendButton from './ReportActionCompose/SendButton'; import Suggestions from './ReportActionCompose/Suggestions'; -import useDeleteDraft from './ReportActionCompose/useDeleteDraft'; +import useDebouncedCommentMaxLengthValidation from './ReportActionCompose/useDebouncedCommentMaxLengthValidation'; +import useEditMessage from './ReportActionCompose/useEditMessage'; import shouldUseEmojiPickerSelection from './shouldUseEmojiPickerSelection'; type ReportActionItemMessageEditProps = { @@ -88,7 +78,7 @@ type ReportActionItemMessageEditProps = { isGroupPolicyReport: boolean; /** Reference to the outer element */ - ref?: ForwardedRef; + ref?: React.Ref; }; const shouldUseForcedSelectionRange = shouldUseEmojiPickerSelection(); @@ -110,13 +100,11 @@ function ReportActionItemMessageEdit({ ref, }: ReportActionItemMessageEditProps) { const [preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE] = useOnyx(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, {canBeMissing: true}); - const {email} = useCurrentUserPersonalDetails(); - const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const containerRef = useRef(null); const reportScrollManager = useReportScrollManager(); - const {translate, preferredLocale} = useLocalize(); + const {preferredLocale} = useLocalize(); const {isKeyboardShown} = useKeyboardState(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const prevDraftMessage = usePrevious(draftMessage); @@ -133,8 +121,8 @@ function ReportActionItemMessageEdit({ }); const [selection, setSelection] = useState({start: draft.length, end: draft.length, positionX: 0, positionY: 0}); const [isFocused, setIsFocused] = useState(false); - const {hasExceededMaxCommentLength, validateCommentMaxLength} = useHandleExceedMaxCommentLength(); - const debouncedValidateCommentMaxLength = useMemo(() => lodashDebounce(validateCommentMaxLength, CONST.TIMING.COMMENT_LENGTH_DEBOUNCE_TIME), [validateCommentMaxLength]); + + const {debouncedCommentMaxLengthValidation, isExceedingMaxLength} = useDebouncedCommentMaxLengthValidation({reportID}); const {isScrollLayoutTriggered, raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); @@ -143,19 +131,12 @@ function ReportActionItemMessageEdit({ const {isScrolling, startScrollBlock, endScrollBlock} = useScrollBlocker(); - const textInputRef = useRef<(HTMLTextAreaElement & TextInput) | null>(null); + const composerRef = useRef(null); const isFocusedRef = useRef(false); const draftRef = useRef(draft); const emojiPickerSelectionRef = useRef(undefined); // The ref to check whether the comment saving is in progress const isCommentPendingSaved = useRef(false); - const [originalReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${originalReportID}`, {canBeMissing: true}); - const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${originalReportID}`, {canBeMissing: true}); - const isOriginalReportArchived = useReportIsArchived(originalReportID); - const originalParentReportID = getOriginalReportID(originalReportID, action, reportActions); - const isOriginalParentReportArchived = useReportIsArchived(originalParentReportID); - const ancestors = useAncestors(originalReport); - const icons = useMemoizedLazyExpensifyIcons(['Checkmark', 'Close']); useEffect(() => { draftMessageVideoAttributeCache.clear(); @@ -170,17 +151,17 @@ function ReportActionItemMessageEdit({ }, [draftMessage, action, prevDraftMessage]); useEffect(() => { - composerFocusKeepFocusOn(textInputRef.current as HTMLElement, isFocused, modal, onyxInputFocused); + composerFocusKeepFocusOn(composerRef.current as HTMLElement, isFocused, modal, onyxInputFocused); }, [isFocused, modal, onyxInputFocused]); useEffect( // Remove focus callback on unmount to avoid stale callbacks () => { - if (textInputRef.current) { - ReportActionComposeFocusManager.editComposerRef.current = textInputRef.current; + if (composerRef.current) { + ReportActionComposeFocusManager.editComposerRef.current = composerRef.current; } return () => { - if (ReportActionComposeFocusManager.editComposerRef.current !== textInputRef.current) { + if (ReportActionComposeFocusManager.editComposerRef.current !== composerRef.current) { return; } ReportActionComposeFocusManager.clear(true); @@ -194,7 +175,7 @@ function ReportActionItemMessageEdit({ * @param shouldDelay - Impose delay before focusing the composer */ const focus = useCallback((shouldDelay = false, forcedSelectionRange?: Selection) => { - focusComposerWithDelay(textInputRef.current)(shouldDelay, forcedSelectionRange); + focusComposerWithDelay(composerRef.current)(shouldDelay, forcedSelectionRange); }, []); // Take over focus priority @@ -271,50 +252,9 @@ function ReportActionItemMessageEdit({ // eslint-disable-next-line react-hooks/exhaustive-deps -- run this only when language is changed }, [action.reportActionID, preferredLocale]); - const deleteDraft = useDeleteDraft({reportID, reportAction: action, index, isFocused}); - - /** - * Save the draft of the comment to be the new comment message. This will take the comment out of "edit mode" with - * the new content. - */ - const publishDraft = useCallback(() => { - // Do nothing if draft exceed the character limit - if (!debouncedValidateCommentMaxLength.flush()) { - return; - } + const {publishDraft, deleteDraft} = useEditMessage({reportID, originalReportID, reportAction: action, index, isFocused, debouncedCommentMaxLengthValidation, composerRef}); - const trimmedNewDraft = draft.trim(); - - // When user tries to save the empty message, it will delete it. Prompt the user to confirm deleting. - if (!trimmedNewDraft) { - textInputRef.current?.blur(); - ReportActionContextMenu.showDeleteModal(originalReportID ?? reportID, action, true, deleteDraft, () => focusEditAfterCancelDelete(textInputRef.current)); - return; - } - editReportComment( - originalReport, - action, - ancestors, - trimmedNewDraft, - isOriginalReportArchived, - isOriginalParentReportArchived, - email ?? '', - Object.fromEntries(draftMessageVideoAttributeCache), - ); - deleteDraft(); - }, [ - reportID, - action, - ancestors, - deleteDraft, - draft, - originalReportID, - isOriginalReportArchived, - originalReport, - isOriginalParentReportArchived, - debouncedValidateCommentMaxLength, - email, - ]); + const publishDraftMessage = useCallback(() => publishDraft(draft), [publishDraft, draft]); /** * @param emoji @@ -378,13 +318,13 @@ function ReportActionItemMessageEdit({ } if (keyEvent.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && !keyEvent.shiftKey) { e.preventDefault(); - publishDraft(); + publishDraftMessage(); } else if (keyEvent.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) { e.preventDefault(); deleteDraft(); } }, - [deleteDraft, hideSuggestionMenu, isKeyboardShown, shouldUseNarrowLayout, publishDraft], + [deleteDraft, hideSuggestionMenu, isKeyboardShown, shouldUseNarrowLayout, publishDraftMessage], ); const measureContainer = useCallback((callback: MeasureInWindowOnSuccessCallback) => { @@ -397,7 +337,7 @@ function ReportActionItemMessageEdit({ const measureParentContainerAndReportCursor = useCallback( (callback: MeasureParentContainerAndCursorCallback) => { const performMeasurement = () => { - const {scrollValue} = getScrollPosition({mobileInputScrollPosition, textInputRef}); + const {scrollValue} = getScrollPosition({mobileInputScrollPosition, textInputRef: composerRef}); const {x: xPosition, y: yPosition} = getCursorPosition({positionOnMobile: cursorPositionValue.get(), positionOnWeb: selection}); measureContainer((x, y, width, height) => { callback({ @@ -422,7 +362,7 @@ function ReportActionItemMessageEdit({ useEffect(() => { // We use the tag to store the native ID of the text input. Later, we use it in onSelectionChange to pick up the proper text input data. - tag.set(findNodeHandle(textInputRef.current) ?? -1); + tag.set(findNodeHandle(composerRef.current) ?? -1); }, [tag]); useFocusedInputHandler( { @@ -441,8 +381,8 @@ function ReportActionItemMessageEdit({ ); useEffect(() => { - debouncedValidateCommentMaxLength(draft, {reportID}); - }, [draft, reportID, debouncedValidateCommentMaxLength]); + debouncedCommentMaxLengthValidation(draft); + }, [draft, debouncedCommentMaxLengthValidation]); useEffect(() => { // required for keeping last state of isFocused variable @@ -465,15 +405,15 @@ function ReportActionItemMessageEdit({ styles.flexRow, styles.flex1, styles.chatItemComposeBox, - hasExceededMaxCommentLength && styles.borderColorDanger, + isExceedingMaxLength && styles.borderColorDanger, ]} > { - textInputRef.current = el; + ref={(el) => { + composerRef.current = el; if (typeof ref === 'function') { ref(el); } else if (ref) { @@ -489,8 +429,8 @@ function ReportActionItemMessageEdit({ style={[styles.textInputCompose, styles.flex1, styles.bgTransparent, styles.textAlignLeft]} onFocus={() => { setIsFocused(true); - if (textInputRef.current) { - ReportActionComposeFocusManager.editComposerRef.current = textInputRef.current; + if (composerRef.current) { + ReportActionComposeFocusManager.editComposerRef.current = composerRef.current; } startScrollBlock(); // eslint-disable-next-line @typescript-eslint/no-deprecated @@ -540,7 +480,7 @@ function ReportActionItemMessageEdit({ - - - e.preventDefault()} - sentryLabel={CONST.SENTRY_LABEL.REPORT.REPORT_ACTION_ITEM_MESSAGE_EDIT_SAVE_BUTTON} - > - - - - + - {hasExceededMaxCommentLength && } + {isExceedingMaxLength && } ); } From ab3ee6e7bea307c3359489fdde8b19b38e64a4bb Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 13 Feb 2026 11:31:36 +0000 Subject: [PATCH 012/233] reactor: update composer ref types in `ReportActionComposeFocusManager` --- src/libs/ReportActionComposeFocusManager.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/libs/ReportActionComposeFocusManager.ts b/src/libs/ReportActionComposeFocusManager.ts index 435468cb8a0e..4807c2554344 100644 --- a/src/libs/ReportActionComposeFocusManager.ts +++ b/src/libs/ReportActionComposeFocusManager.ts @@ -1,7 +1,6 @@ import {findFocusedRoute} from '@react-navigation/native'; -import type {RefObject} from 'react'; import React from 'react'; -import type {TextInput} from 'react-native'; +import type {ComposerRef} from '@components/Composer/types'; import SCREENS from '@src/SCREENS'; import isReportOpenInRHP from './Navigation/helpers/isReportOpenInRHP'; import navigationRef from './Navigation/navigationRef'; @@ -11,11 +10,11 @@ type ComposerType = 'main' | 'edit'; type FocusCallback = (shouldFocusForNonBlurInputOnTapOutside?: boolean) => void; -const composerRef: RefObject = React.createRef(); - // There are two types of composer: general composer (edit composer) and main composer. // The general composer callback will take priority if it exists. -const editComposerRef: RefObject = React.createRef(); +const composerRef = React.createRef(); +const editComposerRef = React.createRef(); + // There are two types of focus callbacks: priority and general // Priority callback would take priority if it existed let priorityFocusCallback: FocusCallback | null = null; From 1d2f1d9ccac91c4525722886b15b20fc0495d2e6 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 13 Feb 2026 15:50:23 +0000 Subject: [PATCH 013/233] fix: remove proxy for `useImperativeHandle` --- .../Composer/implementation/index.tsx | 42 ++++++++----------- 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/src/components/Composer/implementation/index.tsx b/src/components/Composer/implementation/index.tsx index 301b74f2cacb..f3a47b879700 100755 --- a/src/components/Composer/implementation/index.tsx +++ b/src/components/Composer/implementation/index.tsx @@ -285,30 +285,24 @@ function Composer({ onClear(currentText); }, [onClear, onSelectionChange]); - useImperativeHandle( - ref, - () => - new Proxy( - {}, - { - get: (_target, prop) => { - if (prop === 'clear') { - return clear; - } - if (prop === 'blur') { - return () => textInputRef.current?.blur(); - } - if (prop === 'focus') { - return () => textInputRef.current?.focus(); - } - if (prop === 'scrollTop') { - return textInputRef.current?.scrollTop; - } - return textInputRef.current?.[prop as keyof AnimatedMarkdownTextInputRef]; - }, - }, - ) as ComposerRef, - ); + useImperativeHandle(ref, () => { + const textInput = textInputRef.current; + if (!textInput) { + throw new Error('textInput is not available. This should never happen and indicates a developer error.'); + } + + return { + ...textInput, + // Overwrite clear with our custom implementation, which mimics how the native TextInput's clear method works + clear, + // We have to redefine these methods as they are inherited by prototype chain and are not accessible directly + blur: () => textInput.blur(), + focus: () => textInput.focus(), + get scrollTop() { + return textInput.scrollTop; + }, + } as ComposerRef; + }, [clear]); const handleKeyPress = useCallback( (e: TextInputKeyPressEvent) => { From 3ab79b7af8d8c46684be3f428a4554af503d32ae Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 13 Feb 2026 16:18:23 +0000 Subject: [PATCH 014/233] refactor: update props on `useEditMessage` hook --- .../report/ReportActionCompose/ReportActionCompose.tsx | 9 ++++++++- .../inbox/report/ReportActionCompose/useEditMessage.ts | 8 ++++---- src/pages/inbox/report/ReportActionItemMessageEdit.tsx | 10 +++++++++- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index d452dee8d98a..309d55c90f9a 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -370,7 +370,14 @@ function ReportActionCompose({ const {debouncedCommentMaxLengthValidation, exceededMaxLength, isExceedingMaxLength, isTaskTitle} = useDebouncedCommentMaxLengthValidation({reportID}); - const {publishDraft, deleteDraft} = useEditMessage({reportID, reportAction: editingReportAction, index: 0, isFocused, debouncedCommentMaxLengthValidation, composerRef}); + const {publishDraft, deleteDraft} = useEditMessage({ + reportID, + reportAction: activeEdit?.reportAction, + shouldScrollToLastMessage: isEditingLastReportAction, + isFocused, + debouncedCommentMaxLengthValidation, + composerRef, + }); const deleteDraftMessage = useCallback(() => { deleteDraft(); diff --git a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts index 124d7c125383..62847bfc78c2 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts @@ -24,7 +24,7 @@ type UseEditMessageProps = { reportID: string | undefined; originalReportID?: string; reportAction: OnyxTypes.ReportAction | null | undefined; - index: number; + shouldScrollToLastMessage?: boolean; isFocused: boolean; debouncedCommentMaxLengthValidation: DebouncedFuncLeading<(value: string) => boolean>; composerRef: React.RefObject; @@ -33,7 +33,7 @@ type UseEditMessageProps = { /** * Delete the draft of the comment being edited. This will take the comment out of "edit mode" with the old content. */ -function useEditMessage({reportID, originalReportID, reportAction, index, isFocused, debouncedCommentMaxLengthValidation, composerRef}: UseEditMessageProps) { +function useEditMessage({reportID, originalReportID, reportAction, shouldScrollToLastMessage = false, isFocused, debouncedCommentMaxLengthValidation, composerRef}: UseEditMessageProps) { const reportScrollManager = useReportScrollManager(); const isFocusedRef = useRef(isFocused); @@ -74,9 +74,9 @@ function useEditMessage({reportID, originalReportID, reportAction, index, isFocu } // Scroll to the last comment after editing to make sure the whole comment is clearly visible in the report. - if (index === 0) { + if (shouldScrollToLastMessage) { KeyboardUtils.dismiss().then(() => { - reportScrollManager.scrollToIndex(index, false); + reportScrollManager.scrollToIndex(0, false); }); } } diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index 26d2edef554a..48cc943a9239 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -252,7 +252,15 @@ function ReportActionItemMessageEdit({ // eslint-disable-next-line react-hooks/exhaustive-deps -- run this only when language is changed }, [action.reportActionID, preferredLocale]); - const {publishDraft, deleteDraft} = useEditMessage({reportID, originalReportID, reportAction: action, index, isFocused, debouncedCommentMaxLengthValidation, composerRef}); + const {publishDraft, deleteDraft} = useEditMessage({ + reportID, + originalReportID, + reportAction: action, + shouldScrollToLastMessage: index === 0, + isFocused, + debouncedCommentMaxLengthValidation, + composerRef, + }); const publishDraftMessage = useCallback(() => publishDraft(draft), [publishDraft, draft]); From 5717b79107ac0fd5ea1b232293e5ac7c662455bf Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 13 Feb 2026 16:18:57 +0000 Subject: [PATCH 015/233] refactor: editing logic --- src/libs/actions/Report/index.ts | 6 +- .../inbox/report/PureReportActionItem.tsx | 10 +- .../ComposerWithSuggestions.tsx | 104 +++++++------- .../ReportActionCompose.tsx | 129 ++++++++++-------- .../inbox/report/ReportActionCompose/types.ts | 12 ++ 5 files changed, 146 insertions(+), 115 deletions(-) create mode 100644 src/pages/inbox/report/ReportActionCompose/types.ts diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index dd0ad4dd74c8..bfdbc437b88b 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -2381,7 +2381,11 @@ function editReportComment( } /** Deletes the draft for a comment report action. */ -function deleteReportActionDraft(reportID: string | undefined, reportAction: ReportAction) { +function deleteReportActionDraft(reportID: string | undefined, reportAction: ReportAction | null | undefined) { + if (!reportAction) { + return; + } + const originalReportID = getOriginalReportID(reportID, reportAction, undefined); Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${originalReportID}`, {[reportAction.reportActionID]: null}); } diff --git a/src/pages/inbox/report/PureReportActionItem.tsx b/src/pages/inbox/report/PureReportActionItem.tsx index 80f71c63b2f6..2cb4f95ddc82 100644 --- a/src/pages/inbox/report/PureReportActionItem.tsx +++ b/src/pages/inbox/report/PureReportActionItem.tsx @@ -59,7 +59,6 @@ import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import type {OnyxDataWithErrors} from '@libs/ErrorUtils'; import {getLatestErrorMessageField, isReceiptError} from '@libs/ErrorUtils'; import focusComposerWithDelay from '@libs/focusComposerWithDelay'; -import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import {isReportMessageAttachment} from '@libs/isReportMessageAttachment'; import Navigation from '@libs/Navigation/Navigation'; import Parser from '@libs/Parser'; @@ -578,7 +577,10 @@ function PureReportActionItem({ isActionableMentionWhisper(action) || isActionableMentionInviteToSubmitExpenseConfirmWhisper(action) || isActionableTrackExpense(action) || isActionableReportMentionWhisper(action); const isReportArchived = useReportIsArchived(reportID); const isOriginalReportArchived = useReportIsArchived(originalReportID); - const isEditingInline = !isNarrowLayout && draftMessage !== undefined; + const isEditingInline = !shouldUseNarrowLayout && draftMessage !== undefined; + + console.log({isEditingInline, draftMessage, shouldUseNarrowLayout}); + const isHarvestCreatedExpenseReport = isHarvestCreatedExpenseReportUtils(reportNameValuePairsOrigin, reportNameValuePairsOriginalID); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Eye'] as const); @@ -1932,7 +1934,7 @@ function PureReportActionItem({ return emptyHTML; } - if (!isNarrowLayout && draftMessage !== undefined) { + if (!shouldUseNarrowLayout && draftMessage !== undefined) { return {content}; } @@ -1940,7 +1942,7 @@ function PureReportActionItem({ return ( & /** Whether the main composer was hidden */ didHideComposerInput?: boolean; + /** Whether the composer is editing in composer */ + isEditingInComposer: boolean; + + /** The active edit */ + activeEdit?: ActiveEdit | null; + + /** Function to set the active edit */ + setActiveEdit: (activeEdit: ActiveEdit | null) => void; + /** Reference to the outer element */ ref?: Ref; }; @@ -207,6 +217,9 @@ function ComposerWithSuggestions({ lastReportAction, isGroupPolicyReport, policyID, + isEditingInComposer, + activeEdit, + setActiveEdit, // Focus onFocus, @@ -257,55 +270,55 @@ function ComposerWithSuggestions({ const [draftComment = ''] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, {canBeMissing: true}); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const [allActionDrafts] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS, {canBeMissing: true}); - const activeInlineEdit = useMemo(() => { - if (!shouldUseNarrowLayout) { - return null; - } - - const reportDrafts = allActionDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`]; - - if (!reportDrafts) { - return null; - } - - const entry = Object.entries(reportDrafts).find(([, d]) => d?.message); - - if (!entry) { - return null; - } - - const [reportActionID, draft] = entry; - - return { - reportActionID, - message: draft?.message ?? '', - }; - }, [allActionDrafts, reportID, shouldUseNarrowLayout]); - - const initialValue = shouldUseNarrowLayout ? (activeInlineEdit?.message ?? draftComment) : draftComment; + const composerRef = useRef(null); const [value, setValue] = useState(() => { + const initialValue = shouldUseNarrowLayout ? (activeEdit?.message ?? draftComment) : draftComment; + if (initialValue) { emojisPresentBefore.current = extractEmojis(initialValue); } return initialValue; }); + const [selection, setSelection] = useState(() => ({start: value.length, end: value.length, positionX: 0, positionY: 0})); + const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const commentRef = useRef(value); + // Focus the composer when editing in composer + useEffect(() => { + if (!isEditingInComposer) { + return; + } + + composerRef.current?.focus(); + if (activeEdit?.currentSelection) { + setSelection(activeEdit?.currentSelection ?? {start: value.length, end: value.length, positionX: 0, positionY: 0}); + } + }, [activeEdit?.currentSelection, isEditingInComposer, value.length]); + + // Reset the composer value when the app extends to wide layout, + // because the inline composer is showing up useEffect(() => { - if (!shouldUseNarrowLayout) { + if (!activeEdit || shouldUseNarrowLayout) { return; } - const nextValue = activeInlineEdit?.message ?? draftComment ?? ''; + setValue(''); + }, [activeEdit, shouldUseNarrowLayout]); + + useEffect(() => { + if (!shouldUseNarrowLayout || !activeEdit) { + return; + } + + const nextValue = activeEdit.message ?? draftComment ?? ''; emojisPresentBefore.current = extractEmojis(nextValue); setValue(nextValue); commentRef.current = nextValue; - }, [activeInlineEdit?.message, activeInlineEdit?.reportActionID, draftComment, shouldUseNarrowLayout]); + }, [activeEdit, draftComment, shouldUseNarrowLayout]); const {superWideRHPRouteKeys} = useWideRHPState(); // When SearchReport is stacked above another RHP, delay autofocus until after the transition completes to avoid animation jank @@ -329,13 +342,9 @@ function ComposerWithSuggestions({ const valueRef = useRef(value); valueRef.current = value; - const [selection, setSelection] = useState(() => ({start: value.length, end: value.length, positionX: 0, positionY: 0})); - const [composerHeightAfterClear, setDefaultComposerHeight] = useState(null); const emptyComposerHeightRef = useRef(null); - const composerRef = useRef(null); - const syncSelectionWithOnChangeTextRef = useRef(null); // The ref to check whether the comment saving is in progress @@ -514,10 +523,8 @@ function ComposerWithSuggestions({ commentRef.current = newCommentConverted; if (shouldUseNarrowLayout) { - const editingReportActionID = activeInlineEdit?.reportActionID; - - if (editingReportActionID) { - saveReportActionDraft(reportID, {reportActionID: editingReportActionID} as OnyxTypes.ReportAction, newCommentConverted); + if (activeEdit?.reportActionID) { + saveReportActionDraft(reportID, {reportActionID: activeEdit.reportActionID} as OnyxTypes.ReportAction, newCommentConverted); } if (newCommentConverted) { @@ -542,7 +549,7 @@ function ComposerWithSuggestions({ suggestionsRef, raiseIsScrollLikelyLayoutTriggered, debouncedSaveReportComment, - activeInlineEdit?.reportActionID, + activeEdit?.reportActionID, shouldUseNarrowLayout, selection?.end, selection?.start, @@ -665,8 +672,15 @@ function ComposerWithSuggestions({ return; } suggestionsRef.current?.onSelectionChange?.(e); + + if (activeEdit) { + setActiveEdit({ + ...activeEdit, + currentSelection: e.nativeEvent.selection, + }); + } }, - [suggestionsRef], + [activeEdit, setActiveEdit, suggestionsRef], ); const hideSuggestionMenu = useCallback( @@ -781,13 +795,6 @@ function ComposerWithSuggestions({ [checkComposerVisibility, focus, isSidePanelHiddenOrLargeScreen], ); - const blur = useCallback(() => { - if (!composerRef.current) { - return; - } - composerRef.current.blur(); - }, []); - const clearWorklet = useCallback(() => { 'worklet'; @@ -874,9 +881,6 @@ function ComposerWithSuggestions({ {}, { get: (_target, prop) => { - if (prop === 'blur') { - return blur; - } if (prop === 'focus') { return focus; } diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 309d55c90f9a..45f71d837d86 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -62,7 +62,7 @@ import AgentZeroProcessingRequestIndicator from '@pages/inbox/report/AgentZeroPr import ParticipantLocalTime from '@pages/inbox/report/ParticipantLocalTime'; import ReportTypingIndicator from '@pages/inbox/report/ReportTypingIndicator'; import {hideEmojiPicker, isActive as isActiveEmojiPickerAction, isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; -import {addAttachmentWithComment, setIsComposerFullSize} from '@userActions/Report'; +import {addAttachmentWithComment, deleteReportActionDraft, setIsComposerFullSize} from '@userActions/Report'; import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -75,6 +75,7 @@ import ComposerWithSuggestions from './ComposerWithSuggestions'; import type {ComposerWithSuggestionsProps, ComposerWithSuggestionsRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; import MessageEditCancelButton from './MessageEditCancelButton'; import SendButton from './SendButton'; +import type {ActiveEdit} from './types'; import useAttachmentUploadValidation from './useAttachmentUploadValidation'; import useDebouncedCommentMaxLengthValidation from './useDebouncedCommentMaxLengthValidation'; import useEditMessage from './useEditMessage'; @@ -158,28 +159,60 @@ function ReportActionCompose({ const [initialModalState] = useOnyx(ONYXKEYS.MODAL, {canBeMissing: true}); const [newParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`, {canBeMissing: true}); const [draftComment] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, {canBeMissing: true}); - const [allActionDrafts] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS, {canBeMissing: true}); + const [reportActionDrafts] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS, {canBeMissing: true}); - const activeInlineDraft = useMemo(() => { - const reportDrafts = allActionDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`]; + const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`, { + canEvict: false, + canBeMissing: true, + }); + + const [activeEdit, setActiveEdit] = useState(null); + const previousActiveEditRef = useRef(null); + + // Set the active edit when the report actions or draft comments change + useEffect(() => { + const previousActiveEdit = previousActiveEditRef.current; + + if (activeEdit && previousActiveEdit && activeEdit !== previousActiveEdit) { + deleteReportActionDraft(reportID, activeEdit.reportAction); + return; + } + + const reportDrafts = reportActionDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`]; if (!reportDrafts) { - return null; + setActiveEdit(null); + return; } - const entry = Object.entries(reportDrafts).find(([, draft]) => draft?.message); + const reportDraftEntry = Object.entries(reportDrafts).find(([, draft]) => draft?.message); - if (!entry) { - return null; + if (!reportDraftEntry) { + setActiveEdit(null); + return; } - const [reportActionID, draft] = entry; + const [reportActionID, draft] = reportDraftEntry; - return { + previousActiveEditRef.current = activeEdit; + setActiveEdit({ reportActionID, + reportAction: reportActions?.[reportActionID] ?? null, message: draft.message, - }; - }, [allActionDrafts, reportID]); + }); + }, [activeEdit, reportActionDrafts, reportActions, reportID]); + + const isEditingInComposer = shouldUseNarrowLayout && !!activeEdit; + + const isEditingLastReportAction = useMemo(() => { + if (!reportActions) { + return false; + } + + const lastIndex = Object.keys(reportActions).length - 1; + + return activeEdit?.reportActionID === reportActions[lastIndex]?.reportActionID; + }, [activeEdit?.reportActionID, reportActions]); const shouldFocusComposerOnScreenFocus = shouldFocusInputOnScreenFocus || !!draftComment; @@ -198,7 +231,7 @@ function ReportActionCompose({ const {isScrollLayoutTriggered, raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); - const effectiveDraft = shouldUseNarrowLayout ? activeInlineDraft?.message : draftComment; + const effectiveDraft = shouldUseNarrowLayout ? activeEdit?.message : draftComment; const [isCommentEmpty, setIsCommentEmpty] = useState(() => { return !effectiveDraft || !!effectiveDraft.match(CONST.REGEX.EMPTY_COMMENT); @@ -240,33 +273,6 @@ function ReportActionCompose({ const isTransactionThreadView = useMemo(() => isReportTransactionThread(report), [report]); const isExpensesReport = useMemo(() => reportTransactions && reportTransactions.length > 1, [reportTransactions]); - const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`, { - canEvict: false, - canBeMissing: true, - }); - - const [reportActionDrafts] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`, {canBeMissing: true}); - - const [editingReportActionID, setEditingReportActionID] = useState(null); - useEffect(() => { - if (!reportActionDrafts || editingReportActionID) { - return; - } - - const entry = Object.entries(reportActionDrafts).find(([, draft]) => draft?.message); - setEditingReportActionID(entry?.[0] ?? null); - }, [editingReportActionID, reportActionDrafts]); - - const editingReportAction = useMemo(() => { - if (!editingReportActionID || !reportActions) { - return null; - } - - return reportActions[editingReportActionID] ?? null; - }, [editingReportActionID, reportActions]); - - const isEditing = shouldUseNarrowLayout && !!editingReportAction; - const personalDetail = useCurrentUserPersonalDetails(); const iouAction = reportActions ? Object.values(reportActions).find((action) => isMoneyRequestAction(action)) : null; @@ -381,24 +387,24 @@ function ReportActionCompose({ const deleteDraftMessage = useCallback(() => { deleteDraft(); - setEditingReportActionID(null); + setActiveEdit(null); }, [deleteDraft]); /** - * Add a new comment to this chat + * Add or edit a comment in the composer */ const submitForm = useCallback( - (newComment: string) => { - const newCommentTrimmed = newComment.trim(); + (draftMessage: string) => { + const draftMessageTrimmed = draftMessage.trim(); - if (!newCommentTrimmed && !attachmentFileRef.current) { + if (isEditingInComposer && !attachmentFileRef.current) { + publishDraft(draftMessageTrimmed); + deleteDraft(); + setActiveEdit(null); return; } - if (isEditing && !attachmentFileRef.current) { - publishDraft(newCommentTrimmed); - deleteDraft(); - setEditingReportActionID(null); + if (!draftMessageTrimmed && !attachmentFileRef.current) { return; } @@ -413,27 +419,27 @@ function ReportActionCompose({ ancestors, attachments: attachmentFileRef.current, currentUserAccountID: currentUserPersonalDetails.accountID, - text: newCommentTrimmed, + text: draftMessageTrimmed, timezone: personalDetail.timezone, shouldPlaySound: true, isInSidePanel, }); attachmentFileRef.current = null; } else { - Performance.markStart(CONST.TIMING.SEND_MESSAGE, {message: newCommentTrimmed}); + Performance.markStart(CONST.TIMING.SEND_MESSAGE, {message: draftMessageTrimmed}); startSpan(CONST.TELEMETRY.SPAN_SEND_MESSAGE, { name: 'send-message', op: CONST.TELEMETRY.SPAN_SEND_MESSAGE, attributes: { [CONST.TELEMETRY.ATTRIBUTE_REPORT_ID]: reportID, - [CONST.TELEMETRY.ATTRIBUTE_MESSAGE_LENGTH]: newCommentTrimmed.length, + [CONST.TELEMETRY.ATTRIBUTE_MESSAGE_LENGTH]: draftMessageTrimmed.length, }, }); - onSubmit(newCommentTrimmed); + onSubmit(draftMessageTrimmed); } }, [ - isEditing, + isEditingInComposer, isConciergeChat, publishDraft, deleteDraft, @@ -475,9 +481,9 @@ function ReportActionCompose({ }, [onComposerFocus]); useEffect(() => { - const valueToCheck = shouldUseNarrowLayout ? activeInlineDraft?.message : draftComment; + const valueToCheck = shouldUseNarrowLayout ? activeEdit?.message : draftComment; setIsCommentEmpty(!valueToCheck || !!valueToCheck.match(CONST.REGEX.EMPTY_COMMENT)); - }, [activeInlineDraft?.message, draftComment, shouldUseNarrowLayout]); + }, [activeEdit?.message, draftComment, shouldUseNarrowLayout]); // We are returning a callback here as we want to invoke the method on unmount only useEffect( @@ -499,8 +505,8 @@ function ReportActionCompose({ const hasReportRecipient = !isEmptyObject(reportRecipient); - const isNewCommentEmpty = isCommentEmpty && !isEditing; - const isSendDisabled = !isEditing && (isBlockedFromConcierge || isExceedingMaxLength || isNewCommentEmpty); + const isNewCommentEmpty = isCommentEmpty && !isEditingInComposer; + const isSendDisabled = !isEditingInComposer && (isBlockedFromConcierge || isExceedingMaxLength || isNewCommentEmpty); // Note: using JS refs is not well supported in reanimated, thus we need to store the function in a shared value // useSharedValue on web doesn't support functions, so we need to wrap it in an object. @@ -609,7 +615,7 @@ function ReportActionCompose({ ]} > {PDFValidationComponent} - {isEditing ? ( + {isEditingInComposer ? ( ) : ( @@ -708,7 +717,7 @@ function ReportActionCompose({ /> )} diff --git a/src/pages/inbox/report/ReportActionCompose/types.ts b/src/pages/inbox/report/ReportActionCompose/types.ts new file mode 100644 index 000000000000..817a28880b62 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/types.ts @@ -0,0 +1,12 @@ +import type {TextSelection} from '@components/Composer/types'; +import type * as OnyxTypes from '@src/types/onyx'; + +type ActiveEdit = { + reportActionID: string; + reportAction: OnyxTypes.ReportAction | null; + message: string; + currentSelection?: TextSelection; +}; + +// eslint-disable-next-line import/prefer-default-export +export type {ActiveEdit}; From d45f5d10438e5fe45ce258339132a6282984f2c6 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 13 Feb 2026 16:19:12 +0000 Subject: [PATCH 016/233] refactor: composer ref refactorings --- src/pages/inbox/report/PureReportActionItem.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pages/inbox/report/PureReportActionItem.tsx b/src/pages/inbox/report/PureReportActionItem.tsx index 2cb4f95ddc82..7d27738b35c6 100644 --- a/src/pages/inbox/report/PureReportActionItem.tsx +++ b/src/pages/inbox/report/PureReportActionItem.tsx @@ -10,6 +10,7 @@ import type {Emoji} from '@assets/emojis/types'; import * as ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; import {AttachmentContext} from '@components/AttachmentContext'; import Button from '@components/Button'; +import type {ComposerRef} from '@components/Composer/types'; import DisplayNames from '@components/DisplayNames'; import Hoverable from '@components/Hoverable'; import MentionReportContext from '@components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext'; @@ -566,13 +567,12 @@ function PureReportActionItem({ const reactionListRef = useContext(ReactionListContext); const {updateHiddenAttachments} = useContext(AttachmentModalContext); const kycWallRef = useContext(KYCWallContext); - const composerTextInputRef = useRef(null); + const composerRef = useRef(null); const popoverAnchorRef = useRef>(null); const downloadedPreviews = useRef([]); const prevDraftMessage = usePrevious(draftMessage); const isReportActionLinked = linkedReportActionID && action.reportActionID && linkedReportActionID === action.reportActionID; const [isReportActionActive, setIsReportActionActive] = useState(!!isReportActionLinked); - const isNarrowLayout = getIsNarrowLayout(); const isActionableWhisper = isActionableMentionWhisper(action) || isActionableMentionInviteToSubmitExpenseConfirmWhisper(action) || isActionableTrackExpense(action) || isActionableReportMentionWhisper(action); const isReportArchived = useReportIsArchived(reportID); @@ -689,7 +689,7 @@ function PureReportActionItem({ return; } - focusComposerWithDelay(composerTextInputRef.current)(true); + focusComposerWithDelay(composerRef.current)(true); }, [prevDraftMessage, draftMessage]); useEffect(() => { @@ -1793,7 +1793,7 @@ function PureReportActionItem({ originalReportID={originalReportID} policyID={report?.policyID} index={index} - ref={composerTextInputRef} + ref={composerRef} shouldDisableEmojiPicker={ (chatIncludesConcierge(report) && isBlockedFromConcierge(blockedFromConcierge)) || isArchivedNonExpenseReport(report, isArchivedRoom) } From e7615d138e00debc8dacb863b2fa4192b97d49a9 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 13 Feb 2026 19:50:42 +0000 Subject: [PATCH 017/233] feat: only allow editing one comment --- src/libs/actions/Report/index.ts | 5 +++++ .../BaseReportActionContextMenu.tsx | 4 ---- .../report/ContextMenu/ContextMenuActions.tsx | 21 ++++++------------- .../ReportActionCompose.tsx | 3 +-- 4 files changed, 12 insertions(+), 21 deletions(-) diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index bfdbc437b88b..0a260d54314f 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -2380,6 +2380,10 @@ function editReportComment( ); } +function clearReportActionDrafts(reportID: string | undefined) { + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`, null); +} + /** Deletes the draft for a comment report action. */ function deleteReportActionDraft(reportID: string | undefined, reportAction: ReportAction | null | undefined) { if (!reportAction) { @@ -6645,6 +6649,7 @@ export { completeOnboarding, createNewReport, deleteReport, + clearReportActionDrafts, deleteReportActionDraft, deleteReportComment, deleteReportField, diff --git a/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx index c88169aa353a..2fd8696a016b 100755 --- a/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx +++ b/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx @@ -240,8 +240,6 @@ function BaseReportActionContextMenu({ const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {canBeMissing: true}); const isTryNewDotNVPDismissed = !!tryNewDot?.classicRedirect?.dismissed; - const [allDraftMessages] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS, {canBeMissing: true}); - const reportDrafts = originalReportID ? allDraftMessages?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${originalReportID}`] : undefined; const isMoneyRequest = useMemo(() => ReportUtilsIsMoneyRequest(childReport), [childReport]); const isTrackExpenseReport = ReportUtilsIsTrackExpenseReport(childReport); @@ -378,8 +376,6 @@ function BaseReportActionContextMenu({ originalReportID, report, draftMessage, - allDraftMessages: reportDrafts, - shouldUseNarrowLayout, selection, close: () => setShouldKeepOpen(false), transitionActionSheetState, diff --git a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx index c3a982f80634..176e63bd8da4 100644 --- a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx @@ -167,6 +167,7 @@ import { import {getTaskCreatedMessage, getTaskReportActionMessage} from '@libs/TaskUtils'; import {setDownload} from '@userActions/Download'; import { + clearReportActionDrafts, deleteReportActionDraft, explain, markCommentAsUnread, @@ -192,7 +193,6 @@ import type { ReportAction, ReportActionReactions, ReportActions, - ReportActionsDrafts, Report as ReportType, Transaction, } from '@src/types/onyx'; @@ -258,8 +258,6 @@ type ContextMenuActionPayload = { report: OnyxEntry; policy?: OnyxEntry; draftMessage: string; - allDraftMessages?: ReportActionsDrafts; - shouldUseNarrowLayout: boolean; selection: string; close: () => void; transitionActionSheetState: (params: {type: string; payload?: Record}) => void; @@ -518,7 +516,7 @@ const ContextMenuActions: ContextMenuAction[] = [ icon: 'Pencil', shouldShow: ({type, reportAction, isArchivedRoom, isChronosReport, moneyRequestAction}) => type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && (canEditReportAction(reportAction) || canEditReportAction(moneyRequestAction)) && !isArchivedRoom && !isChronosReport, - onPress: (closePopover, {reportID, reportAction, allDraftMessages, shouldUseNarrowLayout, draftMessage, moneyRequestAction, introSelected}) => { + onPress: (closePopover, {reportID, reportAction, draftMessage, moneyRequestAction, introSelected}) => { if (isMoneyRequestAction(reportAction) || isMoneyRequestAction(moneyRequestAction)) { const editExpense = () => { const childReportID = reportAction?.childReportID; @@ -533,19 +531,12 @@ const ContextMenuActions: ContextMenuAction[] = [ return; } const editAction = () => { - if (!draftMessage) { - if (shouldUseNarrowLayout && allDraftMessages) { - for (const actionID of Object.keys(allDraftMessages)) { - if (actionID === reportAction.reportActionID) { - continue; - } - deleteReportActionDraft(reportID, reportAction); - } - } + clearReportActionDrafts(reportID); - saveReportActionDraft(reportID, reportAction, Parser.htmlToMarkdown(getActionHtml(reportAction))); - } else { + if (draftMessage) { deleteReportActionDraft(reportID, reportAction); + } else { + saveReportActionDraft(reportID, reportAction, Parser.htmlToMarkdown(getActionHtml(reportAction))); } }; diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 45f71d837d86..0cda52c7893d 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -173,8 +173,7 @@ function ReportActionCompose({ useEffect(() => { const previousActiveEdit = previousActiveEditRef.current; - if (activeEdit && previousActiveEdit && activeEdit !== previousActiveEdit) { - deleteReportActionDraft(reportID, activeEdit.reportAction); + if (activeEdit) { return; } From 5494c9c6f6303419c4210c7ef2987b6a54e76f4f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 13 Feb 2026 19:53:49 +0000 Subject: [PATCH 018/233] remove unused code --- .../report/ReportActionCompose/ReportActionCompose.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 0cda52c7893d..7f9c5d92ec28 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -62,7 +62,7 @@ import AgentZeroProcessingRequestIndicator from '@pages/inbox/report/AgentZeroPr import ParticipantLocalTime from '@pages/inbox/report/ParticipantLocalTime'; import ReportTypingIndicator from '@pages/inbox/report/ReportTypingIndicator'; import {hideEmojiPicker, isActive as isActiveEmojiPickerAction, isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; -import {addAttachmentWithComment, deleteReportActionDraft, setIsComposerFullSize} from '@userActions/Report'; +import {addAttachmentWithComment, setIsComposerFullSize} from '@userActions/Report'; import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -167,12 +167,9 @@ function ReportActionCompose({ }); const [activeEdit, setActiveEdit] = useState(null); - const previousActiveEditRef = useRef(null); // Set the active edit when the report actions or draft comments change useEffect(() => { - const previousActiveEdit = previousActiveEditRef.current; - if (activeEdit) { return; } @@ -193,7 +190,6 @@ function ReportActionCompose({ const [reportActionID, draft] = reportDraftEntry; - previousActiveEditRef.current = activeEdit; setActiveEdit({ reportActionID, reportAction: reportActions?.[reportActionID] ?? null, From 7b25160dc1c3ec274164e61da6ce6008a91b5c84 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 13 Feb 2026 20:18:11 +0000 Subject: [PATCH 019/233] refactor: move editing state up into context --- src/pages/inbox/ReportScreen.tsx | 209 +++++++++--------- .../ComposerWithSuggestions.tsx | 64 +++--- .../ReportActionCompose.tsx | 53 +---- .../inbox/report/ReportActionCompose/types.ts | 12 - .../report/ReportActionEditMessageContext.tsx | 106 +++++++++ .../report/ReportActionItemMessageEdit.tsx | 17 +- 6 files changed, 268 insertions(+), 193 deletions(-) delete mode 100644 src/pages/inbox/report/ReportActionCompose/types.ts create mode 100644 src/pages/inbox/report/ReportActionEditMessageContext.tsx diff --git a/src/pages/inbox/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx index cbf87cb8335a..a38fecae215f 100644 --- a/src/pages/inbox/ReportScreen.tsx +++ b/src/pages/inbox/ReportScreen.tsx @@ -111,6 +111,7 @@ import {getEmptyObject, isEmptyObject} from '@src/types/utils/EmptyObject'; import HeaderView from './HeaderView'; import useReportWasDeleted from './hooks/useReportWasDeleted'; import ReactionListWrapper from './ReactionListWrapper'; +import {ReportActionEditMessageContextProvider} from './report/ReportActionEditMessageContext'; import ReportActionsView from './report/ReportActionsView'; import ReportFooter from './report/ReportFooter'; import type {ActionListContextType, ScrollPosition} from './ReportScreenContext'; @@ -1010,112 +1011,114 @@ function ReportScreen({route, navigation, isInSidePanel = false}: ReportScreenPr // Wide RHP overlays should be rendered only for the report screen displayed in RHP - - - + + - - - {headerView} - - {!!accountManagerReportID && isConciergeChatReport(report) && isBannerVisible && ( - - )} - - {shouldShowWideRHP && ( - - - - - - )} - + + - {(!report || shouldWaitForTransactions) && } - {!!report && !shouldDisplayMoneyRequestActionsList && !shouldWaitForTransactions ? ( - - ) : null} - {!!report && shouldDisplayMoneyRequestActionsList && !shouldWaitForTransactions ? ( - - ) : null} - {isCurrentReportLoadedFromOnyx ? ( - - ) : null} + {headerView} + + {!!accountManagerReportID && isConciergeChatReport(report) && isBannerVisible && ( + + )} + + {shouldShowWideRHP && ( + + + + + + )} + + {(!report || shouldWaitForTransactions) && } + {!!report && !shouldDisplayMoneyRequestActionsList && !shouldWaitForTransactions ? ( + + ) : null} + {!!report && shouldDisplayMoneyRequestActionsList && !shouldWaitForTransactions ? ( + + ) : null} + {isCurrentReportLoadedFromOnyx ? ( + + ) : null} + - - - - - - + + + + + + ); diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 6eb0e3dd2d06..4b3d868150d2 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -43,7 +43,7 @@ import getScrollPosition from '@pages/inbox/report/ReportActionCompose/getScroll import type {SuggestionsRef} from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; import SilentCommentUpdater from '@pages/inbox/report/ReportActionCompose/SilentCommentUpdater'; import Suggestions from '@pages/inbox/report/ReportActionCompose/Suggestions'; -import type {ActiveEdit} from '@pages/inbox/report/ReportActionCompose/types'; +import {useReportActionActiveEdit} from '@pages/inbox/report/ReportActionEditMessageContext'; import {isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; import type {OnEmojiSelected} from '@userActions/EmojiPickerAction'; import {inputFocusChange} from '@userActions/InputFocus'; @@ -165,12 +165,6 @@ type ComposerWithSuggestionsProps = Partial & /** Whether the composer is editing in composer */ isEditingInComposer: boolean; - /** The active edit */ - activeEdit?: ActiveEdit | null; - - /** Function to set the active edit */ - setActiveEdit: (activeEdit: ActiveEdit | null) => void; - /** Reference to the outer element */ ref?: Ref; }; @@ -218,8 +212,6 @@ function ComposerWithSuggestions({ isGroupPolicyReport, policyID, isEditingInComposer, - activeEdit, - setActiveEdit, // Focus onFocus, @@ -272,8 +264,10 @@ function ComposerWithSuggestions({ const composerRef = useRef(null); + const {editingReportActionID, editingMessage, currentEditMessageSelection, setCurrentEditMessageSelection} = useReportActionActiveEdit(); + const [value, setValue] = useState(() => { - const initialValue = shouldUseNarrowLayout ? (activeEdit?.message ?? draftComment) : draftComment; + const initialValue = shouldUseNarrowLayout ? (editingMessage ?? draftComment) : draftComment; if (initialValue) { emojisPresentBefore.current = extractEmojis(initialValue); @@ -293,32 +287,32 @@ function ComposerWithSuggestions({ } composerRef.current?.focus(); - if (activeEdit?.currentSelection) { - setSelection(activeEdit?.currentSelection ?? {start: value.length, end: value.length, positionX: 0, positionY: 0}); + if (currentEditMessageSelection) { + setSelection(currentEditMessageSelection ?? {start: value.length, end: value.length, positionX: 0, positionY: 0}); } - }, [activeEdit?.currentSelection, isEditingInComposer, value.length]); + }, [currentEditMessageSelection, isEditingInComposer, value.length]); // Reset the composer value when the app extends to wide layout, // because the inline composer is showing up useEffect(() => { - if (!activeEdit || shouldUseNarrowLayout) { + if (!editingReportActionID || shouldUseNarrowLayout) { return; } setValue(''); - }, [activeEdit, shouldUseNarrowLayout]); + }, [editingReportActionID, shouldUseNarrowLayout]); useEffect(() => { - if (!shouldUseNarrowLayout || !activeEdit) { + if (!shouldUseNarrowLayout || !editingReportActionID) { return; } - const nextValue = activeEdit.message ?? draftComment ?? ''; + const nextValue = editingMessage ?? draftComment ?? ''; emojisPresentBefore.current = extractEmojis(nextValue); setValue(nextValue); commentRef.current = nextValue; - }, [activeEdit, draftComment, shouldUseNarrowLayout]); + }, [editingMessage, draftComment, shouldUseNarrowLayout, editingReportActionID]); const {superWideRHPRouteKeys} = useWideRHPState(); // When SearchReport is stacked above another RHP, delay autofocus until after the transition completes to avoid animation jank @@ -523,8 +517,8 @@ function ComposerWithSuggestions({ commentRef.current = newCommentConverted; if (shouldUseNarrowLayout) { - if (activeEdit?.reportActionID) { - saveReportActionDraft(reportID, {reportActionID: activeEdit.reportActionID} as OnyxTypes.ReportAction, newCommentConverted); + if (editingReportActionID) { + saveReportActionDraft(reportID, {reportActionID: editingReportActionID} as OnyxTypes.ReportAction, newCommentConverted); } if (newCommentConverted) { @@ -541,19 +535,19 @@ function ComposerWithSuggestions({ } }, [ + raiseIsScrollLikelyLayoutTriggered, + selection?.start, + selection.end, findNewlyAddedChars, - preferredLocale, preferredSkinTone, - reportID, - setIsCommentEmpty, - suggestionsRef, - raiseIsScrollLikelyLayoutTriggered, - debouncedSaveReportComment, - activeEdit?.reportActionID, + preferredLocale, shouldUseNarrowLayout, - selection?.end, - selection?.start, + suggestionsRef, + setIsCommentEmpty, + editingReportActionID, + reportID, currentUserAccountID, + debouncedSaveReportComment, ], ); @@ -673,14 +667,16 @@ function ComposerWithSuggestions({ } suggestionsRef.current?.onSelectionChange?.(e); - if (activeEdit) { - setActiveEdit({ - ...activeEdit, - currentSelection: e.nativeEvent.selection, + if (editingReportActionID) { + setCurrentEditMessageSelection({ + start: e.nativeEvent.selection.start, + end: e.nativeEvent.selection.end, + positionX: 0, + positionY: 0, }); } }, - [activeEdit, setActiveEdit, suggestionsRef], + [editingReportActionID, setCurrentEditMessageSelection, suggestionsRef], ); const hideSuggestionMenu = useCallback( diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 7f9c5d92ec28..135cc534f968 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -60,6 +60,7 @@ import {getTransactionID, hasReceipt as hasReceiptTransactionUtils} from '@libs/ import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; import AgentZeroProcessingRequestIndicator from '@pages/inbox/report/AgentZeroProcessingRequestIndicator'; import ParticipantLocalTime from '@pages/inbox/report/ParticipantLocalTime'; +import {useReportActionActiveEdit} from '@pages/inbox/report/ReportActionEditMessageContext'; import ReportTypingIndicator from '@pages/inbox/report/ReportTypingIndicator'; import {hideEmojiPicker, isActive as isActiveEmojiPickerAction, isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; import {addAttachmentWithComment, setIsComposerFullSize} from '@userActions/Report'; @@ -75,7 +76,6 @@ import ComposerWithSuggestions from './ComposerWithSuggestions'; import type {ComposerWithSuggestionsProps, ComposerWithSuggestionsRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; import MessageEditCancelButton from './MessageEditCancelButton'; import SendButton from './SendButton'; -import type {ActiveEdit} from './types'; import useAttachmentUploadValidation from './useAttachmentUploadValidation'; import useDebouncedCommentMaxLengthValidation from './useDebouncedCommentMaxLengthValidation'; import useEditMessage from './useEditMessage'; @@ -159,45 +159,15 @@ function ReportActionCompose({ const [initialModalState] = useOnyx(ONYXKEYS.MODAL, {canBeMissing: true}); const [newParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`, {canBeMissing: true}); const [draftComment] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, {canBeMissing: true}); - const [reportActionDrafts] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS, {canBeMissing: true}); const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`, { canEvict: false, canBeMissing: true, }); - const [activeEdit, setActiveEdit] = useState(null); + const {editingReportActionID, editingReportAction, editingMessage, setActiveEdit} = useReportActionActiveEdit(); - // Set the active edit when the report actions or draft comments change - useEffect(() => { - if (activeEdit) { - return; - } - - const reportDrafts = reportActionDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`]; - - if (!reportDrafts) { - setActiveEdit(null); - return; - } - - const reportDraftEntry = Object.entries(reportDrafts).find(([, draft]) => draft?.message); - - if (!reportDraftEntry) { - setActiveEdit(null); - return; - } - - const [reportActionID, draft] = reportDraftEntry; - - setActiveEdit({ - reportActionID, - reportAction: reportActions?.[reportActionID] ?? null, - message: draft.message, - }); - }, [activeEdit, reportActionDrafts, reportActions, reportID]); - - const isEditingInComposer = shouldUseNarrowLayout && !!activeEdit; + const isEditingInComposer = shouldUseNarrowLayout && !!editingReportActionID; const isEditingLastReportAction = useMemo(() => { if (!reportActions) { @@ -206,8 +176,8 @@ function ReportActionCompose({ const lastIndex = Object.keys(reportActions).length - 1; - return activeEdit?.reportActionID === reportActions[lastIndex]?.reportActionID; - }, [activeEdit?.reportActionID, reportActions]); + return editingReportActionID === reportActions[lastIndex]?.reportActionID; + }, [editingReportActionID, reportActions]); const shouldFocusComposerOnScreenFocus = shouldFocusInputOnScreenFocus || !!draftComment; @@ -226,7 +196,7 @@ function ReportActionCompose({ const {isScrollLayoutTriggered, raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); - const effectiveDraft = shouldUseNarrowLayout ? activeEdit?.message : draftComment; + const effectiveDraft = shouldUseNarrowLayout ? editingMessage : draftComment; const [isCommentEmpty, setIsCommentEmpty] = useState(() => { return !effectiveDraft || !!effectiveDraft.match(CONST.REGEX.EMPTY_COMMENT); @@ -373,7 +343,7 @@ function ReportActionCompose({ const {publishDraft, deleteDraft} = useEditMessage({ reportID, - reportAction: activeEdit?.reportAction, + reportAction: editingReportAction, shouldScrollToLastMessage: isEditingLastReportAction, isFocused, debouncedCommentMaxLengthValidation, @@ -383,7 +353,7 @@ function ReportActionCompose({ const deleteDraftMessage = useCallback(() => { deleteDraft(); setActiveEdit(null); - }, [deleteDraft]); + }, [deleteDraft, setActiveEdit]); /** * Add or edit a comment in the composer @@ -438,6 +408,7 @@ function ReportActionCompose({ isConciergeChat, publishDraft, deleteDraft, + setActiveEdit, kickoffWaitingIndicator, transactionThreadReport, report, @@ -476,9 +447,9 @@ function ReportActionCompose({ }, [onComposerFocus]); useEffect(() => { - const valueToCheck = shouldUseNarrowLayout ? activeEdit?.message : draftComment; + const valueToCheck = shouldUseNarrowLayout ? editingMessage : draftComment; setIsCommentEmpty(!valueToCheck || !!valueToCheck.match(CONST.REGEX.EMPTY_COMMENT)); - }, [activeEdit?.message, draftComment, shouldUseNarrowLayout]); + }, [editingMessage, draftComment, shouldUseNarrowLayout]); // We are returning a callback here as we want to invoke the method on unmount only useEffect( @@ -669,8 +640,6 @@ function ReportActionCompose({ measureParentContainer={measureContainer} onValueChange={onValueChange} isEditingInComposer={isEditingInComposer} - activeEdit={activeEdit} - setActiveEdit={setActiveEdit} didHideComposerInput={didHideComposerInput} forwardedFSClass={fsClass} /> diff --git a/src/pages/inbox/report/ReportActionCompose/types.ts b/src/pages/inbox/report/ReportActionCompose/types.ts deleted file mode 100644 index 817a28880b62..000000000000 --- a/src/pages/inbox/report/ReportActionCompose/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type {TextSelection} from '@components/Composer/types'; -import type * as OnyxTypes from '@src/types/onyx'; - -type ActiveEdit = { - reportActionID: string; - reportAction: OnyxTypes.ReportAction | null; - message: string; - currentSelection?: TextSelection; -}; - -// eslint-disable-next-line import/prefer-default-export -export type {ActiveEdit}; diff --git a/src/pages/inbox/report/ReportActionEditMessageContext.tsx b/src/pages/inbox/report/ReportActionEditMessageContext.tsx new file mode 100644 index 000000000000..60c0655ec882 --- /dev/null +++ b/src/pages/inbox/report/ReportActionEditMessageContext.tsx @@ -0,0 +1,106 @@ +import {createContext, useContext, useEffect, useState} from 'react'; +import type {TextSelection} from '@components/Composer/types'; +import useOnyx from '@hooks/useOnyx'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; + +const NOOP = () => {}; + +type ReportActionActiveEdit = { + editingReportActionID: string | null; + editingReportAction: OnyxTypes.ReportAction | null; + editingMessage: string | null; +}; + +type ReportActionEditMessageContextValue = ReportActionActiveEdit & { + setActiveEdit: (activeEdit: ReportActionActiveEdit | null) => void; + + currentEditMessageSelection: TextSelection | null; + setCurrentEditMessageSelection: (selection: TextSelection) => void; +}; + +const ReportActionEditMessageContext = createContext({ + editingReportActionID: null, + editingReportAction: null, + editingMessage: null, + setActiveEdit: NOOP, + + currentEditMessageSelection: null, + setCurrentEditMessageSelection: NOOP, +}); + +type ReportActionEditMessageContextProviderProps = { + reportID: string | undefined; + children: React.ReactNode; +}; + +function ReportActionEditMessageContextProvider({reportID, children}: ReportActionEditMessageContextProviderProps) { + const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID ?? ''}`, { + canEvict: false, + canBeMissing: true, + }); + const [reportActionDrafts] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS, {canBeMissing: true}); + + const [editingReportActionID, setEditingReportActionID] = useState(null); + const [editingReportAction, setEditingReportAction] = useState(null); + const [editingMessage, setEditingMessage] = useState(null); + const [currentEditMessageSelection, setCurrentEditMessageSelection] = useState(null); + + function setActiveEdit(activeEdit: ReportActionActiveEdit | null) { + setEditingReportActionID(activeEdit?.editingReportActionID ?? null); + setEditingReportAction(activeEdit?.editingReportAction ?? null); + setEditingMessage(activeEdit?.editingMessage ?? null); + } + + // Set the active edit when the report actions or draft comments change + useEffect(() => { + if (editingReportActionID) { + return; + } + + const reportDrafts = reportActionDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`]; + + if (!reportDrafts) { + setActiveEdit(null); + return; + } + + const reportDraftEntry = Object.entries(reportDrafts).find(([, draft]) => draft?.message); + + if (!reportDraftEntry) { + setActiveEdit(null); + return; + } + + const [reportActionID, draft] = reportDraftEntry; + + setActiveEdit({ + editingReportActionID: reportActionID, + editingReportAction: reportActions?.[reportActionID] ?? null, + editingMessage: draft.message, + }); + }, [editingReportActionID, reportActionDrafts, reportActions, reportID]); + + return ( + + {children} + + ); +} + +function useReportActionActiveEdit() { + return useContext(ReportActionEditMessageContext); +} + +export {ReportActionEditMessageContextProvider, useReportActionActiveEdit, ReportActionEditMessageContext}; +export type {ReportActionActiveEdit, ReportActionEditMessageContextValue}; diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index 48cc943a9239..e30e17ff4dc2 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -50,6 +50,7 @@ import SendButton from './ReportActionCompose/SendButton'; import Suggestions from './ReportActionCompose/Suggestions'; import useDebouncedCommentMaxLengthValidation from './ReportActionCompose/useDebouncedCommentMaxLengthValidation'; import useEditMessage from './ReportActionCompose/useEditMessage'; +import {useReportActionActiveEdit} from './ReportActionEditMessageContext'; import shouldUseEmojiPickerSelection from './shouldUseEmojiPickerSelection'; type ReportActionItemMessageEditProps = { @@ -119,7 +120,19 @@ function ReportActionItemMessageEdit({ } return draftMessage; }); - const [selection, setSelection] = useState({start: draft.length, end: draft.length, positionX: 0, positionY: 0}); + + const {setCurrentEditMessageSelection} = useReportActionActiveEdit(); + + const [selection, setSelectionState] = useState({start: draft.length, end: draft.length, positionX: 0, positionY: 0}); + + const setSelection = useCallback( + (newSelection: TextSelection) => { + setSelectionState(newSelection); + setCurrentEditMessageSelection({...newSelection, positionX: 0, positionY: 0}); + }, + [setSelectionState, setCurrentEditMessageSelection], + ); + const [isFocused, setIsFocused] = useState(false); const {debouncedCommentMaxLengthValidation, isExceedingMaxLength} = useDebouncedCommentMaxLengthValidation({reportID}); @@ -244,7 +257,7 @@ function ReportActionItemMessageEdit({ debouncedSaveDraft(newDraft); isCommentPendingSaved.current = true; }, - [raiseIsScrollLayoutTriggered, debouncedSaveDraft, preferredSkinTone, preferredLocale, selection.end], + [raiseIsScrollLayoutTriggered, preferredSkinTone, preferredLocale, debouncedSaveDraft, selection?.end, setSelection], ); useEffect(() => { From ade943eb85778b4df7d54d81696944bcac5fe87f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 13 Feb 2026 20:45:21 +0000 Subject: [PATCH 020/233] remove: console.log --- src/pages/inbox/report/PureReportActionItem.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/pages/inbox/report/PureReportActionItem.tsx b/src/pages/inbox/report/PureReportActionItem.tsx index 7d27738b35c6..d30291bfaa6e 100644 --- a/src/pages/inbox/report/PureReportActionItem.tsx +++ b/src/pages/inbox/report/PureReportActionItem.tsx @@ -579,8 +579,6 @@ function PureReportActionItem({ const isOriginalReportArchived = useReportIsArchived(originalReportID); const isEditingInline = !shouldUseNarrowLayout && draftMessage !== undefined; - console.log({isEditingInline, draftMessage, shouldUseNarrowLayout}); - const isHarvestCreatedExpenseReport = isHarvestCreatedExpenseReportUtils(reportNameValuePairsOrigin, reportNameValuePairsOriginalID); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Eye'] as const); From 722699e4c0d4619c50492bedfe4be29d7020b414 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 13 Feb 2026 20:45:42 +0000 Subject: [PATCH 021/233] feat: implement persisting text selection --- .../ComposerWithSuggestions.tsx | 80 ++++++++++++------- .../report/ReportActionEditMessageContext.tsx | 10 ++- .../report/ReportActionItemMessageEdit.tsx | 9 ++- 3 files changed, 69 insertions(+), 30 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 4b3d868150d2..f3eec1407537 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -274,7 +274,7 @@ function ComposerWithSuggestions({ } return initialValue; }); - const [selection, setSelection] = useState(() => ({start: value.length, end: value.length, positionX: 0, positionY: 0})); + const [selection, setSelection] = useState(() => currentEditMessageSelection ?? {start: value.length, end: value.length, positionX: 0, positionY: 0}); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); @@ -287,9 +287,6 @@ function ComposerWithSuggestions({ } composerRef.current?.focus(); - if (currentEditMessageSelection) { - setSelection(currentEditMessageSelection ?? {start: value.length, end: value.length, positionX: 0, positionY: 0}); - } }, [currentEditMessageSelection, isEditingInComposer, value.length]); // Reset the composer value when the app extends to wide layout, @@ -513,6 +510,8 @@ function ComposerWithSuggestions({ positionX: prevSelection.positionX, positionY: prevSelection.positionY, })); + + setCurrentEditMessageSelection({...currentEditMessageSelection, start: position, end: position}); } commentRef.current = newCommentConverted; @@ -544,6 +543,8 @@ function ComposerWithSuggestions({ shouldUseNarrowLayout, suggestionsRef, setIsCommentEmpty, + setCurrentEditMessageSelection, + currentEditMessageSelection, editingReportActionID, reportID, currentUserAccountID, @@ -610,17 +611,35 @@ function ComposerWithSuggestions({ if (lastGraphemeLength > 1) { event.preventDefault(); const newText = lastTextRef.current.slice(0, selection.start - lastGraphemeLength) + lastTextRef.current.slice(selection.start); + const newStart = selection.start - lastGraphemeLength; + const newEnd = selection.start - lastGraphemeLength; + setSelection((prevSelection) => ({ - start: selection.start - lastGraphemeLength, - end: selection.start - lastGraphemeLength, + start: newStart, + end: newEnd, positionX: prevSelection.positionX, positionY: prevSelection.positionY, })); + + setCurrentEditMessageSelection({...currentEditMessageSelection, start: newStart, end: newEnd}); updateComment(newText, true); } } }, - [shouldUseNarrowLayout, isKeyboardShown, suggestionsRef, selection.start, includeChronos, onEnterKeyPress, lastReportAction, reportID, updateComment, selection.end], + [ + shouldUseNarrowLayout, + isKeyboardShown, + suggestionsRef, + selection.start, + selection.end, + includeChronos, + onEnterKeyPress, + lastReportAction, + reportID, + setCurrentEditMessageSelection, + currentEditMessageSelection, + updateComment, + ], ); /** @@ -662,21 +681,19 @@ function ComposerWithSuggestions({ (e: CustomSelectionChangeEvent) => { setSelection(e.nativeEvent.selection); + setCurrentEditMessageSelection({ + start: e.nativeEvent.selection.start, + end: e.nativeEvent.selection.end, + positionX: 0, + positionY: 0, + }); + if (!composerRef.current?.isFocused()) { return; } suggestionsRef.current?.onSelectionChange?.(e); - - if (editingReportActionID) { - setCurrentEditMessageSelection({ - start: e.nativeEvent.selection.start, - end: e.nativeEvent.selection.end, - positionX: 0, - positionY: 0, - }); - } }, - [editingReportActionID, setCurrentEditMessageSelection, suggestionsRef], + [setCurrentEditMessageSelection, suggestionsRef], ); const hideSuggestionMenu = useCallback( @@ -983,18 +1000,27 @@ function ComposerWithSuggestions({ // When using the suggestions box (Suggestions) we need to imperatively // set the cursor to the end of the suggestion/mention after it's selected. - const onSuggestionSelected = useCallback((suggestionSelection: TextSelection) => { - const endOfSuggestionSelection = suggestionSelection.end; - setSelection(suggestionSelection); + const onSuggestionSelected = useCallback( + (suggestionSelection: TextSelection) => { + const endOfSuggestionSelection = suggestionSelection.end; + setSelection(suggestionSelection); + setCurrentEditMessageSelection({ + start: suggestionSelection.start, + end: suggestionSelection.end, + positionX: 0, + positionY: 0, + }); - if (endOfSuggestionSelection === undefined) { - return; - } + if (endOfSuggestionSelection === undefined) { + return; + } - queueMicrotask(() => { - composerRef.current?.setSelection?.(endOfSuggestionSelection, endOfSuggestionSelection); - }); - }, []); + queueMicrotask(() => { + composerRef.current?.setSelection?.(endOfSuggestionSelection, endOfSuggestionSelection); + }); + }, + [setCurrentEditMessageSelection], + ); return ( <> diff --git a/src/pages/inbox/report/ReportActionEditMessageContext.tsx b/src/pages/inbox/report/ReportActionEditMessageContext.tsx index 60c0655ec882..7664d18d891b 100644 --- a/src/pages/inbox/report/ReportActionEditMessageContext.tsx +++ b/src/pages/inbox/report/ReportActionEditMessageContext.tsx @@ -44,7 +44,7 @@ function ReportActionEditMessageContextProvider({reportID, children}: ReportActi const [editingReportActionID, setEditingReportActionID] = useState(null); const [editingReportAction, setEditingReportAction] = useState(null); const [editingMessage, setEditingMessage] = useState(null); - const [currentEditMessageSelection, setCurrentEditMessageSelection] = useState(null); + const [currentEditMessageSelection, setCurrentEditMessageSelectionState] = useState(null); function setActiveEdit(activeEdit: ReportActionActiveEdit | null) { setEditingReportActionID(activeEdit?.editingReportActionID ?? null); @@ -52,6 +52,14 @@ function ReportActionEditMessageContextProvider({reportID, children}: ReportActi setEditingMessage(activeEdit?.editingMessage ?? null); } + function setCurrentEditMessageSelection(selection: TextSelection) { + if (!editingReportActionID) { + return; + } + + setCurrentEditMessageSelectionState(selection); + } + // Set the active edit when the report actions or draft comments change useEffect(() => { if (editingReportActionID) { diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index e30e17ff4dc2..d03bce1aa96e 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -121,9 +121,10 @@ function ReportActionItemMessageEdit({ return draftMessage; }); - const {setCurrentEditMessageSelection} = useReportActionActiveEdit(); + const {currentEditMessageSelection, setCurrentEditMessageSelection} = useReportActionActiveEdit(); - const [selection, setSelectionState] = useState({start: draft.length, end: draft.length, positionX: 0, positionY: 0}); + const defaultSelection = useMemo(() => ({start: draft.length, end: draft.length, positionX: 0, positionY: 0}), [draft.length]); + const [selection, setSelectionState] = useState(() => currentEditMessageSelection ?? defaultSelection); const setSelection = useCallback( (newSelection: TextSelection) => { @@ -133,6 +134,10 @@ function ReportActionItemMessageEdit({ [setSelectionState, setCurrentEditMessageSelection], ); + useEffect(() => { + setSelectionState(currentEditMessageSelection ?? defaultSelection); + }, [currentEditMessageSelection, defaultSelection, draft.length, setSelection]); + const [isFocused, setIsFocused] = useState(false); const {debouncedCommentMaxLengthValidation, isExceedingMaxLength} = useDebouncedCommentMaxLengthValidation({reportID}); From f450f44b736d3180adae36f2c247d4a29513996b Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 13 Feb 2026 20:45:49 +0000 Subject: [PATCH 022/233] fix: missing React import --- src/pages/inbox/report/ReportActionEditMessageContext.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionEditMessageContext.tsx b/src/pages/inbox/report/ReportActionEditMessageContext.tsx index 7664d18d891b..eb030b33398f 100644 --- a/src/pages/inbox/report/ReportActionEditMessageContext.tsx +++ b/src/pages/inbox/report/ReportActionEditMessageContext.tsx @@ -1,4 +1,4 @@ -import {createContext, useContext, useEffect, useState} from 'react'; +import React, {createContext, useContext, useEffect, useState} from 'react'; import type {TextSelection} from '@components/Composer/types'; import useOnyx from '@hooks/useOnyx'; import ONYXKEYS from '@src/ONYXKEYS'; From c363712edb934cbb9f0ac247bcdaefa6921d5ad1 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 13 Feb 2026 21:42:30 +0000 Subject: [PATCH 023/233] fix: message editing --- .../ComposerWithSuggestions.tsx | 32 ++++--------------- .../ReportActionCompose.tsx | 9 ++---- .../report/ReportActionEditMessageContext.tsx | 30 ++++++++--------- 3 files changed, 22 insertions(+), 49 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index f3eec1407537..4c5bfa4e1d13 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -162,9 +162,6 @@ type ComposerWithSuggestionsProps = Partial & /** Whether the main composer was hidden */ didHideComposerInput?: boolean; - /** Whether the composer is editing in composer */ - isEditingInComposer: boolean; - /** Reference to the outer element */ ref?: Ref; }; @@ -211,7 +208,6 @@ function ComposerWithSuggestions({ lastReportAction, isGroupPolicyReport, policyID, - isEditingInComposer, // Focus onFocus, @@ -274,42 +270,26 @@ function ComposerWithSuggestions({ } return initialValue; }); + const [selection, setSelection] = useState(() => currentEditMessageSelection ?? {start: value.length, end: value.length, positionX: 0, positionY: 0}); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const commentRef = useRef(value); - // Focus the composer when editing in composer - useEffect(() => { - if (!isEditingInComposer) { - return; - } - - composerRef.current?.focus(); - }, [currentEditMessageSelection, isEditingInComposer, value.length]); - - // Reset the composer value when the app extends to wide layout, - // because the inline composer is showing up - useEffect(() => { - if (!editingReportActionID || shouldUseNarrowLayout) { - return; - } - - setValue(''); - }, [editingReportActionID, shouldUseNarrowLayout]); - useEffect(() => { if (!shouldUseNarrowLayout || !editingReportActionID) { + setValue(''); return; } const nextValue = editingMessage ?? draftComment ?? ''; - + commentRef.current = nextValue; emojisPresentBefore.current = extractEmojis(nextValue); + setValue(nextValue); - commentRef.current = nextValue; - }, [editingMessage, draftComment, shouldUseNarrowLayout, editingReportActionID]); + composerRef.current?.focus(); + }, [draftComment, editingMessage, editingReportActionID, shouldUseNarrowLayout]); const {superWideRHPRouteKeys} = useWideRHPState(); // When SearchReport is stacked above another RHP, delay autofocus until after the transition completes to avoid animation jank diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 135cc534f968..916a14ea600b 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -165,7 +165,7 @@ function ReportActionCompose({ canBeMissing: true, }); - const {editingReportActionID, editingReportAction, editingMessage, setActiveEdit} = useReportActionActiveEdit(); + const {editingReportActionID, editingReportAction, editingMessage} = useReportActionActiveEdit(); const isEditingInComposer = shouldUseNarrowLayout && !!editingReportActionID; @@ -352,8 +352,7 @@ function ReportActionCompose({ const deleteDraftMessage = useCallback(() => { deleteDraft(); - setActiveEdit(null); - }, [deleteDraft, setActiveEdit]); + }, [deleteDraft]); /** * Add or edit a comment in the composer @@ -365,7 +364,6 @@ function ReportActionCompose({ if (isEditingInComposer && !attachmentFileRef.current) { publishDraft(draftMessageTrimmed); deleteDraft(); - setActiveEdit(null); return; } @@ -408,7 +406,6 @@ function ReportActionCompose({ isConciergeChat, publishDraft, deleteDraft, - setActiveEdit, kickoffWaitingIndicator, transactionThreadReport, report, @@ -639,9 +636,9 @@ function ReportActionCompose({ onBlur={onBlur} measureParentContainer={measureContainer} onValueChange={onValueChange} - isEditingInComposer={isEditingInComposer} didHideComposerInput={didHideComposerInput} forwardedFSClass={fsClass} + key={editingReportActionID} /> {shouldDisplayDualDropZone && ( void; - currentEditMessageSelection: TextSelection | null; setCurrentEditMessageSelection: (selection: TextSelection) => void; }; @@ -23,8 +21,6 @@ const ReportActionEditMessageContext = createContext { + if (!editingReportActionID) { + return; + } - setCurrentEditMessageSelectionState(selection); - } + setCurrentEditMessageSelectionState(selection); + }, + [editingReportActionID], + ); // Set the active edit when the report actions or draft comments change useEffect(() => { - if (editingReportActionID) { - return; - } - const reportDrafts = reportActionDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`]; if (!reportDrafts) { setActiveEdit(null); + setCurrentEditMessageSelection(null); return; } @@ -77,6 +73,7 @@ function ReportActionEditMessageContextProvider({reportID, children}: ReportActi if (!reportDraftEntry) { setActiveEdit(null); + setCurrentEditMessageSelection(null); return; } @@ -87,7 +84,7 @@ function ReportActionEditMessageContextProvider({reportID, children}: ReportActi editingReportAction: reportActions?.[reportActionID] ?? null, editingMessage: draft.message, }); - }, [editingReportActionID, reportActionDrafts, reportActions, reportID]); + }, [editingReportActionID, reportActionDrafts, reportActions, reportID, setCurrentEditMessageSelection]); return ( Date: Fri, 13 Feb 2026 22:10:50 +0000 Subject: [PATCH 024/233] fix: draft not unset --- .../ComposerWithSuggestions.tsx | 5 ++- .../ReportActionCompose.tsx | 6 +-- .../ReportActionCompose/useEditMessage.ts | 5 +++ .../report/ReportActionEditMessageContext.tsx | 45 ++++++++++++++----- 4 files changed, 42 insertions(+), 19 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 4c5bfa4e1d13..000b023baf06 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -260,7 +260,7 @@ function ComposerWithSuggestions({ const composerRef = useRef(null); - const {editingReportActionID, editingMessage, currentEditMessageSelection, setCurrentEditMessageSelection} = useReportActionActiveEdit(); + const {editingReportActionID, editingMessage, currentEditMessageSelection, setCurrentEditMessageSelection, didSubmitEditRef} = useReportActionActiveEdit(); const [value, setValue] = useState(() => { const initialValue = shouldUseNarrowLayout ? (editingMessage ?? draftComment) : draftComment; @@ -496,7 +496,7 @@ function ComposerWithSuggestions({ commentRef.current = newCommentConverted; if (shouldUseNarrowLayout) { - if (editingReportActionID) { + if (editingReportActionID && !didSubmitEditRef.current) { saveReportActionDraft(reportID, {reportActionID: editingReportActionID} as OnyxTypes.ReportAction, newCommentConverted); } @@ -526,6 +526,7 @@ function ComposerWithSuggestions({ setCurrentEditMessageSelection, currentEditMessageSelection, editingReportActionID, + didSubmitEditRef, reportID, currentUserAccountID, debouncedSaveReportComment, diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 916a14ea600b..27ee73d8b915 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -350,10 +350,6 @@ function ReportActionCompose({ composerRef, }); - const deleteDraftMessage = useCallback(() => { - deleteDraft(); - }, [deleteDraft]); - /** * Add or edit a comment in the composer */ @@ -579,7 +575,7 @@ function ReportActionCompose({ > {PDFValidationComponent} {isEditingInComposer ? ( - + ) : ( validateAttachments({files})} diff --git a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts index 62847bfc78c2..71ca082949df 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts @@ -16,6 +16,7 @@ import focusEditAfterCancelDelete from '@libs/focusEditAfterCancelDelete'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import {getOriginalReportID} from '@libs/ReportUtils'; import * as ReportActionContextMenu from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import {useReportActionActiveEdit} from '@pages/inbox/report/ReportActionEditMessageContext'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import KeyboardUtils from '@src/utils/keyboard'; @@ -45,6 +46,8 @@ function useEditMessage({reportID, originalReportID, reportAction, shouldScrollT const isOriginalParentReportArchived = useReportIsArchived(originalParentReportID); const ancestors = useAncestors(originalReport); + const {didSubmitEditRef} = useReportActionActiveEdit(); + useEffect(() => { // required for keeping last state of isFocused variable isFocusedRef.current = isFocused; @@ -103,6 +106,8 @@ function useEditMessage({reportID, originalReportID, reportAction, shouldScrollT ReportActionContextMenu.showDeleteModal(originalReportID ?? reportID, reportAction, true, deleteDraft, () => focusEditAfterCancelDelete(composerRef.current)); return; } + + didSubmitEditRef.current = true; editReportComment( originalReport, reportAction, diff --git a/src/pages/inbox/report/ReportActionEditMessageContext.tsx b/src/pages/inbox/report/ReportActionEditMessageContext.tsx index 4a62d2eba269..4d8cc6300928 100644 --- a/src/pages/inbox/report/ReportActionEditMessageContext.tsx +++ b/src/pages/inbox/report/ReportActionEditMessageContext.tsx @@ -1,4 +1,4 @@ -import React, {createContext, useCallback, useContext, useEffect, useState} from 'react'; +import React, {createContext, useCallback, useContext, useEffect, useRef, useState} from 'react'; import type {TextSelection} from '@components/Composer/types'; import useOnyx from '@hooks/useOnyx'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -15,6 +15,7 @@ type ReportActionActiveEdit = { type ReportActionEditMessageContextValue = ReportActionActiveEdit & { currentEditMessageSelection: TextSelection | null; setCurrentEditMessageSelection: (selection: TextSelection) => void; + didSubmitEditRef: React.RefObject; }; const ReportActionEditMessageContext = createContext({ @@ -23,6 +24,7 @@ const ReportActionEditMessageContext = createContext(null); const [editingMessage, setEditingMessage] = useState(null); const [currentEditMessageSelection, setCurrentEditMessageSelectionState] = useState(null); + const didSubmitEditRef = useRef(false); - function setActiveEdit(activeEdit: ReportActionActiveEdit | null) { - setEditingReportActionID(activeEdit?.editingReportActionID ?? null); - setEditingReportAction(activeEdit?.editingReportAction ?? null); - setEditingMessage(activeEdit?.editingMessage ?? null); - } + const updateActiveEditState = useCallback( + (activeEdit: ReportActionActiveEdit | null) => { + const newEditingReportActionID = activeEdit?.editingReportActionID ?? null; + const newEditingReportAction = activeEdit?.editingReportAction ?? null; + const newEditingMessage = activeEdit?.editingMessage ?? null; + + if (newEditingReportActionID !== editingReportActionID) { + setEditingReportActionID(newEditingReportActionID); + } + if (newEditingReportAction !== editingReportAction) { + setEditingReportAction(newEditingReportAction); + } + if (newEditingMessage !== editingMessage) { + setEditingMessage(newEditingMessage); + } + }, + [editingReportActionID, editingReportAction, editingMessage], + ); const setCurrentEditMessageSelection = useCallback( (selection: TextSelection | null) => { @@ -59,32 +75,36 @@ function ReportActionEditMessageContextProvider({reportID, children}: ReportActi [editingReportActionID], ); + const reset = useCallback(() => { + didSubmitEditRef.current = false; + updateActiveEditState(null); + setCurrentEditMessageSelection(null); + }, [updateActiveEditState, setCurrentEditMessageSelection]); + // Set the active edit when the report actions or draft comments change useEffect(() => { const reportDrafts = reportActionDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`]; if (!reportDrafts) { - setActiveEdit(null); - setCurrentEditMessageSelection(null); + reset(); return; } const reportDraftEntry = Object.entries(reportDrafts).find(([, draft]) => draft?.message); if (!reportDraftEntry) { - setActiveEdit(null); - setCurrentEditMessageSelection(null); + reset(); return; } const [reportActionID, draft] = reportDraftEntry; - setActiveEdit({ + updateActiveEditState({ editingReportActionID: reportActionID, editingReportAction: reportActions?.[reportActionID] ?? null, editingMessage: draft.message, }); - }, [editingReportActionID, reportActionDrafts, reportActions, reportID, setCurrentEditMessageSelection]); + }, [reportActionDrafts, reportActions, reportID, reset, updateActiveEditState]); return ( {children} From c48635a23b856535f61d308da5962a07d84f0df1 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 13 Feb 2026 22:16:09 +0000 Subject: [PATCH 025/233] fix: pass `originalReportID` --- .../inbox/report/ReportActionCompose/ReportActionCompose.tsx | 3 +++ src/pages/inbox/report/ReportActionCompose/useEditMessage.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 27ee73d8b915..6667a751b4b3 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -24,6 +24,7 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import useOriginalReportID from '@hooks/useOriginalReportID'; import usePreferredPolicy from '@hooks/usePreferredPolicy'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -341,8 +342,10 @@ function ReportActionCompose({ const {debouncedCommentMaxLengthValidation, exceededMaxLength, isExceedingMaxLength, isTaskTitle} = useDebouncedCommentMaxLengthValidation({reportID}); + const originalReportID = useOriginalReportID(reportID, editingReportAction); const {publishDraft, deleteDraft} = useEditMessage({ reportID, + originalReportID, reportAction: editingReportAction, shouldScrollToLastMessage: isEditingLastReportAction, isFocused, diff --git a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts index 71ca082949df..40357b507e8a 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts @@ -23,7 +23,7 @@ import KeyboardUtils from '@src/utils/keyboard'; type UseEditMessageProps = { reportID: string | undefined; - originalReportID?: string; + originalReportID: string | undefined; reportAction: OnyxTypes.ReportAction | null | undefined; shouldScrollToLastMessage?: boolean; isFocused: boolean; From e5207452412341740923dfda34fe83c897084301 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 13 Feb 2026 22:31:20 +0000 Subject: [PATCH 026/233] fix: allow passing unset reportAction --- src/libs/actions/Report/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 0a260d54314f..114fb675b41e 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -2395,7 +2395,11 @@ function deleteReportActionDraft(reportID: string | undefined, reportAction: Rep } /** Saves the draft for a comment report action. This will put the comment into "edit mode" */ -function saveReportActionDraft(reportID: string | undefined, reportAction: ReportAction, draftMessage: string) { +function saveReportActionDraft(reportID: string | undefined, reportAction: ReportAction | null, draftMessage: string) { + if (!reportAction) { + return; + } + const originalReportID = getOriginalReportID(reportID, reportAction, undefined); Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${originalReportID}`, {[reportAction.reportActionID]: {message: draftMessage}}); } From 4cbc1ba46fbc71097b9b2875687d72e8dd97d40c Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 13 Feb 2026 22:31:34 +0000 Subject: [PATCH 027/233] fix: allow passing empty draft to delete message --- .../inbox/report/ReportActionCompose/useEditMessage.ts | 3 ++- .../inbox/report/ReportActionEditMessageContext.tsx | 9 +++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts index 40357b507e8a..53fad0d46fdf 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts @@ -67,6 +67,8 @@ function useEditMessage({reportID, originalReportID, reportAction, shouldScrollT return; } + didSubmitEditRef.current = true; + deleteReportActionDraft(reportID, reportAction); if (isActive()) { @@ -107,7 +109,6 @@ function useEditMessage({reportID, originalReportID, reportAction, shouldScrollT return; } - didSubmitEditRef.current = true; editReportComment( originalReport, reportAction, diff --git a/src/pages/inbox/report/ReportActionEditMessageContext.tsx b/src/pages/inbox/report/ReportActionEditMessageContext.tsx index 4d8cc6300928..9b961f547949 100644 --- a/src/pages/inbox/report/ReportActionEditMessageContext.tsx +++ b/src/pages/inbox/report/ReportActionEditMessageContext.tsx @@ -43,7 +43,7 @@ function ReportActionEditMessageContextProvider({reportID, children}: ReportActi const [editingReportAction, setEditingReportAction] = useState(null); const [editingMessage, setEditingMessage] = useState(null); const [currentEditMessageSelection, setCurrentEditMessageSelectionState] = useState(null); - const didSubmitEditRef = useRef(false); + const didSubmitEditRef = useRef(null); const updateActiveEditState = useCallback( (activeEdit: ReportActionActiveEdit | null) => { @@ -76,7 +76,11 @@ function ReportActionEditMessageContextProvider({reportID, children}: ReportActi ); const reset = useCallback(() => { - didSubmitEditRef.current = false; + if (didSubmitEditRef.current === false) { + return; + } + + didSubmitEditRef.current = null; updateActiveEditState(null); setCurrentEditMessageSelection(null); }, [updateActiveEditState, setCurrentEditMessageSelection]); @@ -99,6 +103,7 @@ function ReportActionEditMessageContextProvider({reportID, children}: ReportActi const [reportActionID, draft] = reportDraftEntry; + didSubmitEditRef.current = false; updateActiveEditState({ editingReportActionID: reportActionID, editingReportAction: reportActions?.[reportActionID] ?? null, From e7f098a9f6f94c0ae7466afb596e0d9461a27f51 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 13 Feb 2026 22:31:44 +0000 Subject: [PATCH 028/233] fix: add debounce to draft message save --- .../ComposerWithSuggestions.tsx | 41 ++++++++++++++++--- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 000b023baf06..9be11cd0fe54 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -260,7 +260,7 @@ function ComposerWithSuggestions({ const composerRef = useRef(null); - const {editingReportActionID, editingMessage, currentEditMessageSelection, setCurrentEditMessageSelection, didSubmitEditRef} = useReportActionActiveEdit(); + const {editingReportActionID, editingReportAction, editingMessage, currentEditMessageSelection, setCurrentEditMessageSelection, didSubmitEditRef} = useReportActionActiveEdit(); const [value, setValue] = useState(() => { const initialValue = shouldUseNarrowLayout ? (editingMessage ?? draftComment) : draftComment; @@ -318,9 +318,6 @@ function ComposerWithSuggestions({ const syncSelectionWithOnChangeTextRef = useRef(null); - // The ref to check whether the comment saving is in progress - const isCommentPendingSaved = useRef(false); - // Tracks transition state to prevent SilentCommentUpdater from overwriting the just-saved draft during report ID changes const isTransitioningToPreExistingReport = useRef(false); @@ -351,6 +348,34 @@ function ComposerWithSuggestions({ RNTextInputReset.resetKeyboardInput(CONST.COMPOSER.NATIVE_ID); }, []); + // The ref to check whether the comment saving is in progress + const isDraftPendingSaved = useRef(false); + + /** + * Save the draft of the comment. This debounced so that we're not ceaselessly saving your edit. Saving the draft + * allows one to navigate somewhere else and come back to the comment and still have it in edit mode. + * @param {String} newDraft + */ + const debouncedSaveDraft = useMemo( + () => + lodashDebounce((newDraft: string) => { + saveReportActionDraft(reportID, editingReportAction, newDraft); + isDraftPendingSaved.current = false; + }, 1000), + [reportID, editingReportAction], + ); + + useEffect( + () => () => { + debouncedSaveDraft.cancel(); + isDraftPendingSaved.current = false; + }, + [debouncedSaveDraft], + ); + + // The ref to check whether the comment saving is in progress + const isCommentPendingSaved = useRef(false); + const debouncedSaveReportComment = useMemo( () => lodashDebounce((selectedReportID: string, newComment: string | null) => { @@ -497,7 +522,12 @@ function ComposerWithSuggestions({ commentRef.current = newCommentConverted; if (shouldUseNarrowLayout) { if (editingReportActionID && !didSubmitEditRef.current) { - saveReportActionDraft(reportID, {reportActionID: editingReportActionID} as OnyxTypes.ReportAction, newCommentConverted); + if (shouldDebounceSaveComment) { + isDraftPendingSaved.current = true; + debouncedSaveDraft(newCommentConverted); + } else { + saveReportActionDraft(reportID, {reportActionID: editingReportActionID} as OnyxTypes.ReportAction, newCommentConverted); + } } if (newCommentConverted) { @@ -527,6 +557,7 @@ function ComposerWithSuggestions({ currentEditMessageSelection, editingReportActionID, didSubmitEditRef, + debouncedSaveDraft, reportID, currentUserAccountID, debouncedSaveReportComment, From cb8d96966cecb0d3c823bcbaf25c34015451d00a Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 13 Feb 2026 22:35:38 +0000 Subject: [PATCH 029/233] fix: TS error --- src/pages/inbox/report/ReportActionEditMessageContext.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/inbox/report/ReportActionEditMessageContext.tsx b/src/pages/inbox/report/ReportActionEditMessageContext.tsx index 9b961f547949..0c0845ac8ae8 100644 --- a/src/pages/inbox/report/ReportActionEditMessageContext.tsx +++ b/src/pages/inbox/report/ReportActionEditMessageContext.tsx @@ -15,7 +15,7 @@ type ReportActionActiveEdit = { type ReportActionEditMessageContextValue = ReportActionActiveEdit & { currentEditMessageSelection: TextSelection | null; setCurrentEditMessageSelection: (selection: TextSelection) => void; - didSubmitEditRef: React.RefObject; + didSubmitEditRef: React.RefObject; }; const ReportActionEditMessageContext = createContext({ @@ -24,7 +24,7 @@ const ReportActionEditMessageContext = createContext Date: Mon, 16 Feb 2026 18:33:52 +0000 Subject: [PATCH 030/233] fix: default string in `useOnyx` hook --- src/pages/inbox/report/ReportActionEditMessageContext.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionEditMessageContext.tsx b/src/pages/inbox/report/ReportActionEditMessageContext.tsx index 0c0845ac8ae8..de3439a91b3e 100644 --- a/src/pages/inbox/report/ReportActionEditMessageContext.tsx +++ b/src/pages/inbox/report/ReportActionEditMessageContext.tsx @@ -1,6 +1,7 @@ import React, {createContext, useCallback, useContext, useEffect, useRef, useState} from 'react'; import type {TextSelection} from '@components/Composer/types'; import useOnyx from '@hooks/useOnyx'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; @@ -33,7 +34,7 @@ type ReportActionEditMessageContextProviderProps = { }; function ReportActionEditMessageContextProvider({reportID, children}: ReportActionEditMessageContextProviderProps) { - const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID ?? ''}`, { + const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { canEvict: false, canBeMissing: true, }); From 280368576377d39d44d5ed3a2b819de3b4aefb7c Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 16 Feb 2026 18:34:12 +0000 Subject: [PATCH 031/233] fix: remove unused import --- src/pages/inbox/report/ReportActionEditMessageContext.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionEditMessageContext.tsx b/src/pages/inbox/report/ReportActionEditMessageContext.tsx index de3439a91b3e..e11c34a86b9d 100644 --- a/src/pages/inbox/report/ReportActionEditMessageContext.tsx +++ b/src/pages/inbox/report/ReportActionEditMessageContext.tsx @@ -1,7 +1,6 @@ import React, {createContext, useCallback, useContext, useEffect, useRef, useState} from 'react'; import type {TextSelection} from '@components/Composer/types'; import useOnyx from '@hooks/useOnyx'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; From 5b4103c7245299fa351d808824ed8884c7a4a660 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 16 Feb 2026 18:47:11 +0000 Subject: [PATCH 032/233] fix: pass missing `isEditing` flag --- .../inbox/report/ReportActionCompose/ReportActionCompose.tsx | 5 ++++- .../useDebouncedCommentMaxLengthValidation.ts | 2 ++ src/pages/inbox/report/ReportActionItemMessageEdit.tsx | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index c2b986139de0..c156d05192e3 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -341,7 +341,10 @@ function ReportActionCompose({ ComposerFocusManager.setReadyToFocus(); }, [updateShouldShowSuggestionMenuToFalse]); - const {debouncedCommentMaxLengthValidation, exceededMaxLength, isExceedingMaxLength, isTaskTitle} = useDebouncedCommentMaxLengthValidation({reportID}); + const {debouncedCommentMaxLengthValidation, exceededMaxLength, isExceedingMaxLength, isTaskTitle} = useDebouncedCommentMaxLengthValidation({ + reportID, + isEditing: !!editingReportAction, + }); const originalReportID = useOriginalReportID(reportID, editingReportAction); const {publishDraft, deleteDraft} = useEditMessage({ diff --git a/src/pages/inbox/report/ReportActionCompose/useDebouncedCommentMaxLengthValidation.ts b/src/pages/inbox/report/ReportActionCompose/useDebouncedCommentMaxLengthValidation.ts index e83d9a678f82..15baf8e62669 100644 --- a/src/pages/inbox/report/ReportActionCompose/useDebouncedCommentMaxLengthValidation.ts +++ b/src/pages/inbox/report/ReportActionCompose/useDebouncedCommentMaxLengthValidation.ts @@ -15,10 +15,12 @@ function useDebouncedCommentMaxLengthValidation({reportID, isEditing = false}: U /** * Updates the composer when the comment length is exceeded * Shows red borders and prevents the comment from being sent + * When editing, we only validate comment length; task title rules do not apply. */ function validateMaxLength(value: string) { const taskCommentMatch = value?.match(CONST.REGEX.TASK_TITLE_WITH_OPTIONAL_SHORT_MENTION); + // Only apply task-title validation when composing (not when editing an existing message) if (!isEditing && taskCommentMatch) { const title = taskCommentMatch?.[3] ? taskCommentMatch[3].trim().replaceAll('\n', ' ') : ''; const exceeded = title ? title.length > CONST.TITLE_CHARACTER_LIMIT : false; diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index d03bce1aa96e..5ebe6025656f 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -140,7 +140,7 @@ function ReportActionItemMessageEdit({ const [isFocused, setIsFocused] = useState(false); - const {debouncedCommentMaxLengthValidation, isExceedingMaxLength} = useDebouncedCommentMaxLengthValidation({reportID}); + const {debouncedCommentMaxLengthValidation, isExceedingMaxLength} = useDebouncedCommentMaxLengthValidation({reportID, isEditing: true}); const {isScrollLayoutTriggered, raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); From 05b972d0340a5fd9cc10ff244a33d16a754b17c1 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 16 Feb 2026 19:23:11 +0000 Subject: [PATCH 033/233] test: extract logic into test utils --- tests/ui/ReportActionComposeTest.tsx | 32 +------ tests/ui/ReportActionItemMessageEditTest.tsx | 33 +------- tests/utils/ReportActionComposeUtils.tsx | 87 ++++++++++++++++++++ 3 files changed, 92 insertions(+), 60 deletions(-) create mode 100644 tests/utils/ReportActionComposeUtils.tsx diff --git a/tests/ui/ReportActionComposeTest.tsx b/tests/ui/ReportActionComposeTest.tsx index a9eecd42f649..27c6b26db3bf 100644 --- a/tests/ui/ReportActionComposeTest.tsx +++ b/tests/ui/ReportActionComposeTest.tsx @@ -1,16 +1,11 @@ import type * as NativeNavigation from '@react-navigation/native'; -import {act, fireEvent, render, screen, waitFor} from '@testing-library/react-native'; -import React from 'react'; +import {act, fireEvent, screen, waitFor} from '@testing-library/react-native'; import Onyx from 'react-native-onyx'; -import ComposeProviders from '@components/ComposeProviders'; -import {LocaleContextProvider} from '@components/LocaleContextProvider'; -import OnyxListItemProvider from '@components/OnyxListItemProvider'; import {forceClearInput} from '@libs/ComponentUtils'; -import type {ReportActionComposeProps} from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; -import ReportActionCompose, {onSubmitAction} from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; +import {onSubmitAction} from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import * as LHNTestUtils from '../utils/LHNTestUtils'; +import {renderReportActionCompose} from '../utils/ReportActionComposeUtils'; import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; @@ -41,27 +36,6 @@ jest.mock('@react-navigation/native', () => ({ TestHelper.setupGlobalFetchMock(); -const defaultReport = LHNTestUtils.getFakeReport(); -const defaultProps: ReportActionComposeProps = { - onSubmit: jest.fn(), - isComposerFullSize: false, - reportID: defaultReport.reportID, - report: defaultReport, -}; - -const renderReportActionCompose = (props?: Partial) => { - return render( - - - , - ); -}; - // Helper function to simulate text selection const simulateSelection = (composer: ReturnType, start: number, end: number) => { fireEvent(composer, 'selectionChange', { diff --git a/tests/ui/ReportActionItemMessageEditTest.tsx b/tests/ui/ReportActionItemMessageEditTest.tsx index 95a9c42d2ded..84212a6afec6 100644 --- a/tests/ui/ReportActionItemMessageEditTest.tsx +++ b/tests/ui/ReportActionItemMessageEditTest.tsx @@ -1,16 +1,10 @@ import type * as NativeNavigation from '@react-navigation/native'; -import {act, fireEvent, render, screen} from '@testing-library/react-native'; -import React from 'react'; +import {act, fireEvent, screen} from '@testing-library/react-native'; import Onyx from 'react-native-onyx'; -import ComposeProviders from '@components/ComposeProviders'; -import {LocaleContextProvider} from '@components/LocaleContextProvider'; -import OnyxListItemProvider from '@components/OnyxListItemProvider'; import {editReportComment} from '@libs/actions/Report'; -import ReportActionItemMessageEdit from '@pages/inbox/report/ReportActionItemMessageEdit'; -import type {ReportActionItemMessageEditProps} from '@pages/inbox/report/ReportActionItemMessageEdit'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import * as LHNTestUtils from '../utils/LHNTestUtils'; +import {renderReportActionItemMessageEdit} from '../utils/ReportActionComposeUtils'; import * as TestHelper from '../utils/TestHelper'; const mockEditReportComment = jest.mocked(editReportComment); @@ -46,29 +40,6 @@ jest.mock('@react-navigation/native', () => ({ TestHelper.setupGlobalFetchMock(); -const defaultReport = LHNTestUtils.getFakeReport(); -const defaultProps: ReportActionItemMessageEditProps = { - action: LHNTestUtils.getFakeReportAction(), - draftMessage: '', - reportID: defaultReport.reportID, - originalReportID: defaultReport.reportID, - index: 0, - isGroupPolicyReport: false, -}; - -const renderReportActionItemMessageEdit = (props?: Partial) => { - return render( - - - , - ); -}; - describe('ReportActionCompose Integration Tests', () => { beforeAll(() => { Onyx.init({ diff --git a/tests/utils/ReportActionComposeUtils.tsx b/tests/utils/ReportActionComposeUtils.tsx new file mode 100644 index 000000000000..d8ef98e2a04c --- /dev/null +++ b/tests/utils/ReportActionComposeUtils.tsx @@ -0,0 +1,87 @@ +import {render} from '@testing-library/react-native'; +import type {PropsWithChildren} from 'react'; +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import type {ReportActionComposeProps} from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; +import ReportActionCompose from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; +import {ReportActionEditMessageContextProvider} from '@pages/inbox/report/ReportActionEditMessageContext'; +import type {ReportActionItemMessageEditProps} from '@pages/inbox/report/ReportActionItemMessageEdit'; +import ReportActionItemMessageEdit from '@pages/inbox/report/ReportActionItemMessageEdit'; +import * as LHNTestUtils from './LHNTestUtils'; + +const defaultReport = LHNTestUtils.getFakeReport(); + +function ReportActionEditMessageContextProviderForReport({children}: PropsWithChildren) { + return {children}; +} + +function ReportScreenProviders({children}: PropsWithChildren) { + return {children}; +} + +const defaultReportActionComposeProps: ReportActionComposeProps = { + onSubmit: jest.fn(), + isComposerFullSize: false, + reportID: defaultReport.reportID, + report: defaultReport, +}; + +const renderReportActionCompose = (props?: Partial) => { + return render( + + + , + ); +}; + +const defaultReportActionItemMessageEditProps: ReportActionItemMessageEditProps = { + action: LHNTestUtils.getFakeReportAction(), + draftMessage: '', + reportID: defaultReport.reportID, + originalReportID: defaultReport.reportID, + index: 0, + isGroupPolicyReport: false, +}; + +const renderReportActionItemMessageEdit = (props?: Partial) => { + return render( + + + , + ); +}; + +const renderReportActionMessageEditComponents = ( + reportActionComposeProps?: Partial, + reportActionItemMessageEditProps?: Partial, +) => { + return render( + + + + , + ); +}; + +export {renderReportActionCompose, renderReportActionItemMessageEdit, renderReportActionMessageEditComponents}; From ce4fecc9b2488ac9da9a623c8ef2cf891958ca49 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 16 Feb 2026 19:23:27 +0000 Subject: [PATCH 034/233] fix: pass `maxCommentLength` prop --- src/components/ExceededCommentLength.tsx | 2 +- src/pages/inbox/report/ReportActionItemMessageEdit.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/ExceededCommentLength.tsx b/src/components/ExceededCommentLength.tsx index 8996a69c0ba8..69bf7c96d8f4 100644 --- a/src/components/ExceededCommentLength.tsx +++ b/src/components/ExceededCommentLength.tsx @@ -9,7 +9,7 @@ type ExceededCommentLengthProps = { isTaskTitle?: boolean; }; -function ExceededCommentLength({maxCommentLength = CONST.MAX_COMMENT_LENGTH, isTaskTitle}: ExceededCommentLengthProps) { +function ExceededCommentLength({maxCommentLength = CONST.MAX_COMMENT_LENGTH, isTaskTitle = false}: ExceededCommentLengthProps) { const styles = useThemeStyles(); const {numberFormat, translate} = useLocalize(); diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index 5ebe6025656f..e317c21c60cb 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -140,7 +140,7 @@ function ReportActionItemMessageEdit({ const [isFocused, setIsFocused] = useState(false); - const {debouncedCommentMaxLengthValidation, isExceedingMaxLength} = useDebouncedCommentMaxLengthValidation({reportID, isEditing: true}); + const {debouncedCommentMaxLengthValidation, exceededMaxLength, isExceedingMaxLength} = useDebouncedCommentMaxLengthValidation({reportID, isEditing: true}); const {isScrollLayoutTriggered, raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); @@ -539,7 +539,7 @@ function ReportActionItemMessageEdit({ /> - {isExceedingMaxLength && } + {isExceedingMaxLength && !!exceededMaxLength && } ); } From ba5f1c06f14e671ff46d8cb77ea8b9eb22e4aa31 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 16 Feb 2026 19:35:07 +0000 Subject: [PATCH 035/233] remove unused code --- .../report/ReportActionCompose/SendButton.tsx | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/SendButton.tsx b/src/pages/inbox/report/ReportActionCompose/SendButton.tsx index e8a3a48ce4a6..6cf4607b6de3 100644 --- a/src/pages/inbox/report/ReportActionCompose/SendButton.tsx +++ b/src/pages/inbox/report/ReportActionCompose/SendButton.tsx @@ -79,21 +79,6 @@ function SendButton({isDisabled: isDisabledProp, onSend, isEditing = false}: Sen - - {/* - e.preventDefault()} - > - - - */} ); From 8c7e1d388083349bd589e528b40f8c0876301009 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 16 Feb 2026 21:10:23 +0000 Subject: [PATCH 036/233] fix: use checkmark icon for editing in send button --- src/pages/inbox/report/ReportActionCompose/SendButton.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/SendButton.tsx b/src/pages/inbox/report/ReportActionCompose/SendButton.tsx index 6cf4607b6de3..43546461ca90 100644 --- a/src/pages/inbox/report/ReportActionCompose/SendButton.tsx +++ b/src/pages/inbox/report/ReportActionCompose/SendButton.tsx @@ -10,6 +10,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; type SendButtonProps = { /** Whether the button is disabled */ @@ -26,6 +27,8 @@ function SendButton({isDisabled: isDisabledProp, onSend, isEditing = false}: Sen const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Checkmark']); + // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to manage GestureDetector correctly // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); @@ -33,7 +36,8 @@ function SendButton({isDisabled: isDisabledProp, onSend, isEditing = false}: Sen .onEnd(() => { onSend(); }) - .runOnJS(true); + .runOnJS(true) + .withTestId(CONST.COMPOSER.SEND_BUTTON_TEST_ID); const label = isEditing ? translate('common.saveChanges') : translate('common.send'); const sentryLabel = isEditing ? CONST.SENTRY_LABEL.REPORT.REPORT_ACTION_ITEM_MESSAGE_EDIT_SAVE_BUTTON : CONST.SENTRY_LABEL.REPORT.SEND_BUTTON; @@ -72,7 +76,7 @@ function SendButton({isDisabled: isDisabledProp, onSend, isEditing = false}: Sen > {({pressed}) => ( )} From a9c5406a767c38f8921c9818f4ee0fdbc5d3ba28 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 16 Feb 2026 21:52:18 +0000 Subject: [PATCH 037/233] fix: tests with new `SendButton` --- tests/ui/ReportActionItemMessageEditTest.tsx | 17 ++++++++++------- tests/utils/ReportActionComposeUtils.tsx | 16 +++++++++++++++- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/tests/ui/ReportActionItemMessageEditTest.tsx b/tests/ui/ReportActionItemMessageEditTest.tsx index 84212a6afec6..204755c12d34 100644 --- a/tests/ui/ReportActionItemMessageEditTest.tsx +++ b/tests/ui/ReportActionItemMessageEditTest.tsx @@ -4,7 +4,8 @@ import Onyx from 'react-native-onyx'; import {editReportComment} from '@libs/actions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {renderReportActionItemMessageEdit} from '../utils/ReportActionComposeUtils'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; +import {pressReportActionComposeSendButton, renderReportActionItemMessageEdit} from '../utils/ReportActionComposeUtils'; import * as TestHelper from '../utils/TestHelper'; const mockEditReportComment = jest.mocked(editReportComment); @@ -58,15 +59,16 @@ describe('ReportActionCompose Integration Tests', () => { describe('Message validation', () => { it('should edit when length is within the limit', async () => { renderReportActionItemMessageEdit(); - const composer = screen.getByTestId('composer'); - const saveChangesButton = screen.getByLabelText('common.saveChanges'); + const composer = screen.getByTestId(CONST.COMPOSER.NATIVE_ID); // Given a message that is within the length limit const validMessage = 'x'.repeat(CONST.MAX_COMMENT_LENGTH); fireEvent.changeText(composer, validMessage); + await waitForBatchedUpdatesWithAct(); + // When the message is saved - fireEvent.press(saveChangesButton); + pressReportActionComposeSendButton(); // Then the message should be edited expect(mockEditReportComment).toHaveBeenCalledTimes(1); @@ -74,15 +76,16 @@ describe('ReportActionCompose Integration Tests', () => { it('should not edit when length exceeds the limit', async () => { renderReportActionItemMessageEdit(); - const composer = screen.getByTestId('composer'); - const saveChangesButton = screen.getByLabelText('common.saveChanges'); + const composer = screen.getByTestId(CONST.COMPOSER.NATIVE_ID); // Given a message that is over the length limit const invalidMessage = 'x'.repeat(CONST.MAX_COMMENT_LENGTH + 1); fireEvent.changeText(composer, invalidMessage); + await waitForBatchedUpdatesWithAct(); + // When the message is saved - fireEvent.press(saveChangesButton); + pressReportActionComposeSendButton(); // Then the message should NOT be edited expect(mockEditReportComment).toHaveBeenCalledTimes(0); diff --git a/tests/utils/ReportActionComposeUtils.tsx b/tests/utils/ReportActionComposeUtils.tsx index d8ef98e2a04c..b0cb8fe24be3 100644 --- a/tests/utils/ReportActionComposeUtils.tsx +++ b/tests/utils/ReportActionComposeUtils.tsx @@ -8,6 +8,9 @@ import ReportActionCompose from '@pages/inbox/report/ReportActionCompose/ReportA import {ReportActionEditMessageContextProvider} from '@pages/inbox/report/ReportActionEditMessageContext'; import type {ReportActionItemMessageEditProps} from '@pages/inbox/report/ReportActionItemMessageEdit'; import ReportActionItemMessageEdit from '@pages/inbox/report/ReportActionItemMessageEdit'; +import {fireGestureHandler, getByGestureTestId} from 'react-native-gesture-handler/jest-utils'; +import CONST from '@src/CONST'; +import {State} from 'react-native-gesture-handler'; import * as LHNTestUtils from './LHNTestUtils'; const defaultReport = LHNTestUtils.getFakeReport(); @@ -84,4 +87,15 @@ const renderReportActionMessageEditComponents = ( ); }; -export {renderReportActionCompose, renderReportActionItemMessageEdit, renderReportActionMessageEditComponents}; +function pressReportActionComposeSendButton() { + const gesture = getByGestureTestId(CONST.COMPOSER.SEND_BUTTON_TEST_ID); + + fireGestureHandler(gesture, [ + {oldState: State.UNDETERMINED, state: State.BEGAN}, + {oldState: State.BEGAN, state: State.ACTIVE}, + {oldState: State.ACTIVE, state: State.ACTIVE}, + {oldState: State.ACTIVE, state: State.END}, + ]); +} + +export {renderReportActionCompose, renderReportActionItemMessageEdit, renderReportActionMessageEditComponents, pressReportActionComposeSendButton}; From 682ea8ab7c49f3e56bcb95c9050a1b63a38e4561 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 16 Feb 2026 21:53:22 +0000 Subject: [PATCH 038/233] fix: extract COMPOSER test id --- .../ComposerWithSuggestions/ComposerWithSuggestions.tsx | 2 +- src/pages/inbox/report/ReportActionItemMessageEdit.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 9be11cd0fe54..a448d9d7a0af 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -1074,7 +1074,7 @@ function ComposerWithSuggestions({ isComposerFullSize={isComposerFullSize} onContentSizeChange={handleContentSizeChange} value={value} - testID="composer" + testID={CONST.COMPOSER.NATIVE_ID} shouldCalculateCaretPosition onLayout={onLayout} onScroll={hideSuggestionMenu} diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index e317c21c60cb..6882e82ed695 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -500,7 +500,7 @@ function ReportActionItemMessageEdit({ isGroupPolicyReport={isGroupPolicyReport} shouldCalculateCaretPosition onScroll={onSaveScrollAndHideSuggestionMenu} - testID="composer" + testID={CONST.COMPOSER.NATIVE_ID} /> From 106bc0e6c13d12fea8af689b2f8c9ea347ea653e Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 16 Feb 2026 21:53:33 +0000 Subject: [PATCH 039/233] refactor: SendButton props --- .../inbox/report/ReportActionCompose/SendButton.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/SendButton.tsx b/src/pages/inbox/report/ReportActionCompose/SendButton.tsx index 43546461ca90..ad726e3b5a18 100644 --- a/src/pages/inbox/report/ReportActionCompose/SendButton.tsx +++ b/src/pages/inbox/report/ReportActionCompose/SendButton.tsx @@ -23,7 +23,7 @@ type SendButtonProps = { onSend: () => void; }; -function SendButton({isDisabled: isDisabledProp, onSend, isEditing = false}: SendButtonProps) { +function SendButton({isDisabled: isDisabledProp = false, isEditing = false, onSend}: SendButtonProps) { const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -33,13 +33,14 @@ function SendButton({isDisabled: isDisabledProp, onSend, isEditing = false}: Sen // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); const Tap = Gesture.Tap() + .enabled(!isDisabledProp) .onEnd(() => { onSend(); }) .runOnJS(true) .withTestId(CONST.COMPOSER.SEND_BUTTON_TEST_ID); - const label = isEditing ? translate('common.saveChanges') : translate('common.send'); + const label = translate(isEditing ? 'common.saveChanges' : 'common.send'); const sentryLabel = isEditing ? CONST.SENTRY_LABEL.REPORT.REPORT_ACTION_ITEM_MESSAGE_EDIT_SAVE_BUTTON : CONST.SENTRY_LABEL.REPORT.SEND_BUTTON; return ( @@ -65,14 +66,15 @@ function SendButton({isDisabled: isDisabledProp, onSend, isEditing = false}: Sen [ styles.chatItemSubmitButton, - isDisabledProp || pressed || isDisabled ? undefined : styles.buttonSuccess, - isDisabledProp ? styles.cursorDisabled : undefined, + pressed || isDisabled ? undefined : styles.buttonSuccess, + isDisabled ? styles.cursorDisabled : undefined, ]} // Since the parent View has accessible, we need to set accessible to false here to avoid duplicate accessibility elements. // On Android when TalkBack is enabled, only the parent element should be accessible, otherwise the button will not work. accessible={false} focusable={false} sentryLabel={sentryLabel} + disabled={isDisabledProp} > {({pressed}) => ( Date: Mon, 16 Feb 2026 21:53:48 +0000 Subject: [PATCH 040/233] fix: ignore react-hooks rule specifically --- src/pages/inbox/report/ReportActionEditMessageContext.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/inbox/report/ReportActionEditMessageContext.tsx b/src/pages/inbox/report/ReportActionEditMessageContext.tsx index e11c34a86b9d..9d6c07c67656 100644 --- a/src/pages/inbox/report/ReportActionEditMessageContext.tsx +++ b/src/pages/inbox/report/ReportActionEditMessageContext.tsx @@ -90,6 +90,7 @@ function ReportActionEditMessageContextProvider({reportID, children}: ReportActi const reportDrafts = reportActionDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`]; if (!reportDrafts) { + // eslint-disable-next-line react-hooks/set-state-in-effect reset(); return; } From e6451cc89bb57817cb3b17a4bb867018cb6a2e7f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 16 Feb 2026 21:53:50 +0000 Subject: [PATCH 041/233] Update index.ts --- src/CONST/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index da294e4abb72..a78e2ef74f15 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1671,6 +1671,7 @@ const CONST = { MAX_LINES_FULL: -1, // The minimum height needed to enable the full screen composer FULL_COMPOSER_MIN_HEIGHT: 60, + SEND_BUTTON_TEST_ID: 'send-button', }, MODAL: { MODAL_TYPE: { From c588aac1dca0ace4ff2b6b74e7160aedeeab79b0 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 16 Feb 2026 22:52:35 +0000 Subject: [PATCH 042/233] fix: simplify `useImperativeHandle` for `ComposerWithSuggestions` --- src/components/Composer/types.ts | 4 +-- .../ComposerWithSuggestions.tsx | 33 +++++-------------- 2 files changed, 11 insertions(+), 26 deletions(-) diff --git a/src/components/Composer/types.ts b/src/components/Composer/types.ts index 7fc8708eb9aa..1cdd50f4db61 100644 --- a/src/components/Composer/types.ts +++ b/src/components/Composer/types.ts @@ -1,6 +1,6 @@ +import type {MarkdownTextInput} from '@expensify/react-native-live-markdown'; import type {Ref} from 'react'; import type {StyleProp, TextInputProps, TextInputSelectionChangeEvent, TextStyle} from 'react-native'; -import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import type {FileObject} from '@src/types/utils/Attachment'; @@ -15,7 +15,7 @@ type CustomSelectionChangeEvent = TextInputSelectionChangeEvent & { positionY?: number; }; -type ComposerRef = AnimatedMarkdownTextInputRef & HTMLInputElement & HTMLTextAreaElement; +type ComposerRef = MarkdownTextInput & HTMLInputElement & HTMLTextAreaElement; type ComposerProps = Omit & ForwardedFSClassProps & { diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index a448d9d7a0af..d763e7be1e32 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -902,30 +902,15 @@ function ComposerWithSuggestions({ useImperativeHandle( ref, () => - new Proxy( - {}, - { - get: (_target, prop) => { - if (prop === 'focus') { - return focus; - } - if (prop === 'replaceSelectionWithText') { - return replaceSelectionWithText; - } - if (prop === 'getCurrentText') { - return getCurrentText; - } - if (prop === 'clearWorklet') { - return clearWorklet; - } - if (prop === 'resetHeight') { - return resetHeight; - } - - return composerRef.current?.[prop as keyof ComposerRef]; - }, - }, - ) as ComposerWithSuggestionsRef, + ({ + ...composerRef.current, + focus, + replaceSelectionWithText, + getCurrentText, + clearWorklet, + resetHeight, + }) as unknown as ComposerWithSuggestionsRef, + [focus, replaceSelectionWithText, clearWorklet, resetHeight, getCurrentText], ); useEffect(() => { From 7b87df45f8730cc02c093942762253871900a90a Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 16 Feb 2026 22:53:08 +0000 Subject: [PATCH 043/233] feat: also add `DraftMessageVideoAttributeCache` for `ReportActionCompose` --- src/libs/DraftMessageVideoAttributeCache.ts | 4 -- .../ComposerWithSuggestions.tsx | 15 ++++-- .../ReportActionCompose/useEditMessage.ts | 2 +- .../useDraftMessageVideoAttributeCache.ts | 53 +++++++++++++++++++ 4 files changed, 66 insertions(+), 8 deletions(-) delete mode 100644 src/libs/DraftMessageVideoAttributeCache.ts create mode 100644 src/pages/inbox/report/useDraftMessageVideoAttributeCache.ts diff --git a/src/libs/DraftMessageVideoAttributeCache.ts b/src/libs/DraftMessageVideoAttributeCache.ts deleted file mode 100644 index 6c50113d7282..000000000000 --- a/src/libs/DraftMessageVideoAttributeCache.ts +++ /dev/null @@ -1,4 +0,0 @@ -// video source -> video attributes -const draftMessageVideoAttributeCache = new Map(); - -export default draftMessageVideoAttributeCache; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index d763e7be1e32..5eef48c4fa51 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -57,6 +57,7 @@ import type {FileObject} from '@src/types/utils/Attachment'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; // eslint-disable-next-line no-restricted-imports import findNodeHandle from '@src/utils/findNodeHandle'; +import useDraftMessageVideoAttributeCache from '@pages/inbox/report/useDraftMessageVideoAttributeCache'; type SyncSelection = { position: number; @@ -271,6 +272,17 @@ function ComposerWithSuggestions({ return initialValue; }); + // The ref to check whether the comment saving is in progress + const isDraftPendingSaved = useRef(false); + + useDraftMessageVideoAttributeCache({ + draftMessage: value, + isEditing: !!editingReportActionID, + editingReportAction, + updateDraftMessage: setValue, + isEditInProgressRef: isDraftPendingSaved, + }); + const [selection, setSelection] = useState(() => currentEditMessageSelection ?? {start: value.length, end: value.length, positionX: 0, positionY: 0}); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); @@ -348,9 +360,6 @@ function ComposerWithSuggestions({ RNTextInputReset.resetKeyboardInput(CONST.COMPOSER.NATIVE_ID); }, []); - // The ref to check whether the comment saving is in progress - const isDraftPendingSaved = useRef(false); - /** * Save the draft of the comment. This debounced so that we're not ceaselessly saving your edit. Saving the draft * allows one to navigate somewhere else and come back to the comment and still have it in edit mode. diff --git a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts index 53fad0d46fdf..4b3ea4bc59ca 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts @@ -11,7 +11,6 @@ import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportScrollManager from '@hooks/useReportScrollManager'; import {isActive as isEmojiPickerActive} from '@libs/actions/EmojiPickerAction'; import {deleteReportActionDraft, editReportComment} from '@libs/actions/Report'; -import draftMessageVideoAttributeCache from '@libs/DraftMessageVideoAttributeCache'; import focusEditAfterCancelDelete from '@libs/focusEditAfterCancelDelete'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import {getOriginalReportID} from '@libs/ReportUtils'; @@ -20,6 +19,7 @@ import {useReportActionActiveEdit} from '@pages/inbox/report/ReportActionEditMes import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import KeyboardUtils from '@src/utils/keyboard'; +import {draftMessageVideoAttributeCache} from '@pages/inbox/report/useDraftMessageVideoAttributeCache'; type UseEditMessageProps = { reportID: string | undefined; diff --git a/src/pages/inbox/report/useDraftMessageVideoAttributeCache.ts b/src/pages/inbox/report/useDraftMessageVideoAttributeCache.ts new file mode 100644 index 000000000000..95c19ee15530 --- /dev/null +++ b/src/pages/inbox/report/useDraftMessageVideoAttributeCache.ts @@ -0,0 +1,53 @@ +import Parser from '@libs/Parser'; +import {getReportActionHtml, isDeletedAction} from '@libs/ReportActionsUtils'; +import type React from 'react'; +import {useEffect} from 'react'; +import type * as OnyxTypes from '@src/types/onyx'; +import usePrevious from '@hooks/usePrevious'; + +type DraftMessageVideoAttributeCache = Map; + +const draftMessageVideoAttributeCache: DraftMessageVideoAttributeCache = new Map(); +type UseDraftMessageVideoAttributeCacheProps = { + draftMessage: string; + isEditing: boolean; + editingReportAction: OnyxTypes.ReportAction | null; + updateDraftMessage: (draftMessage: string) => void; + isEditInProgressRef: React.RefObject; +}; + +function useDraftMessageVideoAttributeCache({ + draftMessage, + isEditing = false, + editingReportAction = null, + updateDraftMessage: updateDraftMessageProp = () => {}, + isEditInProgressRef, +}: UseDraftMessageVideoAttributeCacheProps): DraftMessageVideoAttributeCache { + const prevDraftMessage = usePrevious(draftMessage); + + useEffect(() => { + if (!isEditing) { + return; + } + + draftMessageVideoAttributeCache.clear(); + + const originalMessage = Parser.htmlToMarkdown(getReportActionHtml(editingReportAction), { + cacheVideoAttributes: (videoSource, attrs) => draftMessageVideoAttributeCache.set(videoSource, attrs), + }); + if ( + isDeletedAction(editingReportAction) || + !!(editingReportAction?.message && draftMessage === originalMessage) || + !!(prevDraftMessage === draftMessage || isEditInProgressRef.current) + ) { + return; + } + updateDraftMessageProp(draftMessage); + }, [draftMessage, editingReportAction, isEditInProgressRef, isEditing, prevDraftMessage, updateDraftMessageProp]); + + return draftMessageVideoAttributeCache; +} + +export default useDraftMessageVideoAttributeCache; +export {draftMessageVideoAttributeCache}; +export type {DraftMessageVideoAttributeCache}; From cba88e8ca94ac1d2d31f876655490db765cffff4 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 16 Feb 2026 22:53:28 +0000 Subject: [PATCH 044/233] Update ReportActionItemMessageEdit.tsx --- .../report/ReportActionItemMessageEdit.tsx | 34 +++++++------------ 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index 6882e82ed695..f6dfdfbd308d 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -14,7 +14,6 @@ import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTrig import useKeyboardState from '@hooks/useKeyboardState'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import usePrevious from '@hooks/usePrevious'; import useReportScrollManager from '@hooks/useReportScrollManager'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useScrollBlocker from '@hooks/useScrollBlocker'; @@ -27,14 +26,11 @@ import {saveReportActionDraft} from '@libs/actions/Report'; import {isMobileChrome} from '@libs/Browser'; import {canSkipTriggerHotkeys, insertText} from '@libs/ComposerUtils'; import DomUtils from '@libs/DomUtils'; -import draftMessageVideoAttributeCache from '@libs/DraftMessageVideoAttributeCache'; import {extractEmojis, getZWNJCursorOffset, insertZWNJBetweenDigitAndEmoji, replaceAndExtractEmojis} from '@libs/EmojiUtils'; import focusComposerWithDelay from '@libs/focusComposerWithDelay'; import type {Selection} from '@libs/focusComposerWithDelay/types'; -import Parser from '@libs/Parser'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import reportActionItemEventHandler from '@libs/ReportActionItemEventHandler'; -import {getReportActionHtml, isDeletedAction} from '@libs/ReportActionsUtils'; import setShouldShowComposeInputKeyboardAware from '@libs/setShouldShowComposeInputKeyboardAware'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -52,6 +48,7 @@ import useDebouncedCommentMaxLengthValidation from './ReportActionCompose/useDeb import useEditMessage from './ReportActionCompose/useEditMessage'; import {useReportActionActiveEdit} from './ReportActionEditMessageContext'; import shouldUseEmojiPickerSelection from './shouldUseEmojiPickerSelection'; +import useDraftMessageVideoAttributeCache from './useDraftMessageVideoAttributeCache'; type ReportActionItemMessageEditProps = { /** All the data of the action */ @@ -108,7 +105,6 @@ function ReportActionItemMessageEdit({ const {preferredLocale} = useLocalize(); const {isKeyboardShown} = useKeyboardState(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const prevDraftMessage = usePrevious(draftMessage); const suggestionsRef = useRef(null); const mobileInputScrollPosition = useRef(0); const cursorPositionValue = useSharedValue({x: 0, y: 0}); @@ -150,23 +146,18 @@ function ReportActionItemMessageEdit({ const {isScrolling, startScrollBlock, endScrollBlock} = useScrollBlocker(); const composerRef = useRef(null); - const isFocusedRef = useRef(false); const draftRef = useRef(draft); const emojiPickerSelectionRef = useRef(undefined); // The ref to check whether the comment saving is in progress const isCommentPendingSaved = useRef(false); - useEffect(() => { - draftMessageVideoAttributeCache.clear(); - - const originalMessage = Parser.htmlToMarkdown(getReportActionHtml(action), { - cacheVideoAttributes: (videoSource, attrs) => draftMessageVideoAttributeCache.set(videoSource, attrs), - }); - if (isDeletedAction(action) || !!(action.message && draftMessage === originalMessage) || !!(prevDraftMessage === draftMessage || isCommentPendingSaved.current)) { - return; - } - setDraft(draftMessage); - }, [draftMessage, action, prevDraftMessage]); + useDraftMessageVideoAttributeCache({ + draftMessage, + isEditing: true, + editingReportAction: action, + updateDraftMessage: setDraft, + isEditInProgressRef: isCommentPendingSaved, + }); useEffect(() => { composerFocusKeepFocusOn(composerRef.current as HTMLElement, isFocused, modal, onyxInputFocused); @@ -411,12 +402,11 @@ function ReportActionItemMessageEdit({ }, [draft, debouncedCommentMaxLengthValidation]); useEffect(() => { - // required for keeping last state of isFocused variable - isFocusedRef.current = isFocused; - - if (!isFocused) { - hideSuggestionMenu(); + if (isFocused) { + return; } + + hideSuggestionMenu(); }, [isFocused, hideSuggestionMenu]); return ( From 225d2b9951c7b1471ef2e2c997dad0a6be0f1aa3 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 16 Feb 2026 23:08:10 +0000 Subject: [PATCH 045/233] fix: remove unknown cast --- .../ComposerWithSuggestions/ComposerWithSuggestions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 5eef48c4fa51..4c963f378f14 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -918,7 +918,7 @@ function ComposerWithSuggestions({ getCurrentText, clearWorklet, resetHeight, - }) as unknown as ComposerWithSuggestionsRef, + }) as ComposerWithSuggestionsRef, [focus, replaceSelectionWithText, clearWorklet, resetHeight, getCurrentText], ); From 58e986a1265e079529fb751a6d0f62d5b0f2fe43 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 17 Feb 2026 09:14:47 +0000 Subject: [PATCH 046/233] refactor: remove unnecessary callback --- src/pages/inbox/report/ReportActionItemMessageEdit.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index f6dfdfbd308d..b52a4ab24714 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -271,8 +271,6 @@ function ReportActionItemMessageEdit({ composerRef, }); - const publishDraftMessage = useCallback(() => publishDraft(draft), [publishDraft, draft]); - /** * @param emoji */ @@ -335,13 +333,13 @@ function ReportActionItemMessageEdit({ } if (keyEvent.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && !keyEvent.shiftKey) { e.preventDefault(); - publishDraftMessage(); + publishDraft(draft); } else if (keyEvent.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) { e.preventDefault(); deleteDraft(); } }, - [deleteDraft, hideSuggestionMenu, isKeyboardShown, shouldUseNarrowLayout, publishDraftMessage], + [shouldUseNarrowLayout, isKeyboardShown, hideSuggestionMenu, publishDraft, draft, deleteDraft], ); const measureContainer = useCallback((callback: MeasureInWindowOnSuccessCallback) => { @@ -524,7 +522,7 @@ function ReportActionItemMessageEdit({ publishDraft(draft)} isEditing /> From 0aa900a62665bdc11efa9bd46408704ad6d3b382 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 17 Feb 2026 09:23:07 +0000 Subject: [PATCH 047/233] refactor: simplify `ReportActionsList` draftMessage retrieval --- src/pages/inbox/report/ReportActionsList.tsx | 47 +++++--------------- 1 file changed, 11 insertions(+), 36 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index e92ed7f56a8b..ba7b7db10049 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -48,7 +48,6 @@ import { canShowReportRecipientLocalTime, canUserPerformWriteAction, chatIncludesChronosWithID, - getOriginalReportID, getReportLastVisibleActionCreated, isArchivedNonExpenseReport, isCanceledTaskReport, @@ -74,6 +73,7 @@ import ListBoundaryLoader from './ListBoundaryLoader'; import ReportActionsListItemRenderer from './ReportActionsListItemRenderer'; import shouldDisplayNewMarkerOnReportAction from './shouldDisplayNewMarkerOnReportAction'; import useReportUnreadMessageScrollTracking from './useReportUnreadMessageScrollTracking'; +import {useReportActionActiveEdit} from './ReportActionEditMessageContext'; type ReportActionsListProps = { /** The report currently being looked at */ @@ -183,9 +183,8 @@ function ReportActionsList({ const isReportArchived = useReportIsArchived(report?.reportID); const [userWalletTierName] = useOnyx(ONYXKEYS.USER_WALLET, {selector: tierNameSelector, canBeMissing: false}); const [isUserValidated] = useOnyx(ONYXKEYS.ACCOUNT, {selector: isUserValidatedSelector, canBeMissing: true}); - const [draftMessage] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}`, {canBeMissing: true}); + const [allDraftMessages] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}`, {canBeMissing: true}); const [emojiReactions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}`, {canBeMissing: true}); - const [reportActionsFromOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, {canBeMissing: true}); const [userBillingFundID] = useOnyx(ONYXKEYS.NVP_BILLING_FUND_ID, {canBeMissing: true}); const [tryNewDot] = useOnyx(ONYXKEYS.NVP_TRY_NEW_DOT, {canBeMissing: true}); const isTryNewDotNVPDismissed = !!tryNewDot?.classicRedirect?.dismissed; @@ -679,33 +678,10 @@ function ReportActionsList({ return isExpenseReport(report) || isIOUReport(report) || isInvoiceReport(report); }, [parentReportAction, report, sortedVisibleReportActions]); - const activeMobileEditActionID = useMemo(() => { - if (!shouldUseNarrowLayout || !draftMessage) { - return null; - } - - for (const reportDrafts of Object.values(draftMessage)) { - if (!reportDrafts) { - continue; - } - - for (const [actionID, draft] of Object.entries(reportDrafts)) { - if (draft?.message) { - return actionID; - } - } - } - - return null; - }, [shouldUseNarrowLayout, draftMessage]); - + const {editingReportActionID, editingMessage} = useReportActionActiveEdit(); const renderItem = useCallback( ({item: reportAction, index}: ListRenderItemInfo) => { - const originalReportID = getOriginalReportID(report.reportID, reportAction, reportActionsFromOnyx); - const reportDraftMessages = draftMessage?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${originalReportID}`]; - const matchingDraftMessage = reportDraftMessages?.[reportAction.reportActionID]; - const matchingDraftMessageString = - shouldUseNarrowLayout && activeMobileEditActionID && activeMobileEditActionID !== reportAction.reportActionID ? undefined : matchingDraftMessage?.message; + const draftMessage = !!editingReportActionID && editingReportActionID === reportAction.reportActionID ? (editingMessage ?? undefined) : undefined; const actionEmojiReactions = emojiReactions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportAction.reportActionID}`]; const transactionID = isMoneyRequestAction(reportAction) && getOriginalMessage(reportAction)?.IOUTransactionID; @@ -737,9 +713,9 @@ function ReportActionsList({ userWalletTierName={userWalletTierName} isUserValidated={isUserValidated} personalDetails={personalDetailsList} - draftMessage={matchingDraftMessageString} + allDraftMessages={allDraftMessages} emojiReactions={actionEmojiReactions} - allDraftMessages={draftMessage} + draftMessage={draftMessage} allEmojiReactions={emojiReactions} isReportArchived={isReportArchived} linkedTransactionRouteError={actionLinkedTransactionRouteError} @@ -751,8 +727,10 @@ function ReportActionsList({ ); }, [ - draftMessage, + editingReportActionID, + editingMessage, emojiReactions, + transactions, allReports, policies, parentReportAction, @@ -766,18 +744,15 @@ function ReportActionsList({ unreadMarkerReportActionID, firstVisibleReportActionID, shouldUseThreadDividerLine, - transactions, userWalletTierName, isUserValidated, personalDetailsList, + allDraftMessages, + isReportArchived, userBillingFundID, isTryNewDotNVPDismissed, - isReportArchived, - activeMobileEditActionID, - shouldUseNarrowLayout, reportNameValuePairs?.origin, reportNameValuePairs?.originalID, - reportActionsFromOnyx, ], ); From 1bd7e175bedd73c14fc5a9213b59e3536e890366 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 17 Feb 2026 10:06:10 +0000 Subject: [PATCH 048/233] refactor: rename `handleSendMessage` callback --- .../report/ReportActionCompose/ReportActionCompose.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index c156d05192e3..98ddb1e11445 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -478,7 +478,7 @@ function ReportActionCompose({ // useSharedValue on web doesn't support functions, so we need to wrap it in an object. const composerRefShared = useSharedValue>({}); - const handleSendMessage = useCallback(() => { + const sendMessage = useCallback(() => { if (isSendDisabled || !debouncedCommentMaxLengthValidation.flush()) { return; } @@ -498,7 +498,7 @@ function ReportActionCompose({ clearWorklet?.(); }); }, [isSendDisabled, debouncedCommentMaxLengthValidation, isComposerFullSize, reportID, composerRefShared]); - onSubmitAction = handleSendMessage; + onSubmitAction = sendMessage; const emojiPositionValues = useMemo( () => ({ @@ -633,7 +633,7 @@ function ReportActionCompose({ onClear={submitForm} disabled={isBlockedFromConcierge || isEmojiPickerVisible()} setIsCommentEmpty={setIsCommentEmpty} - onEnterKeyPress={handleSendMessage} + onEnterKeyPress={sendMessage} shouldShowComposeInput={shouldShowComposeInput} onFocus={onFocus} onBlur={onBlur} @@ -683,7 +683,7 @@ function ReportActionCompose({ {ErrorModal} From 89395b7ae955d7308c69e628cd94942fb9a49021 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 17 Feb 2026 10:11:58 +0000 Subject: [PATCH 049/233] fix: `SendButton` disabled state --- src/pages/inbox/report/ReportActionCompose/SendButton.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/SendButton.tsx b/src/pages/inbox/report/ReportActionCompose/SendButton.tsx index ad726e3b5a18..10027addd4e3 100644 --- a/src/pages/inbox/report/ReportActionCompose/SendButton.tsx +++ b/src/pages/inbox/report/ReportActionCompose/SendButton.tsx @@ -66,8 +66,8 @@ function SendButton({isDisabled: isDisabledProp = false, isEditing = false, onSe [ styles.chatItemSubmitButton, - pressed || isDisabled ? undefined : styles.buttonSuccess, - isDisabled ? styles.cursorDisabled : undefined, + isDisabledProp || pressed || isDisabled ? undefined : styles.buttonSuccess, + isDisabledProp ? styles.cursorDisabled : undefined, ]} // Since the parent View has accessible, we need to set accessible to false here to avoid duplicate accessibility elements. // On Android when TalkBack is enabled, only the parent element should be accessible, otherwise the button will not work. From 64e4b883299bdf98ec4df5a5bc7b1abfb6023fb3 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 17 Feb 2026 10:12:43 +0000 Subject: [PATCH 050/233] fix: value resetting immediately --- .../ComposerWithSuggestions.tsx | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 4c963f378f14..5d860df1aa2b 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -290,7 +290,11 @@ function ComposerWithSuggestions({ const commentRef = useRef(value); useEffect(() => { - if (!shouldUseNarrowLayout || !editingReportActionID) { + if (!editingReportActionID) { + return; + } + + if (!shouldUseNarrowLayout) { setValue(''); return; } @@ -388,13 +392,13 @@ function ComposerWithSuggestions({ const debouncedSaveReportComment = useMemo( () => lodashDebounce((selectedReportID: string, newComment: string | null) => { - if (shouldUseNarrowLayout) { + if (editingReportActionID) { return; } saveReportDraftComment(selectedReportID, newComment); isCommentPendingSaved.current = false; }, 1000), - [shouldUseNarrowLayout], + [editingReportActionID], ); useEffect(() => { @@ -529,19 +533,14 @@ function ComposerWithSuggestions({ } commentRef.current = newCommentConverted; - if (shouldUseNarrowLayout) { - if (editingReportActionID && !didSubmitEditRef.current) { - if (shouldDebounceSaveComment) { - isDraftPendingSaved.current = true; - debouncedSaveDraft(newCommentConverted); - } else { - saveReportActionDraft(reportID, {reportActionID: editingReportActionID} as OnyxTypes.ReportAction, newCommentConverted); - } + if (!!editingReportActionID && shouldUseNarrowLayout && !didSubmitEditRef.current) { + if (shouldDebounceSaveComment) { + isDraftPendingSaved.current = true; + debouncedSaveDraft(newCommentConverted); + return; } - if (newCommentConverted) { - debouncedBroadcastUserIsTyping(reportID, currentUserAccountID); - } + saveReportActionDraft(reportID, {reportActionID: editingReportActionID} as OnyxTypes.ReportAction, newCommentConverted); return; } @@ -551,6 +550,10 @@ function ComposerWithSuggestions({ } else { saveReportDraftComment(reportID, newCommentConverted); } + + if (newCommentConverted) { + debouncedBroadcastUserIsTyping(reportID, currentUserAccountID); + } }, [ raiseIsScrollLikelyLayoutTriggered, From fa8a9f299cfd2fbcd12c84344fbcee96765d314e Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 17 Feb 2026 10:24:28 +0000 Subject: [PATCH 051/233] fix: only clear composer value when we switch from narrow to wide layout --- .../ComposerWithSuggestions.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 5d860df1aa2b..186aa4bcc683 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -289,12 +289,20 @@ function ComposerWithSuggestions({ const commentRef = useRef(value); + const wasEditingInComposerRef = useRef(shouldUseNarrowLayout); useEffect(() => { if (!editingReportActionID) { + wasEditingInComposerRef.current = shouldUseNarrowLayout; return; } - if (!shouldUseNarrowLayout) { + if (shouldUseNarrowLayout && !wasEditingInComposerRef.current) { + wasEditingInComposerRef.current = true; + } + + if (!shouldUseNarrowLayout && wasEditingInComposerRef.current) { + wasEditingInComposerRef.current = false; + // When we switch from narrow to wide layout, we need to clear the composer value setValue(''); return; } From fa328e6769f9870868ea1c1439a5673c27421102 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 17 Feb 2026 10:59:57 +0000 Subject: [PATCH 052/233] fix: clearing and resetting of composer and edit message composers --- .../ComposerWithSuggestions.tsx | 29 +++++++++++++++---- .../ReportActionCompose.tsx | 2 +- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 186aa4bcc683..eca30b4eb1eb 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -289,21 +289,39 @@ function ComposerWithSuggestions({ const commentRef = useRef(value); + const wasEditing = useRef(!!editingReportActionID); const wasEditingInComposerRef = useRef(shouldUseNarrowLayout); useEffect(() => { if (!editingReportActionID) { + if (wasEditing.current && shouldUseNarrowLayout) { + setValue(''); + } + + wasEditing.current = false; wasEditingInComposerRef.current = shouldUseNarrowLayout; return; } + if (wasEditing.current && !shouldUseNarrowLayout) { + wasEditing.current = true; + // When we switch from narrow to wide layout, we need to clear the composer value + setValue(''); + return; + } + + wasEditing.current = true; + if (shouldUseNarrowLayout && !wasEditingInComposerRef.current) { wasEditingInComposerRef.current = true; } - if (!shouldUseNarrowLayout && wasEditingInComposerRef.current) { - wasEditingInComposerRef.current = false; - // When we switch from narrow to wide layout, we need to clear the composer value - setValue(''); + if (!shouldUseNarrowLayout) { + if (wasEditingInComposerRef.current) { + wasEditingInComposerRef.current = false; + // When we switch from narrow to wide layout, we need to clear the composer value + setValue(''); + } + return; } @@ -312,8 +330,9 @@ function ComposerWithSuggestions({ emojisPresentBefore.current = extractEmojis(nextValue); setValue(nextValue); + setSelection(currentEditMessageSelection ?? {start: nextValue.length, end: nextValue.length, positionX: 0, positionY: 0}); composerRef.current?.focus(); - }, [draftComment, editingMessage, editingReportActionID, shouldUseNarrowLayout]); + }, [currentEditMessageSelection, draftComment, editingMessage, editingReportActionID, shouldUseNarrowLayout]); const {superWideRHPRouteKeys} = useWideRHPState(); // When SearchReport is stacked above another RHP, delay autofocus until after the transition completes to avoid animation jank diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 98ddb1e11445..418a4da9e891 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -641,7 +641,7 @@ function ReportActionCompose({ onValueChange={onValueChange} didHideComposerInput={didHideComposerInput} forwardedFSClass={fsClass} - key={editingReportActionID} + // key={editingReportActionID} /> {shouldDisplayDualDropZone && ( Date: Tue, 17 Feb 2026 11:11:28 +0000 Subject: [PATCH 053/233] fix: composer value states --- .../ComposerWithSuggestions.tsx | 91 ++++++++++++++----- 1 file changed, 67 insertions(+), 24 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index eca30b4eb1eb..bb00129b7cc0 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -291,48 +291,91 @@ function ComposerWithSuggestions({ const wasEditing = useRef(!!editingReportActionID); const wasEditingInComposerRef = useRef(shouldUseNarrowLayout); + + const applyComposerValue = useCallback( + (nextValue: string, selectionOverride?: TextSelection | null, shouldFocusComposer?: boolean) => { + const selectionToApply = + selectionOverride ?? ({start: nextValue.length, end: nextValue.length, positionX: 0, positionY: 0} satisfies TextSelection); + + commentRef.current = nextValue; + emojisPresentBefore.current = extractEmojis(nextValue); + + setValue(nextValue); + setSelection(selectionToApply); + + if (shouldFocusComposer) { + composerRef.current?.focus(); + } + }, + [], + ); + useEffect(() => { - if (!editingReportActionID) { - if (wasEditing.current && shouldUseNarrowLayout) { - setValue(''); + const isEditing = !!editingReportActionID; + const isNarrowLayout = shouldUseNarrowLayout; + + if (!isEditing) { + if (wasEditing.current && wasEditingInComposerRef.current) { + // Editing just ended in the composer – restore the draft comment. + const nextValue = draftComment ?? ''; + applyComposerValue(nextValue, null, false); } wasEditing.current = false; - wasEditingInComposerRef.current = shouldUseNarrowLayout; + wasEditingInComposerRef.current = isNarrowLayout; return; } - if (wasEditing.current && !shouldUseNarrowLayout) { + const shouldEditInComposer = isNarrowLayout; + const wasEditingBefore = wasEditing.current; + const wasEditingInComposerBefore = wasEditingInComposerRef.current; + + // Editing just started. + if (!wasEditingBefore) { wasEditing.current = true; - // When we switch from narrow to wide layout, we need to clear the composer value - setValue(''); + wasEditingInComposerRef.current = shouldEditInComposer; + + if (!shouldEditInComposer) { + // Wide layout – another editor handles the edit, keep composer draft as-is. + return; + } + + const nextValue = editingMessage ?? draftComment ?? ''; + applyComposerValue(nextValue, currentEditMessageSelection, true); return; } - wasEditing.current = true; - - if (shouldUseNarrowLayout && !wasEditingInComposerRef.current) { + // Editing is ongoing and layout toggled from wide to narrow. + if (shouldEditInComposer && !wasEditingInComposerBefore) { wasEditingInComposerRef.current = true; + const nextValue = editingMessage ?? draftComment ?? ''; + applyComposerValue(nextValue, currentEditMessageSelection, true); + return; } - if (!shouldUseNarrowLayout) { - if (wasEditingInComposerRef.current) { - wasEditingInComposerRef.current = false; - // When we switch from narrow to wide layout, we need to clear the composer value - setValue(''); - } - + // Editing is ongoing and layout toggled from narrow to wide. + if (!shouldEditInComposer && wasEditingInComposerBefore) { + wasEditingInComposerRef.current = false; + const nextValue = draftComment ?? ''; + applyComposerValue(nextValue, null, false); return; } - const nextValue = editingMessage ?? draftComment ?? ''; - commentRef.current = nextValue; - emojisPresentBefore.current = extractEmojis(nextValue); + // Editing is ongoing and layout did not change. + if (shouldEditInComposer && wasEditingInComposerBefore) { + const nextValue = editingMessage ?? draftComment ?? ''; - setValue(nextValue); - setSelection(currentEditMessageSelection ?? {start: nextValue.length, end: nextValue.length, positionX: 0, positionY: 0}); - composerRef.current?.focus(); - }, [currentEditMessageSelection, draftComment, editingMessage, editingReportActionID, shouldUseNarrowLayout]); + if (nextValue !== commentRef.current) { + // We switched to editing a different message while staying in narrow layout. + applyComposerValue(nextValue, currentEditMessageSelection, true); + return; + } + + if (currentEditMessageSelection) { + setSelection(currentEditMessageSelection); + } + } + }, [applyComposerValue, currentEditMessageSelection, draftComment, editingMessage, editingReportActionID, shouldUseNarrowLayout]); const {superWideRHPRouteKeys} = useWideRHPState(); // When SearchReport is stacked above another RHP, delay autofocus until after the transition completes to avoid animation jank From 3e133b17bd23805e540027eb4612561271078c95 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 17 Feb 2026 12:22:00 +0000 Subject: [PATCH 054/233] fix: focus composer after setting selection --- .../ComposerWithSuggestions/ComposerWithSuggestions.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index bb00129b7cc0..2783c3765d37 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -373,6 +373,7 @@ function ComposerWithSuggestions({ if (currentEditMessageSelection) { setSelection(currentEditMessageSelection); + composerRef.current?.focus(); } } }, [applyComposerValue, currentEditMessageSelection, draftComment, editingMessage, editingReportActionID, shouldUseNarrowLayout]); From 485c0f7564c07ee904d09feba0b11161c6662317 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 17 Feb 2026 13:24:54 +0000 Subject: [PATCH 055/233] fix: composer value states --- .../ComposerWithSuggestions.tsx | 74 +++++++++---------- 1 file changed, 34 insertions(+), 40 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 2783c3765d37..54925af370fa 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -290,12 +290,14 @@ function ComposerWithSuggestions({ const commentRef = useRef(value); const wasEditing = useRef(!!editingReportActionID); + const wasEditingInComposerRef = useRef(shouldUseNarrowLayout); + const previousEditingReportActionIDRef = useRef(editingReportActionID ?? null); const applyComposerValue = useCallback( - (nextValue: string, selectionOverride?: TextSelection | null, shouldFocusComposer?: boolean) => { - const selectionToApply = - selectionOverride ?? ({start: nextValue.length, end: nextValue.length, positionX: 0, positionY: 0} satisfies TextSelection); + (nextValue: string, isEditingInComposer?: boolean) => { + const defaultSelection = {start: nextValue.length, end: nextValue.length, positionX: 0, positionY: 0} satisfies TextSelection; + const selectionToApply = isEditingInComposer ? (currentEditMessageSelection ?? defaultSelection) : defaultSelection; commentRef.current = nextValue; emojisPresentBefore.current = extractEmojis(nextValue); @@ -303,78 +305,70 @@ function ComposerWithSuggestions({ setValue(nextValue); setSelection(selectionToApply); - if (shouldFocusComposer) { + if (isEditingInComposer) { composerRef.current?.focus(); } }, - [], + [currentEditMessageSelection], ); useEffect(() => { const isEditing = !!editingReportActionID; - const isNarrowLayout = shouldUseNarrowLayout; + const previousEditingReportActionID = previousEditingReportActionIDRef.current; + const currentEditingReportActionID = editingReportActionID ?? null; + const didChangeEditedAction = isEditing && previousEditingReportActionID && currentEditingReportActionID && previousEditingReportActionID !== currentEditingReportActionID; + + previousEditingReportActionIDRef.current = currentEditingReportActionID; if (!isEditing) { if (wasEditing.current && wasEditingInComposerRef.current) { // Editing just ended in the composer – restore the draft comment. const nextValue = draftComment ?? ''; - applyComposerValue(nextValue, null, false); + applyComposerValue(nextValue, false); } wasEditing.current = false; - wasEditingInComposerRef.current = isNarrowLayout; + wasEditingInComposerRef.current = shouldUseNarrowLayout; return; } - const shouldEditInComposer = isNarrowLayout; - const wasEditingBefore = wasEditing.current; - const wasEditingInComposerBefore = wasEditingInComposerRef.current; - // Editing just started. - if (!wasEditingBefore) { + if (!wasEditing.current) { wasEditing.current = true; - wasEditingInComposerRef.current = shouldEditInComposer; + wasEditingInComposerRef.current = shouldUseNarrowLayout; - if (!shouldEditInComposer) { + if (!shouldUseNarrowLayout) { // Wide layout – another editor handles the edit, keep composer draft as-is. return; } - const nextValue = editingMessage ?? draftComment ?? ''; - applyComposerValue(nextValue, currentEditMessageSelection, true); + // In narrow layout we always show the message being edited. + const nextValue = editingMessage ?? ''; + applyComposerValue(nextValue, true); + return; + } + + // We are already in editing mode, but the target message changed. + if (didChangeEditedAction && shouldUseNarrowLayout) { + const nextValue = editingMessage ?? ''; + applyComposerValue(nextValue, true); return; } // Editing is ongoing and layout toggled from wide to narrow. - if (shouldEditInComposer && !wasEditingInComposerBefore) { + if (shouldUseNarrowLayout && !wasEditingInComposerRef.current) { wasEditingInComposerRef.current = true; - const nextValue = editingMessage ?? draftComment ?? ''; - applyComposerValue(nextValue, currentEditMessageSelection, true); + // We just moved from wide to narrow while editing – start editing in the composer. + const nextValue = editingMessage ?? ''; + applyComposerValue(nextValue, true); return; } // Editing is ongoing and layout toggled from narrow to wide. - if (!shouldEditInComposer && wasEditingInComposerBefore) { + if (!shouldUseNarrowLayout && wasEditingInComposerRef.current) { wasEditingInComposerRef.current = false; - const nextValue = draftComment ?? ''; - applyComposerValue(nextValue, null, false); - return; - } - - // Editing is ongoing and layout did not change. - if (shouldEditInComposer && wasEditingInComposerBefore) { - const nextValue = editingMessage ?? draftComment ?? ''; - - if (nextValue !== commentRef.current) { - // We switched to editing a different message while staying in narrow layout. - applyComposerValue(nextValue, currentEditMessageSelection, true); - return; - } - - if (currentEditMessageSelection) { - setSelection(currentEditMessageSelection); - composerRef.current?.focus(); - } + const nextValue = editingMessage ?? ''; + applyComposerValue(nextValue, false); } }, [applyComposerValue, currentEditMessageSelection, draftComment, editingMessage, editingReportActionID, shouldUseNarrowLayout]); From 50dec5fdbe16359b6bee0f62a6e2760c0dcc6e50 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 17 Feb 2026 14:15:41 +0000 Subject: [PATCH 056/233] fix: send button incompatible with tests --- .../ReportActionCompose.tsx | 4 +- .../ReportActionComposeSendButton.tsx | 70 ++++++++++++++ .../report/ReportActionCompose/SendButton.tsx | 93 ------------------- .../ReportActionCompose/SendOrSaveButton.tsx | 59 ++++++++++++ .../report/ReportActionItemMessageEdit.tsx | 21 +++-- 5 files changed, 145 insertions(+), 102 deletions(-) create mode 100644 src/pages/inbox/report/ReportActionCompose/ReportActionComposeSendButton.tsx delete mode 100644 src/pages/inbox/report/ReportActionCompose/SendButton.tsx create mode 100644 src/pages/inbox/report/ReportActionCompose/SendOrSaveButton.tsx diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 418a4da9e891..db4c8aea21d4 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -76,7 +76,7 @@ import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; import ComposerWithSuggestions from './ComposerWithSuggestions'; import type {ComposerWithSuggestionsProps, ComposerWithSuggestionsRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; import MessageEditCancelButton from './MessageEditCancelButton'; -import SendButton from './SendButton'; +import ReportActionComposeSendButton from './ReportActionComposeSendButton'; import useAttachmentUploadValidation from './useAttachmentUploadValidation'; import useDebouncedCommentMaxLengthValidation from './useDebouncedCommentMaxLengthValidation'; import useEditMessage from './useEditMessage'; @@ -680,7 +680,7 @@ function ReportActionCompose({ shiftVertical={emojiShiftVertical} /> )} - void; +}; + +function ReportActionComposeSendButton({isDisabled: isDisabledProp = false, isEditing = false, onSend}: SendButtonProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to manage GestureDetector correctly + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); + const Tap = Gesture.Tap() + .enabled(!isDisabledProp) + .onEnd(() => { + onSend(); + }) + .runOnJS(true) + .withTestId(CONST.COMPOSER.SEND_BUTTON_TEST_ID); + + const accessibilityLabel = translate(isEditing ? 'common.saveChanges' : 'common.send'); + + return ( + e.preventDefault()} + > + + + + + + + ); +} + +export default memo(ReportActionComposeSendButton); diff --git a/src/pages/inbox/report/ReportActionCompose/SendButton.tsx b/src/pages/inbox/report/ReportActionCompose/SendButton.tsx deleted file mode 100644 index 10027addd4e3..000000000000 --- a/src/pages/inbox/report/ReportActionCompose/SendButton.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import React, {memo} from 'react'; -import {View} from 'react-native'; -import {Gesture, GestureDetector} from 'react-native-gesture-handler'; -import Icon from '@components/Icon'; -import * as Expensicons from '@components/Icon/Expensicons'; -import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; -import Tooltip from '@components/Tooltip'; -import useLocalize from '@hooks/useLocalize'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import CONST from '@src/CONST'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; - -type SendButtonProps = { - /** Whether the button is disabled */ - isDisabled: boolean; - - /** Whether the button is in editing mode */ - isEditing?: boolean; - - /** Handle clicking on send button */ - onSend: () => void; -}; - -function SendButton({isDisabled: isDisabledProp = false, isEditing = false, onSend}: SendButtonProps) { - const theme = useTheme(); - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const icons = useMemoizedLazyExpensifyIcons(['Checkmark']); - - // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to manage GestureDetector correctly - // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {isSmallScreenWidth} = useResponsiveLayout(); - const Tap = Gesture.Tap() - .enabled(!isDisabledProp) - .onEnd(() => { - onSend(); - }) - .runOnJS(true) - .withTestId(CONST.COMPOSER.SEND_BUTTON_TEST_ID); - - const label = translate(isEditing ? 'common.saveChanges' : 'common.send'); - const sentryLabel = isEditing ? CONST.SENTRY_LABEL.REPORT.REPORT_ACTION_ITEM_MESSAGE_EDIT_SAVE_BUTTON : CONST.SENTRY_LABEL.REPORT.SEND_BUTTON; - - return ( - e.preventDefault()} - > - - - - [ - styles.chatItemSubmitButton, - isDisabledProp || pressed || isDisabled ? undefined : styles.buttonSuccess, - isDisabledProp ? styles.cursorDisabled : undefined, - ]} - // Since the parent View has accessible, we need to set accessible to false here to avoid duplicate accessibility elements. - // On Android when TalkBack is enabled, only the parent element should be accessible, otherwise the button will not work. - accessible={false} - focusable={false} - sentryLabel={sentryLabel} - disabled={isDisabledProp} - > - {({pressed}) => ( - - )} - - - - - - ); -} - -export default memo(SendButton); diff --git a/src/pages/inbox/report/ReportActionCompose/SendOrSaveButton.tsx b/src/pages/inbox/report/ReportActionCompose/SendOrSaveButton.tsx new file mode 100644 index 000000000000..3128e73d0739 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/SendOrSaveButton.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import type {PressableWithFeedbackProps} from '@components/Pressable/PressableWithFeedback'; +import Tooltip from '@components/Tooltip'; +import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; + +type SendOrSaveButtonProps = PressableWithFeedbackProps & { + /** Whether the button is disabled */ + isDisabled: boolean; + + /** Whether the button is in editing mode */ + isEditing?: boolean; + + /** Handle clicking on send button */ + onSendOrSave?: () => void; +}; + +function SendOrSaveButton({isDisabled: isDisabledProp = false, isEditing = false, onSendOrSave, ...restProps}: SendOrSaveButtonProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Checkmark']); + const label = translate(isEditing ? 'common.saveChanges' : 'common.send'); + const sentryLabel = isEditing ? CONST.SENTRY_LABEL.REPORT.REPORT_ACTION_ITEM_MESSAGE_EDIT_SAVE_BUTTON : CONST.SENTRY_LABEL.REPORT.SEND_BUTTON; + + return ( + + [ + styles.chatItemSubmitButton, + isDisabledProp || pressed || isDisabled ? undefined : styles.buttonSuccess, + isDisabledProp ? styles.cursorDisabled : undefined, + ]} + // Since the parent View has accessible, we need to set accessible to false here to avoid duplicate accessibility elements. + // On Android when TalkBack is enabled, only the parent element should be accessible, otherwise the button will not work. + onPress={onSendOrSave} + sentryLabel={sentryLabel} + disabled={isDisabledProp} + // eslint-disable-next-line react/jsx-props-no-spreading + {...restProps} + > + {({pressed}) => ( + + )} + + + ); +} + +export default SendOrSaveButton; diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index b52a4ab24714..2449e9e26507 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -42,12 +42,12 @@ import getCursorPosition from './ReportActionCompose/getCursorPosition'; import getScrollPosition from './ReportActionCompose/getScrollPosition'; import MessageEditCancelButton from './ReportActionCompose/MessageEditCancelButton'; import type {SuggestionsRef} from './ReportActionCompose/ReportActionCompose'; -import SendButton from './ReportActionCompose/SendButton'; import Suggestions from './ReportActionCompose/Suggestions'; import useDebouncedCommentMaxLengthValidation from './ReportActionCompose/useDebouncedCommentMaxLengthValidation'; import useEditMessage from './ReportActionCompose/useEditMessage'; import {useReportActionActiveEdit} from './ReportActionEditMessageContext'; import shouldUseEmojiPickerSelection from './shouldUseEmojiPickerSelection'; +import SendOrSaveButton from './ReportActionCompose/SendOrSaveButton'; import useDraftMessageVideoAttributeCache from './useDraftMessageVideoAttributeCache'; type ReportActionItemMessageEditProps = { @@ -102,7 +102,7 @@ function ReportActionItemMessageEdit({ const StyleUtils = useStyleUtils(); const containerRef = useRef(null); const reportScrollManager = useReportScrollManager(); - const {preferredLocale} = useLocalize(); + const {translate, preferredLocale} = useLocalize(); const {isKeyboardShown} = useKeyboardState(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const suggestionsRef = useRef(null); @@ -520,11 +520,18 @@ function ReportActionItemMessageEdit({ /> - publishDraft(draft)} - isEditing - /> + + publishDraft(draft)} + accessibilityLabel={translate('common.saveChanges')} + role={CONST.ROLE.BUTTON} + hoverDimmingValue={1} + pressDimmingValue={0.2} + onMouseDown={(e) => e.preventDefault()} + /> + {isExceedingMaxLength && !!exceededMaxLength && } From 75442640f58e9b3f41fd5059ddfc5d1c5ecf57b8 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 17 Feb 2026 14:15:50 +0000 Subject: [PATCH 057/233] fix: failing tests --- .../report/ReportActionItemMessageEdit.tsx | 16 ++++--- tests/ui/ReportActionItemMessageEditTest.tsx | 48 ++++++++++++++----- 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index 2449e9e26507..9bcade3891b7 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -37,18 +37,18 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; // eslint-disable-next-line no-restricted-imports import findNodeHandle from '@src/utils/findNodeHandle'; -import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu'; import getCursorPosition from './ReportActionCompose/getCursorPosition'; import getScrollPosition from './ReportActionCompose/getScrollPosition'; -import MessageEditCancelButton from './ReportActionCompose/MessageEditCancelButton'; import type {SuggestionsRef} from './ReportActionCompose/ReportActionCompose'; import Suggestions from './ReportActionCompose/Suggestions'; +import shouldUseEmojiPickerSelection from './shouldUseEmojiPickerSelection'; import useDebouncedCommentMaxLengthValidation from './ReportActionCompose/useDebouncedCommentMaxLengthValidation'; import useEditMessage from './ReportActionCompose/useEditMessage'; -import {useReportActionActiveEdit} from './ReportActionEditMessageContext'; -import shouldUseEmojiPickerSelection from './shouldUseEmojiPickerSelection'; +import MessageEditCancelButton from './ReportActionCompose/MessageEditCancelButton'; +import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu'; import SendOrSaveButton from './ReportActionCompose/SendOrSaveButton'; import useDraftMessageVideoAttributeCache from './useDraftMessageVideoAttributeCache'; +import {useReportActionActiveEdit} from './ReportActionEditMessageContext'; type ReportActionItemMessageEditProps = { /** All the data of the action */ @@ -135,8 +135,10 @@ function ReportActionItemMessageEdit({ }, [currentEditMessageSelection, defaultSelection, draft.length, setSelection]); const [isFocused, setIsFocused] = useState(false); - - const {debouncedCommentMaxLengthValidation, exceededMaxLength, isExceedingMaxLength} = useDebouncedCommentMaxLengthValidation({reportID, isEditing: true}); + const {debouncedCommentMaxLengthValidation, isExceedingMaxLength, exceededMaxLength} = useDebouncedCommentMaxLengthValidation({ + reportID, + isEditing: true, + }); const {isScrollLayoutTriggered, raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); @@ -253,7 +255,7 @@ function ReportActionItemMessageEdit({ debouncedSaveDraft(newDraft); isCommentPendingSaved.current = true; }, - [raiseIsScrollLayoutTriggered, preferredSkinTone, preferredLocale, debouncedSaveDraft, selection?.end, setSelection], + [debouncedSaveDraft, preferredLocale, preferredSkinTone, raiseIsScrollLayoutTriggered, selection?.end, setSelection], ); useEffect(() => { diff --git a/tests/ui/ReportActionItemMessageEditTest.tsx b/tests/ui/ReportActionItemMessageEditTest.tsx index 204755c12d34..95a9c42d2ded 100644 --- a/tests/ui/ReportActionItemMessageEditTest.tsx +++ b/tests/ui/ReportActionItemMessageEditTest.tsx @@ -1,11 +1,16 @@ import type * as NativeNavigation from '@react-navigation/native'; -import {act, fireEvent, screen} from '@testing-library/react-native'; +import {act, fireEvent, render, screen} from '@testing-library/react-native'; +import React from 'react'; import Onyx from 'react-native-onyx'; +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; import {editReportComment} from '@libs/actions/Report'; +import ReportActionItemMessageEdit from '@pages/inbox/report/ReportActionItemMessageEdit'; +import type {ReportActionItemMessageEditProps} from '@pages/inbox/report/ReportActionItemMessageEdit'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; -import {pressReportActionComposeSendButton, renderReportActionItemMessageEdit} from '../utils/ReportActionComposeUtils'; +import * as LHNTestUtils from '../utils/LHNTestUtils'; import * as TestHelper from '../utils/TestHelper'; const mockEditReportComment = jest.mocked(editReportComment); @@ -41,6 +46,29 @@ jest.mock('@react-navigation/native', () => ({ TestHelper.setupGlobalFetchMock(); +const defaultReport = LHNTestUtils.getFakeReport(); +const defaultProps: ReportActionItemMessageEditProps = { + action: LHNTestUtils.getFakeReportAction(), + draftMessage: '', + reportID: defaultReport.reportID, + originalReportID: defaultReport.reportID, + index: 0, + isGroupPolicyReport: false, +}; + +const renderReportActionItemMessageEdit = (props?: Partial) => { + return render( + + + , + ); +}; + describe('ReportActionCompose Integration Tests', () => { beforeAll(() => { Onyx.init({ @@ -59,16 +87,15 @@ describe('ReportActionCompose Integration Tests', () => { describe('Message validation', () => { it('should edit when length is within the limit', async () => { renderReportActionItemMessageEdit(); - const composer = screen.getByTestId(CONST.COMPOSER.NATIVE_ID); + const composer = screen.getByTestId('composer'); + const saveChangesButton = screen.getByLabelText('common.saveChanges'); // Given a message that is within the length limit const validMessage = 'x'.repeat(CONST.MAX_COMMENT_LENGTH); fireEvent.changeText(composer, validMessage); - await waitForBatchedUpdatesWithAct(); - // When the message is saved - pressReportActionComposeSendButton(); + fireEvent.press(saveChangesButton); // Then the message should be edited expect(mockEditReportComment).toHaveBeenCalledTimes(1); @@ -76,16 +103,15 @@ describe('ReportActionCompose Integration Tests', () => { it('should not edit when length exceeds the limit', async () => { renderReportActionItemMessageEdit(); - const composer = screen.getByTestId(CONST.COMPOSER.NATIVE_ID); + const composer = screen.getByTestId('composer'); + const saveChangesButton = screen.getByLabelText('common.saveChanges'); // Given a message that is over the length limit const invalidMessage = 'x'.repeat(CONST.MAX_COMMENT_LENGTH + 1); fireEvent.changeText(composer, invalidMessage); - await waitForBatchedUpdatesWithAct(); - // When the message is saved - pressReportActionComposeSendButton(); + fireEvent.press(saveChangesButton); // Then the message should NOT be edited expect(mockEditReportComment).toHaveBeenCalledTimes(0); From 43d6e9fbb48ef5be1fb3bbf36d068586ff97e7f0 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 17 Feb 2026 14:15:58 +0000 Subject: [PATCH 058/233] fix: Composer ref --- src/components/Composer/implementation/index.native.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Composer/implementation/index.native.tsx b/src/components/Composer/implementation/index.native.tsx index 722e13cab437..34e528a3c36d 100644 --- a/src/components/Composer/implementation/index.native.tsx +++ b/src/components/Composer/implementation/index.native.tsx @@ -1,4 +1,4 @@ -import type {MarkdownStyle} from '@expensify/react-native-live-markdown'; +import type {MarkdownStyle, MarkdownTextInput} from '@expensify/react-native-live-markdown'; import mimeDb from 'mime-db'; import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import type {NativeSyntheticEvent, TextInputChangeEvent, TextInputPasteEventData} from 'react-native'; @@ -36,7 +36,7 @@ function Composer({ ref, ...props }: ComposerProps) { - const textInputRef = useRef(null); + const textInputRef = useRef(null); const textContainsOnlyEmojis = useMemo(() => containsOnlyEmojis(Parser.htmlToText(Parser.replace(value ?? ''))), [value]); const theme = useTheme(); const markdownStyle = useMarkdownStyle(textContainsOnlyEmojis, !isGroupPolicyReport ? excludeReportMentionStyle : excludeNoStyles); From 6692e981613be0c725d7b513f7ceba4a73a793c4 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 17 Feb 2026 14:16:50 +0000 Subject: [PATCH 059/233] Update ReportActionComposeUtils.tsx --- tests/utils/ReportActionComposeUtils.tsx | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/tests/utils/ReportActionComposeUtils.tsx b/tests/utils/ReportActionComposeUtils.tsx index b0cb8fe24be3..d8ef98e2a04c 100644 --- a/tests/utils/ReportActionComposeUtils.tsx +++ b/tests/utils/ReportActionComposeUtils.tsx @@ -8,9 +8,6 @@ import ReportActionCompose from '@pages/inbox/report/ReportActionCompose/ReportA import {ReportActionEditMessageContextProvider} from '@pages/inbox/report/ReportActionEditMessageContext'; import type {ReportActionItemMessageEditProps} from '@pages/inbox/report/ReportActionItemMessageEdit'; import ReportActionItemMessageEdit from '@pages/inbox/report/ReportActionItemMessageEdit'; -import {fireGestureHandler, getByGestureTestId} from 'react-native-gesture-handler/jest-utils'; -import CONST from '@src/CONST'; -import {State} from 'react-native-gesture-handler'; import * as LHNTestUtils from './LHNTestUtils'; const defaultReport = LHNTestUtils.getFakeReport(); @@ -87,15 +84,4 @@ const renderReportActionMessageEditComponents = ( ); }; -function pressReportActionComposeSendButton() { - const gesture = getByGestureTestId(CONST.COMPOSER.SEND_BUTTON_TEST_ID); - - fireGestureHandler(gesture, [ - {oldState: State.UNDETERMINED, state: State.BEGAN}, - {oldState: State.BEGAN, state: State.ACTIVE}, - {oldState: State.ACTIVE, state: State.ACTIVE}, - {oldState: State.ACTIVE, state: State.END}, - ]); -} - -export {renderReportActionCompose, renderReportActionItemMessageEdit, renderReportActionMessageEditComponents, pressReportActionComposeSendButton}; +export {renderReportActionCompose, renderReportActionItemMessageEdit, renderReportActionMessageEditComponents}; From 7942284d61b59a47167380128a9001251b2f3b74 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 11 Feb 2026 16:01:20 +0000 Subject: [PATCH 060/233] revert: unrelated change --- src/pages/inbox/report/ReportActionsList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index ba7b7db10049..4a49f23c46fd 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -186,7 +186,7 @@ function ReportActionsList({ const [allDraftMessages] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}`, {canBeMissing: true}); const [emojiReactions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}`, {canBeMissing: true}); const [userBillingFundID] = useOnyx(ONYXKEYS.NVP_BILLING_FUND_ID, {canBeMissing: true}); - const [tryNewDot] = useOnyx(ONYXKEYS.NVP_TRY_NEW_DOT, {canBeMissing: true}); + const [tryNewDot] = useOnyx(ONYXKEYS.NVP_TRY_NEW_DOT, {canBeMissing: false}); const isTryNewDotNVPDismissed = !!tryNewDot?.classicRedirect?.dismissed; const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {canBeMissing: true}); const [isScrollToBottomEnabled, setIsScrollToBottomEnabled] = useState(false); From 1dd03ba58dd82963f8d7e289acd1de843e53ff61 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 17 Feb 2026 17:15:44 +0000 Subject: [PATCH 061/233] fix: extract `ReportActionComposeWrapper` to utils --- .../ReportActionCompose.perf-test.tsx | 19 +------------------ tests/utils/ReportActionComposeUtils.tsx | 16 +++++++++++----- 2 files changed, 12 insertions(+), 23 deletions(-) diff --git a/tests/perf-test/ReportActionCompose.perf-test.tsx b/tests/perf-test/ReportActionCompose.perf-test.tsx index 4aa022dfa749..f439c4cecb01 100644 --- a/tests/perf-test/ReportActionCompose.perf-test.tsx +++ b/tests/perf-test/ReportActionCompose.perf-test.tsx @@ -3,15 +3,10 @@ import React from 'react'; import Onyx from 'react-native-onyx'; import type Animated from 'react-native-reanimated'; import {measureRenders} from 'reassure'; -import OnyxListItemProvider from '@components/OnyxListItemProvider'; import type {EmojiPickerRef} from '@libs/actions/EmojiPickerAction'; import type Navigation from '@libs/Navigation/Navigation'; -import ReportActionCompose from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; -import ComposeProviders from '@src/components/ComposeProviders'; -import {LocaleContextProvider} from '@src/components/LocaleContextProvider'; -import {KeyboardStateProvider} from '@src/components/withKeyboardState'; import ONYXKEYS from '@src/ONYXKEYS'; -import * as LHNTestUtils from '../utils/LHNTestUtils'; +import {ReportActionComposeWrapper} from '../utils/ReportActionComposeUtils'; import {translateLocal} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; @@ -79,18 +74,6 @@ beforeEach(() => { Onyx.merge(ONYXKEYS.NETWORK, {isOffline: false}); }); -function ReportActionComposeWrapper() { - return ( - - jest.fn()} - reportID="1" - report={LHNTestUtils.getFakeReport()} - isComposerFullSize - /> - - ); -} const mockEvent = {preventDefault: jest.fn()}; test('[ReportActionCompose] should render Composer with text input interactions', async () => { diff --git a/tests/utils/ReportActionComposeUtils.tsx b/tests/utils/ReportActionComposeUtils.tsx index d8ef98e2a04c..763e84b1e5db 100644 --- a/tests/utils/ReportActionComposeUtils.tsx +++ b/tests/utils/ReportActionComposeUtils.tsx @@ -8,6 +8,7 @@ import ReportActionCompose from '@pages/inbox/report/ReportActionCompose/ReportA import {ReportActionEditMessageContextProvider} from '@pages/inbox/report/ReportActionEditMessageContext'; import type {ReportActionItemMessageEditProps} from '@pages/inbox/report/ReportActionItemMessageEdit'; import ReportActionItemMessageEdit from '@pages/inbox/report/ReportActionItemMessageEdit'; +import {KeyboardStateProvider} from '@src/components/withKeyboardState'; import * as LHNTestUtils from './LHNTestUtils'; const defaultReport = LHNTestUtils.getFakeReport(); @@ -17,7 +18,7 @@ function ReportActionEditMessageContextProviderForReport({children}: PropsWithCh } function ReportScreenProviders({children}: PropsWithChildren) { - return {children}; + return {children}; } const defaultReportActionComposeProps: ReportActionComposeProps = { @@ -27,8 +28,8 @@ const defaultReportActionComposeProps: ReportActionComposeProps = { report: defaultReport, }; -const renderReportActionCompose = (props?: Partial) => { - return render( +function ReportActionComposeWrapper(props?: Partial) { + return ( ) => // eslint-disable-next-line react/jsx-props-no-spreading {...props} /> - , + ); +} + +const renderReportActionCompose = (props?: Partial) => { + // eslint-disable-next-line react/jsx-props-no-spreading + return render(); }; const defaultReportActionItemMessageEditProps: ReportActionItemMessageEditProps = { @@ -84,4 +90,4 @@ const renderReportActionMessageEditComponents = ( ); }; -export {renderReportActionCompose, renderReportActionItemMessageEdit, renderReportActionMessageEditComponents}; +export {ReportActionComposeWrapper, renderReportActionCompose, renderReportActionItemMessageEdit, renderReportActionMessageEditComponents}; From 13e4d6fd4ca4793aacf77b3532b72532091fd655 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 17 Feb 2026 18:04:56 +0000 Subject: [PATCH 062/233] fix: extract composer handle --- .../Composer/implementation/index.native.tsx | 11 ++++--- .../Composer/implementation/index.tsx | 5 ++++ src/components/Composer/useComposerHandle.ts | 29 +++++++++++++++++++ 3 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 src/components/Composer/useComposerHandle.ts diff --git a/src/components/Composer/implementation/index.native.tsx b/src/components/Composer/implementation/index.native.tsx index 34e528a3c36d..5af4a929a2e9 100644 --- a/src/components/Composer/implementation/index.native.tsx +++ b/src/components/Composer/implementation/index.native.tsx @@ -1,6 +1,6 @@ -import type {MarkdownStyle, MarkdownTextInput} from '@expensify/react-native-live-markdown'; +import type {MarkdownStyle} from '@expensify/react-native-live-markdown'; import mimeDb from 'mime-db'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; +import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef} from 'react'; import type {NativeSyntheticEvent, TextInputChangeEvent, TextInputPasteEventData} from 'react-native'; import {StyleSheet} from 'react-native'; import type {ComposerProps, ComposerRef} from '@components/Composer/types'; @@ -16,6 +16,7 @@ import Parser from '@libs/Parser'; import getFileSize from '@pages/Share/getFileSize'; import CONST from '@src/CONST'; import type {FileObject} from '@src/types/utils/Attachment'; +import useComposerHandle, {getComposerHandle} from '@components/Composer/useComposerHandle'; const excludeNoStyles: Array = []; const excludeReportMentionStyle: Array = ['mentionReport']; @@ -36,7 +37,7 @@ function Composer({ ref, ...props }: ComposerProps) { - const textInputRef = useRef(null); + const textInputRef = useRef(null); const textContainsOnlyEmojis = useMemo(() => containsOnlyEmojis(Parser.htmlToText(Parser.replace(value ?? ''))), [value]); const theme = useTheme(); const markdownStyle = useMarkdownStyle(textContainsOnlyEmojis, !isGroupPolicyReport ? excludeReportMentionStyle : excludeNoStyles); @@ -76,7 +77,7 @@ function Composer({ // get a ref to the inner textInput element e.g. if we do // this.textInput = el} /> this will not // return a ref to the component, but rather the HTML element by default - ref(textInputRef.current as ComposerRef); + ref(getComposerHandle(textInputRef.current, {})); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -110,6 +111,8 @@ function Composer({ const maxHeightStyle = useMemo(() => StyleUtils.getComposerMaxHeightStyle(maxLines, isComposerFullSize), [StyleUtils, isComposerFullSize, maxLines]); const composerStyle = useMemo(() => StyleSheet.flatten([style, textContainsOnlyEmojis ? styles.onlyEmojisTextLineHeight : {}]), [style, textContainsOnlyEmojis, styles]); + useComposerHandle(ref, textInputRef, {}); + return ( = []; const excludeReportMentionStyle: Array = ['mentionReport']; @@ -304,6 +305,10 @@ function Composer({ } as ComposerRef; }, [clear]); + useComposerHandle(ref, textInputRef, { + clear, + }); + const handleKeyPress = useCallback( (e: TextInputKeyPressEvent) => { // Prevent onKeyPress from being triggered if the Enter key is pressed while text is being composed diff --git a/src/components/Composer/useComposerHandle.ts b/src/components/Composer/useComposerHandle.ts new file mode 100644 index 000000000000..193f17d9eaee --- /dev/null +++ b/src/components/Composer/useComposerHandle.ts @@ -0,0 +1,29 @@ +import {useImperativeHandle} from 'react'; +import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; +import type {ComposerRef} from './types'; + +function useComposerHandle(ref: React.Ref | undefined, textInputRef: React.RefObject, additionalMethods: Partial) { + useImperativeHandle(ref, () => { + const textInput = textInputRef.current; + if (!textInput) { + throw new Error('textInput is not available. This should never happen and indicates a developer error.'); + } + + return getComposerHandle(textInput, additionalMethods); + }, [additionalMethods, textInputRef]); +} + +function getComposerHandle(textInput: AnimatedMarkdownTextInputRef | null, additionalMethods: Partial) { + return { + ...textInput, + blur: () => textInput?.blur(), + focus: () => textInput?.focus(), + get scrollTop() { + return textInput?.scrollTop; + }, + ...additionalMethods, + } as ComposerRef; +} + +export default useComposerHandle; +export {getComposerHandle}; From f56af3e1eb9e0490cddce29ef253c803193e374a Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 17 Feb 2026 18:28:08 +0000 Subject: [PATCH 063/233] fix: composer ref --- .../Composer/implementation/index.native.tsx | 11 +++---- .../Composer/implementation/index.tsx | 5 --- src/components/Composer/useComposerHandle.ts | 29 ---------------- .../ComposerWithSuggestions.tsx | 33 ++++++++++++++----- 4 files changed, 28 insertions(+), 50 deletions(-) delete mode 100644 src/components/Composer/useComposerHandle.ts diff --git a/src/components/Composer/implementation/index.native.tsx b/src/components/Composer/implementation/index.native.tsx index 5af4a929a2e9..34e528a3c36d 100644 --- a/src/components/Composer/implementation/index.native.tsx +++ b/src/components/Composer/implementation/index.native.tsx @@ -1,6 +1,6 @@ -import type {MarkdownStyle} from '@expensify/react-native-live-markdown'; +import type {MarkdownStyle, MarkdownTextInput} from '@expensify/react-native-live-markdown'; import mimeDb from 'mime-db'; -import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import type {NativeSyntheticEvent, TextInputChangeEvent, TextInputPasteEventData} from 'react-native'; import {StyleSheet} from 'react-native'; import type {ComposerProps, ComposerRef} from '@components/Composer/types'; @@ -16,7 +16,6 @@ import Parser from '@libs/Parser'; import getFileSize from '@pages/Share/getFileSize'; import CONST from '@src/CONST'; import type {FileObject} from '@src/types/utils/Attachment'; -import useComposerHandle, {getComposerHandle} from '@components/Composer/useComposerHandle'; const excludeNoStyles: Array = []; const excludeReportMentionStyle: Array = ['mentionReport']; @@ -37,7 +36,7 @@ function Composer({ ref, ...props }: ComposerProps) { - const textInputRef = useRef(null); + const textInputRef = useRef(null); const textContainsOnlyEmojis = useMemo(() => containsOnlyEmojis(Parser.htmlToText(Parser.replace(value ?? ''))), [value]); const theme = useTheme(); const markdownStyle = useMarkdownStyle(textContainsOnlyEmojis, !isGroupPolicyReport ? excludeReportMentionStyle : excludeNoStyles); @@ -77,7 +76,7 @@ function Composer({ // get a ref to the inner textInput element e.g. if we do // this.textInput = el} /> this will not // return a ref to the component, but rather the HTML element by default - ref(getComposerHandle(textInputRef.current, {})); + ref(textInputRef.current as ComposerRef); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -111,8 +110,6 @@ function Composer({ const maxHeightStyle = useMemo(() => StyleUtils.getComposerMaxHeightStyle(maxLines, isComposerFullSize), [StyleUtils, isComposerFullSize, maxLines]); const composerStyle = useMemo(() => StyleSheet.flatten([style, textContainsOnlyEmojis ? styles.onlyEmojisTextLineHeight : {}]), [style, textContainsOnlyEmojis, styles]); - useComposerHandle(ref, textInputRef, {}); - return ( = []; const excludeReportMentionStyle: Array = ['mentionReport']; @@ -305,10 +304,6 @@ function Composer({ } as ComposerRef; }, [clear]); - useComposerHandle(ref, textInputRef, { - clear, - }); - const handleKeyPress = useCallback( (e: TextInputKeyPressEvent) => { // Prevent onKeyPress from being triggered if the Enter key is pressed while text is being composed diff --git a/src/components/Composer/useComposerHandle.ts b/src/components/Composer/useComposerHandle.ts deleted file mode 100644 index 193f17d9eaee..000000000000 --- a/src/components/Composer/useComposerHandle.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {useImperativeHandle} from 'react'; -import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; -import type {ComposerRef} from './types'; - -function useComposerHandle(ref: React.Ref | undefined, textInputRef: React.RefObject, additionalMethods: Partial) { - useImperativeHandle(ref, () => { - const textInput = textInputRef.current; - if (!textInput) { - throw new Error('textInput is not available. This should never happen and indicates a developer error.'); - } - - return getComposerHandle(textInput, additionalMethods); - }, [additionalMethods, textInputRef]); -} - -function getComposerHandle(textInput: AnimatedMarkdownTextInputRef | null, additionalMethods: Partial) { - return { - ...textInput, - blur: () => textInput?.blur(), - focus: () => textInput?.focus(), - get scrollTop() { - return textInput?.scrollTop; - }, - ...additionalMethods, - } as ComposerRef; -} - -export default useComposerHandle; -export {getComposerHandle}; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 54925af370fa..d6a24261af77 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -979,15 +979,30 @@ function ComposerWithSuggestions({ useImperativeHandle( ref, () => - ({ - ...composerRef.current, - focus, - replaceSelectionWithText, - getCurrentText, - clearWorklet, - resetHeight, - }) as ComposerWithSuggestionsRef, - [focus, replaceSelectionWithText, clearWorklet, resetHeight, getCurrentText], + new Proxy( + {}, + { + get: (_target, prop) => { + if (prop === 'focus') { + return focus; + } + if (prop === 'replaceSelectionWithText') { + return replaceSelectionWithText; + } + if (prop === 'getCurrentText') { + return getCurrentText; + } + if (prop === 'clearWorklet') { + return clearWorklet; + } + if (prop === 'resetHeight') { + return resetHeight; + } + + return composerRef.current?.[prop as keyof ComposerRef]; + }, + }, + ) as ComposerWithSuggestionsRef, ); useEffect(() => { From ba352b5f25c263e2d786387b581341fbefab5a58 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 24 Feb 2026 10:44:51 +0000 Subject: [PATCH 064/233] fix: remove `useOnyx` `canBeMissing` flag --- src/pages/inbox/report/ReportActionCompose/useEditMessage.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts index 4b3ea4bc59ca..dc0aa022f982 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts @@ -39,9 +39,9 @@ function useEditMessage({reportID, originalReportID, reportAction, shouldScrollT const isFocusedRef = useRef(isFocused); const {email} = useCurrentUserPersonalDetails(); - const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${originalReportID}`, {canBeMissing: true}); + const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${originalReportID}`); const originalParentReportID = getOriginalReportID(originalReportID, reportAction, reportActions); - const [originalReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${originalReportID}`, {canBeMissing: true}); + const [originalReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${originalReportID}`); const isOriginalReportArchived = useReportIsArchived(originalReportID); const isOriginalParentReportArchived = useReportIsArchived(originalParentReportID); const ancestors = useAncestors(originalReport); From 32d196b35669f75d708184bdc25dc8eb343d9f9f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 25 Feb 2026 12:48:25 +0000 Subject: [PATCH 065/233] refactor: remove unused gesture detector test id --- src/CONST/index.ts | 1 - .../ReportActionCompose/ReportActionComposeSendButton.tsx | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 45001dd83229..06350bea2d00 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1681,7 +1681,6 @@ const CONST = { MAX_LINES_FULL: -1, // The minimum height needed to enable the full screen composer FULL_COMPOSER_MIN_HEIGHT: 60, - SEND_BUTTON_TEST_ID: 'send-button', }, MODAL: { MODAL_TYPE: { diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionComposeSendButton.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionComposeSendButton.tsx index f25096e1f8e6..e3b07a1793c7 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionComposeSendButton.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionComposeSendButton.tsx @@ -30,8 +30,7 @@ function ReportActionComposeSendButton({isDisabled: isDisabledProp = false, isEd .onEnd(() => { onSend(); }) - .runOnJS(true) - .withTestId(CONST.COMPOSER.SEND_BUTTON_TEST_ID); + .runOnJS(true); const accessibilityLabel = translate(isEditing ? 'common.saveChanges' : 'common.send'); From 8ae2a531f3f38552f5a264a816ea4d5a167bf159 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 25 Feb 2026 13:00:49 +0000 Subject: [PATCH 066/233] fix: duplicate click event --- .../report/ReportActionCompose/ReportActionComposeSendButton.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionComposeSendButton.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionComposeSendButton.tsx index e3b07a1793c7..93061a49aab0 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionComposeSendButton.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionComposeSendButton.tsx @@ -56,7 +56,6 @@ function ReportActionComposeSendButton({isDisabled: isDisabledProp = false, isEd From 21f3597e83186df9631acfb6942a80b872540b12 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 25 Feb 2026 13:01:30 +0000 Subject: [PATCH 067/233] fix: don't update `isCommentEmpty` based on editing message --- .../inbox/report/ReportActionCompose/ReportActionCompose.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 3d6c8163dd74..f340e3935c7d 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -453,11 +453,6 @@ function ReportActionCompose({ onComposerFocus?.(); }, [onComposerFocus]); - useEffect(() => { - const valueToCheck = shouldUseNarrowLayout ? editingMessage : draftComment; - setIsCommentEmpty(!valueToCheck || !!valueToCheck.match(CONST.REGEX.EMPTY_COMMENT)); - }, [editingMessage, draftComment, shouldUseNarrowLayout]); - // We are returning a callback here as we want to invoke the method on unmount only useEffect( () => () => { From b939f9c798338cbe0da5a2b00ae6ff4b9a85ad8a Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 25 Feb 2026 13:01:44 +0000 Subject: [PATCH 068/233] refactor: simplify condition --- .../inbox/report/ReportActionCompose/ReportActionCompose.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index f340e3935c7d..8787f0edf191 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -473,8 +473,7 @@ function ReportActionCompose({ const hasReportRecipient = !isEmptyObject(reportRecipient); - const isNewCommentEmpty = isCommentEmpty && !isEditingInComposer; - const isSendDisabled = !isEditingInComposer && (isBlockedFromConcierge || isExceedingMaxLength || isNewCommentEmpty); + const isSendDisabled = !isEditingInComposer && (isBlockedFromConcierge || isExceedingMaxLength || isCommentEmpty); // Note: using JS refs is not well supported in reanimated, thus we need to store the function in a shared value // useSharedValue on web doesn't support functions, so we need to wrap it in an object. From 7a1bba6ac91b8bf847354ad52163a6cf72f0166c Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 25 Feb 2026 13:03:45 +0000 Subject: [PATCH 069/233] fix: don't re-mount composer based on editing state --- .../inbox/report/ReportActionCompose/ReportActionCompose.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 8787f0edf191..28f9d9232c9c 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -642,7 +642,6 @@ function ReportActionCompose({ onValueChange={onValueChange} didHideComposerInput={didHideComposerInput} forwardedFSClass={fsClass} - // key={editingReportActionID} /> {shouldDisplayDualDropZone && ( Date: Wed, 25 Feb 2026 13:09:10 +0000 Subject: [PATCH 070/233] fix: invalid array index on object and refactor --- .../ReportActionCompose/ReportActionCompose.tsx | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 28f9d9232c9c..c4104f95bcc5 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -171,15 +171,8 @@ function ReportActionCompose({ const isEditingInComposer = shouldUseNarrowLayout && !!editingReportActionID; - const isEditingLastReportAction = useMemo(() => { - if (!reportActions) { - return false; - } - - const lastIndex = Object.keys(reportActions).length - 1; - - return editingReportActionID === reportActions[lastIndex]?.reportActionID; - }, [editingReportActionID, reportActions]); + const reportActionEntries = useMemo(() => (reportActions ? Object.entries(reportActions) : []), [reportActions]); + const isEditingLastReportAction = useMemo(() => editingReportActionID === reportActionEntries.at(-1)?.[0], [editingReportActionID, reportActionEntries]); const shouldFocusComposerOnScreenFocus = shouldFocusInputOnScreenFocus || !!draftComment; @@ -242,7 +235,7 @@ function ReportActionCompose({ const personalDetail = useCurrentUserPersonalDetails(); - const iouAction = reportActions ? Object.values(reportActions).find((action) => isMoneyRequestAction(action)) : null; + const iouAction = reportActionEntries.find(([, action]) => isMoneyRequestAction(action))?.[1]; const linkedTransactionID = iouAction && !isExpensesReport ? getLinkedTransactionID(iouAction) : undefined; const transactionID = useMemo(() => getTransactionID(report) ?? linkedTransactionID, [report, linkedTransactionID]); From dda59f73e5290d58152dc98363e40e83c0fbe02f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 25 Feb 2026 13:13:46 +0000 Subject: [PATCH 071/233] fix: Composer imports --- .../Composer/implementation/index.tsx | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/components/Composer/implementation/index.tsx b/src/components/Composer/implementation/index.tsx index 1daf65b2e916..b3953bc2401d 100755 --- a/src/components/Composer/implementation/index.tsx +++ b/src/components/Composer/implementation/index.tsx @@ -5,23 +5,23 @@ import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, use // eslint-disable-next-line no-restricted-imports import type {TextInputKeyPressEvent, TextInputSelectionChangeEvent} from 'react-native'; import {DeviceEventEmitter, StyleSheet} from 'react-native'; -import type {ComposerProps, ComposerRef} from './src/components/Composer/types'; -import {useSession} from './src/components/OnyxListItemProvider'; -import type {AnimatedMarkdownTextInputRef} from './src/components/RNMarkdownTextInput'; -import RNMarkdownTextInput from './src/components/RNMarkdownTextInput'; -import CONST from './src/CONST'; -import useHtmlPaste from './src/hooks/useHtmlPaste'; -import useIsScrollBarVisible from './src/hooks/useIsScrollBarVisible'; -import useMarkdownStyle from './src/hooks/useMarkdownStyle'; -import useStyleUtils from './src/hooks/useStyleUtils'; -import useTheme from './src/hooks/useTheme'; -import useThemeStyles from './src/hooks/useThemeStyles'; -import addEncryptedAuthTokenToURL from './src/libs/addEncryptedAuthTokenToURL'; -import {isMobileSafari, isSafari} from './src/libs/Browser'; -import {containsOnlyEmojis} from './src/libs/EmojiUtils'; -import {base64ToFile} from './src/libs/fileDownload/FileUtils'; -import isEnterWhileComposition from './src/libs/KeyboardShortcut/isEnterWhileComposition'; -import Parser from './src/libs/Parser'; +import type {ComposerProps} from '@components/Composer/types'; +import {useSession} from '@components/OnyxListItemProvider'; +import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; +import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; +import useHtmlPaste from '@hooks/useHtmlPaste'; +import useIsScrollBarVisible from '@hooks/useIsScrollBarVisible'; +import useMarkdownStyle from '@hooks/useMarkdownStyle'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; +import {isMobileSafari, isSafari} from '@libs/Browser'; +import {containsOnlyEmojis} from '@libs/EmojiUtils'; +import {base64ToFile} from '@libs/fileDownload/FileUtils'; +import isEnterWhileComposition from '@libs/KeyboardShortcut/isEnterWhileComposition'; +import Parser from '@libs/Parser'; +import CONST from '@src/CONST'; const excludeNoStyles: Array = []; const excludeReportMentionStyle: Array = ['mentionReport']; From f8815c8e3e646d620f4f59b01973bb78997416c2 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 25 Feb 2026 13:24:39 +0000 Subject: [PATCH 072/233] fix: prettier --- .../inbox/report/ReportActionCompose/SendOrSaveButton.tsx | 2 +- .../inbox/report/ReportActionCompose/useEditMessage.ts | 2 +- .../inbox/report/useDraftMessageVideoAttributeCache.ts | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/SendOrSaveButton.tsx b/src/pages/inbox/report/ReportActionCompose/SendOrSaveButton.tsx index 3128e73d0739..6ed69876731f 100644 --- a/src/pages/inbox/report/ReportActionCompose/SendOrSaveButton.tsx +++ b/src/pages/inbox/report/ReportActionCompose/SendOrSaveButton.tsx @@ -4,11 +4,11 @@ import * as Expensicons from '@components/Icon/Expensicons'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import type {PressableWithFeedbackProps} from '@components/Pressable/PressableWithFeedback'; import Tooltip from '@components/Tooltip'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; type SendOrSaveButtonProps = PressableWithFeedbackProps & { /** Whether the button is disabled */ diff --git a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts index dc0aa022f982..893fa006a818 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts @@ -16,10 +16,10 @@ import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManag import {getOriginalReportID} from '@libs/ReportUtils'; import * as ReportActionContextMenu from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; import {useReportActionActiveEdit} from '@pages/inbox/report/ReportActionEditMessageContext'; +import {draftMessageVideoAttributeCache} from '@pages/inbox/report/useDraftMessageVideoAttributeCache'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import KeyboardUtils from '@src/utils/keyboard'; -import {draftMessageVideoAttributeCache} from '@pages/inbox/report/useDraftMessageVideoAttributeCache'; type UseEditMessageProps = { reportID: string | undefined; diff --git a/src/pages/inbox/report/useDraftMessageVideoAttributeCache.ts b/src/pages/inbox/report/useDraftMessageVideoAttributeCache.ts index 95c19ee15530..8124ec8b2378 100644 --- a/src/pages/inbox/report/useDraftMessageVideoAttributeCache.ts +++ b/src/pages/inbox/report/useDraftMessageVideoAttributeCache.ts @@ -1,9 +1,9 @@ -import Parser from '@libs/Parser'; -import {getReportActionHtml, isDeletedAction} from '@libs/ReportActionsUtils'; import type React from 'react'; import {useEffect} from 'react'; -import type * as OnyxTypes from '@src/types/onyx'; import usePrevious from '@hooks/usePrevious'; +import Parser from '@libs/Parser'; +import {getReportActionHtml, isDeletedAction} from '@libs/ReportActionsUtils'; +import type * as OnyxTypes from '@src/types/onyx'; type DraftMessageVideoAttributeCache = Map; From eed0b024678256aaeb6df884843dc76c93e4911d Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 25 Feb 2026 13:39:07 +0000 Subject: [PATCH 073/233] fix: ComposerRef import --- src/components/Composer/implementation/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Composer/implementation/index.tsx b/src/components/Composer/implementation/index.tsx index b3953bc2401d..2d82c05ab3ab 100755 --- a/src/components/Composer/implementation/index.tsx +++ b/src/components/Composer/implementation/index.tsx @@ -5,7 +5,7 @@ import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, use // eslint-disable-next-line no-restricted-imports import type {TextInputKeyPressEvent, TextInputSelectionChangeEvent} from 'react-native'; import {DeviceEventEmitter, StyleSheet} from 'react-native'; -import type {ComposerProps} from '@components/Composer/types'; +import type {ComposerProps, ComposerRef} from '@components/Composer/types'; import {useSession} from '@components/OnyxListItemProvider'; import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; From 9c39ab6d0df2d1c7650cde1e38b2c7695280f0a9 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 25 Feb 2026 13:51:33 +0000 Subject: [PATCH 074/233] fix: editing main thread message --- src/pages/inbox/report/PureReportActionItem.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/inbox/report/PureReportActionItem.tsx b/src/pages/inbox/report/PureReportActionItem.tsx index 672ba805be48..856a51bd744e 100644 --- a/src/pages/inbox/report/PureReportActionItem.tsx +++ b/src/pages/inbox/report/PureReportActionItem.tsx @@ -637,7 +637,7 @@ function PureReportActionItem({ clearError(transactionID); } clearAllRelatedReportActionErrors(reportID, action, originalReportID); - }, [action, isSendingMoney, reportID, clearAllRelatedReportActionErrors, report, chatReport, clearError]); + }, [action, isSendingMoney, reportID, clearAllRelatedReportActionErrors, originalReportID, report, chatReport, clearError]); const showDismissReceiptErrorModal = useCallback(async () => { const result = await showConfirmModal({ @@ -1835,7 +1835,7 @@ function PureReportActionItem({ ?.split(',') .map((accountID) => Number(accountID)) .filter((accountID): accountID is number => typeof accountID === 'number') ?? []; - const draftMessageRightAlign = draftMessage !== undefined ? styles.chatItemReactionsDraftRight : {}; + const draftMessageRightAlign = isEditingInline ? styles.chatItemReactionsDraftRight : {}; const itemContent = ( <> From 6578581daae4f22ab163a18bdf8491f44ea4b45c Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 25 Feb 2026 13:55:21 +0000 Subject: [PATCH 075/233] fix: remove duplicate `deleteDraft` call --- .../inbox/report/ReportActionCompose/ReportActionCompose.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index c4104f95bcc5..7424aec467c7 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -359,7 +359,6 @@ function ReportActionCompose({ if (isEditingInComposer && !attachmentFileRef.current) { publishDraft(draftMessageTrimmed); - deleteDraft(); return; } @@ -407,7 +406,6 @@ function ReportActionCompose({ isEditingInComposer, isConciergeChat, publishDraft, - deleteDraft, kickoffWaitingIndicator, transactionThreadReport, report, From d9139cea012045261d4504ef6ada2a7be22001bd Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 25 Feb 2026 13:55:53 +0000 Subject: [PATCH 076/233] fix: prevent value changes when comment/draft has been submitted --- .../ComposerWithSuggestions/ComposerWithSuggestions.tsx | 6 +++++- .../inbox/report/ReportActionCompose/useEditMessage.ts | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index d7232395ff23..f1f206b99f11 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -313,6 +313,10 @@ function ComposerWithSuggestions({ ); useEffect(() => { + if (didSubmitEditRef.current) { + return; + } + const isEditing = !!editingReportActionID; const previousEditingReportActionID = previousEditingReportActionIDRef.current; const currentEditingReportActionID = editingReportActionID ?? null; @@ -370,7 +374,7 @@ function ComposerWithSuggestions({ const nextValue = editingMessage ?? ''; applyComposerValue(nextValue, false); } - }, [applyComposerValue, currentEditMessageSelection, draftComment, editingMessage, editingReportActionID, shouldUseNarrowLayout]); + }, [applyComposerValue, currentEditMessageSelection, didSubmitEditRef, draftComment, editingMessage, editingReportActionID, shouldUseNarrowLayout]); const {superWideRHPRouteKeys} = useWideRHPState(); // When SearchReport is stacked above another RHP, delay autofocus until after the transition completes to avoid animation jank diff --git a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts index 893fa006a818..cda305573d35 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts @@ -67,8 +67,6 @@ function useEditMessage({reportID, originalReportID, reportAction, shouldScrollT return; } - didSubmitEditRef.current = true; - deleteReportActionDraft(reportID, reportAction); if (isActive()) { @@ -100,6 +98,8 @@ function useEditMessage({reportID, originalReportID, reportAction, shouldScrollT return; } + didSubmitEditRef.current = true; + const trimmedNewDraft = draftMessage.trim(); // When user tries to save the empty message, it will delete it. Prompt the user to confirm deleting. From b3901c3311717edae7015edeaf66f5f0b68512a6 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 25 Feb 2026 14:04:06 +0000 Subject: [PATCH 077/233] fix: remove `canBeMissing` flag --- src/pages/inbox/report/ReportActionEditMessageContext.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pages/inbox/report/ReportActionEditMessageContext.tsx b/src/pages/inbox/report/ReportActionEditMessageContext.tsx index 9d6c07c67656..b9bd5b6a5a32 100644 --- a/src/pages/inbox/report/ReportActionEditMessageContext.tsx +++ b/src/pages/inbox/report/ReportActionEditMessageContext.tsx @@ -35,9 +35,8 @@ type ReportActionEditMessageContextProviderProps = { function ReportActionEditMessageContextProvider({reportID, children}: ReportActionEditMessageContextProviderProps) { const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { canEvict: false, - canBeMissing: true, }); - const [reportActionDrafts] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS, {canBeMissing: true}); + const [reportActionDrafts] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS); const [editingReportActionID, setEditingReportActionID] = useState(null); const [editingReportAction, setEditingReportAction] = useState(null); From 85892fa21ccfe37fee450a64e43b50225585250d Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 25 Feb 2026 14:11:06 +0000 Subject: [PATCH 078/233] fix: remove unused import --- .../ReportActionCompose/ReportActionComposeSendButton.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionComposeSendButton.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionComposeSendButton.tsx index e21bc825ef87..93061a49aab0 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionComposeSendButton.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionComposeSendButton.tsx @@ -1,7 +1,6 @@ import React, {memo} from 'react'; import {View} from 'react-native'; import {Gesture, GestureDetector} from 'react-native-gesture-handler'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -26,7 +25,6 @@ function ReportActionComposeSendButton({isDisabled: isDisabledProp = false, isEd // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to manage GestureDetector correctly // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); - const icons = useMemoizedLazyExpensifyIcons(['Send'] as const); const Tap = Gesture.Tap() .enabled(!isDisabledProp) .onEnd(() => { From 37dfe5637ca5cf4947eae6dae3c4aee922a641a4 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 10 Mar 2026 14:29:20 +0000 Subject: [PATCH 079/233] refactor: remove unnecessary nested folder --- .../{ComposerWithSuggestions => }/ComposerWithSuggestions.tsx | 0 .../ReportActionCompose/ComposerWithSuggestions/index.tsx | 3 --- 2 files changed, 3 deletions(-) rename src/pages/inbox/report/ReportActionCompose/{ComposerWithSuggestions => }/ComposerWithSuggestions.tsx (100%) delete mode 100644 src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/index.tsx diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx similarity index 100% rename from src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx rename to src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/index.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/index.tsx deleted file mode 100644 index f2aebd390ba6..000000000000 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import ComposerWithSuggestions from './ComposerWithSuggestions'; - -export default ComposerWithSuggestions; From b9c7105ebdfea8e5537d45cd4f5b6bb34d640890 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 10 Mar 2026 14:43:04 +0000 Subject: [PATCH 080/233] refactor: update `ComposerWithSuggestions` import --- .../inbox/report/ReportActionCompose/ReportActionCompose.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index a993ccca21aa..f95d8c1d19e1 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -72,7 +72,7 @@ import type {FileObject} from '@src/types/utils/Attachment'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; import ComposerWithSuggestions from './ComposerWithSuggestions'; -import type {ComposerWithSuggestionsProps, ComposerWithSuggestionsRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; +import type {ComposerWithSuggestionsProps, ComposerWithSuggestionsRef} from './ComposerWithSuggestions'; import MessageEditCancelButton from './MessageEditCancelButton'; import ReportActionComposeSendButton from './ReportActionComposeSendButton'; import useAttachmentUploadValidation from './useAttachmentUploadValidation'; From 471fa77b17c3c2217bf4faa696d62e0e78f1663d Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 10 Mar 2026 14:43:41 +0000 Subject: [PATCH 081/233] refactor: import order --- .../ReportActionCompose/ComposerWithSuggestions.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index f1f206b99f11..d9779a0e4057 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -38,11 +38,6 @@ import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManag import {isValidReportIDFromPath, shouldAutoFocusOnKeyPress} from '@libs/ReportUtils'; import updateMultilineInputRange from '@libs/updateMultilineInputRange'; import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; -import getCursorPosition from '@pages/inbox/report/ReportActionCompose/getCursorPosition'; -import getScrollPosition from '@pages/inbox/report/ReportActionCompose/getScrollPosition'; -import type {SuggestionsRef} from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; -import SilentCommentUpdater from '@pages/inbox/report/ReportActionCompose/SilentCommentUpdater'; -import Suggestions from '@pages/inbox/report/ReportActionCompose/Suggestions'; import {useReportActionActiveEdit} from '@pages/inbox/report/ReportActionEditMessageContext'; import useDraftMessageVideoAttributeCache from '@pages/inbox/report/useDraftMessageVideoAttributeCache'; import {isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; @@ -58,6 +53,11 @@ import type {FileObject} from '@src/types/utils/Attachment'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; // eslint-disable-next-line no-restricted-imports import findNodeHandle from '@src/utils/findNodeHandle'; +import getCursorPosition from './getCursorPosition'; +import getScrollPosition from './getScrollPosition'; +import type {SuggestionsRef} from './ReportActionCompose'; +import SilentCommentUpdater from './SilentCommentUpdater'; +import Suggestions from './Suggestions'; type SyncSelection = { position: number; From 400a1e1ede3098f7f564b9c5bed68ec36db1749d Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 10 Mar 2026 14:44:02 +0000 Subject: [PATCH 082/233] fix: improve editing state ref --- .../ComposerWithSuggestions.tsx | 16 +++++----- .../ReportActionCompose/useEditMessage.ts | 6 ++-- .../report/ReportActionEditMessageContext.tsx | 29 ++++++++++++++----- 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index d9779a0e4057..1460a4d6637a 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -261,7 +261,7 @@ function ComposerWithSuggestions({ const composerRef = useRef(null); - const {editingReportActionID, editingReportAction, editingMessage, currentEditMessageSelection, setCurrentEditMessageSelection, didSubmitEditRef} = useReportActionActiveEdit(); + const {editingReportActionID, editingReportAction, editingMessage, currentEditMessageSelection, setCurrentEditMessageSelection, getEditingState} = useReportActionActiveEdit(); const [value, setValue] = useState(() => { const initialValue = shouldUseNarrowLayout ? (editingMessage ?? draftComment) : draftComment; @@ -313,7 +313,7 @@ function ComposerWithSuggestions({ ); useEffect(() => { - if (didSubmitEditRef.current) { + if (getEditingState() === 'submitted') { return; } @@ -374,7 +374,7 @@ function ComposerWithSuggestions({ const nextValue = editingMessage ?? ''; applyComposerValue(nextValue, false); } - }, [applyComposerValue, currentEditMessageSelection, didSubmitEditRef, draftComment, editingMessage, editingReportActionID, shouldUseNarrowLayout]); + }, [applyComposerValue, currentEditMessageSelection, draftComment, editingMessage, editingReportActionID, getEditingState, shouldUseNarrowLayout]); const {superWideRHPRouteKeys} = useWideRHPState(); // When SearchReport is stacked above another RHP, delay autofocus until after the transition completes to avoid animation jank @@ -602,7 +602,7 @@ function ComposerWithSuggestions({ } commentRef.current = newCommentConverted; - if (!!editingReportActionID && shouldUseNarrowLayout && !didSubmitEditRef.current) { + if (!!editingReportActionID && shouldUseNarrowLayout && !getEditingState()) { if (shouldDebounceSaveComment) { isDraftPendingSaved.current = true; debouncedSaveDraft(newCommentConverted); @@ -631,17 +631,17 @@ function ComposerWithSuggestions({ findNewlyAddedChars, preferredSkinTone, preferredLocale, + editingReportActionID, shouldUseNarrowLayout, + getEditingState, suggestionsRef, setIsCommentEmpty, setCurrentEditMessageSelection, currentEditMessageSelection, - editingReportActionID, - didSubmitEditRef, - debouncedSaveDraft, reportID, - currentUserAccountID, + debouncedSaveDraft, debouncedSaveReportComment, + currentUserAccountID, ], ); diff --git a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts index 65262f917a4e..c6bd16a5e6a1 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts @@ -47,7 +47,7 @@ function useEditMessage({reportID, originalReportID, reportAction, shouldScrollT const isOriginalParentReportArchived = useReportIsArchived(originalParentReportID); const ancestors = useAncestors(originalReport); - const {didSubmitEditRef} = useReportActionActiveEdit(); + const {setEditingState} = useReportActionActiveEdit(); useEffect(() => { // required for keeping last state of isFocused variable @@ -68,6 +68,8 @@ function useEditMessage({reportID, originalReportID, reportAction, shouldScrollT return; } + setEditingState('cancelled'); + deleteReportActionDraft(reportID, reportAction); if (isActive()) { @@ -99,7 +101,7 @@ function useEditMessage({reportID, originalReportID, reportAction, shouldScrollT return; } - didSubmitEditRef.current = true; + setEditingState('submitted'); const trimmedNewDraft = draftMessage.trim(); diff --git a/src/pages/inbox/report/ReportActionEditMessageContext.tsx b/src/pages/inbox/report/ReportActionEditMessageContext.tsx index b9bd5b6a5a32..3abadc859973 100644 --- a/src/pages/inbox/report/ReportActionEditMessageContext.tsx +++ b/src/pages/inbox/report/ReportActionEditMessageContext.tsx @@ -4,7 +4,11 @@ import useOnyx from '@hooks/useOnyx'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; -const NOOP = () => {}; +function NOOP() { + return null; +} + +type EditingState = 'editing' | 'submitted' | 'cancelled'; type ReportActionActiveEdit = { editingReportActionID: string | null; @@ -15,7 +19,8 @@ type ReportActionActiveEdit = { type ReportActionEditMessageContextValue = ReportActionActiveEdit & { currentEditMessageSelection: TextSelection | null; setCurrentEditMessageSelection: (selection: TextSelection) => void; - didSubmitEditRef: React.RefObject; + getEditingState: () => EditingState | null; + setEditingState: (state: EditingState | null) => void; }; const ReportActionEditMessageContext = createContext({ @@ -24,7 +29,8 @@ const ReportActionEditMessageContext = createContext(null); const [editingMessage, setEditingMessage] = useState(null); const [currentEditMessageSelection, setCurrentEditMessageSelectionState] = useState(null); - const didSubmitEditRef = useRef(null); + const editingStateRef = useRef(null); + const getEditingState = () => { + return editingStateRef.current; + }; + const setEditingState = (state: EditingState | null) => { + editingStateRef.current = state; + }; const updateActiveEditState = useCallback( (activeEdit: ReportActionActiveEdit | null) => { @@ -75,11 +87,11 @@ function ReportActionEditMessageContextProvider({reportID, children}: ReportActi ); const reset = useCallback(() => { - if (didSubmitEditRef.current === false) { + if (editingStateRef.current === 'editing') { return; } - didSubmitEditRef.current = null; + editingStateRef.current = null; updateActiveEditState(null); setCurrentEditMessageSelection(null); }, [updateActiveEditState, setCurrentEditMessageSelection]); @@ -103,7 +115,7 @@ function ReportActionEditMessageContextProvider({reportID, children}: ReportActi const [reportActionID, draft] = reportDraftEntry; - didSubmitEditRef.current = false; + editingStateRef.current = 'editing'; updateActiveEditState({ editingReportActionID: reportActionID, editingReportAction: reportActions?.[reportActionID] ?? null, @@ -120,7 +132,8 @@ function ReportActionEditMessageContextProvider({reportID, children}: ReportActi editingMessage, currentEditMessageSelection, setCurrentEditMessageSelection, - didSubmitEditRef, + getEditingState, + setEditingState, }} > {children} From 65ee2fc82f2c04a018f5e1a13dad03bbaa9c19cb Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 10 Mar 2026 20:36:11 +0000 Subject: [PATCH 083/233] fix: inconsistent state naming in `ComposerWithSuggestions` --- .../report/ReportActionCompose/ComposerWithSuggestions.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 1460a4d6637a..c24f47bbdc2d 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -398,7 +398,7 @@ function ComposerWithSuggestions({ const valueRef = useRef(value); valueRef.current = value; - const [composerHeightAfterClear, setDefaultComposerHeight] = useState(null); + const [composerHeightAfterClear, setComposerHeightAfterClear] = useState(null); const emptyComposerHeightRef = useRef(null); const syncSelectionWithOnChangeTextRef = useRef(null); @@ -743,7 +743,7 @@ function ComposerWithSuggestions({ if (composerHeightAfterClear == null) { return; } - setDefaultComposerHeight(null); + setComposerHeightAfterClear(null); }, [composerHeightAfterClear]); const onChangeText = useCallback( @@ -911,7 +911,7 @@ function ComposerWithSuggestions({ if (!emptyComposerHeightRef.current) { return; } - setDefaultComposerHeight(emptyComposerHeightRef.current); + setComposerHeightAfterClear(emptyComposerHeightRef.current); }, []); const getCurrentText = useCallback(() => { From b450e88339f023790473dbbf6386179acabfa042 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 10 Mar 2026 20:36:32 +0000 Subject: [PATCH 084/233] fix: don't clear composer if editing and draftComment is set --- .../ReportActionCompose/ReportActionCompose.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index f95d8c1d19e1..ee2e55f5f2c1 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -171,6 +171,7 @@ function ReportActionCompose({ const {editingReportActionID, editingReportAction, editingMessage} = useReportActionActiveEdit(); const isEditingInComposer = shouldUseNarrowLayout && !!editingReportActionID; + const effectiveDraft = shouldUseNarrowLayout ? editingMessage : draftComment; const reportActionEntries = useMemo(() => (reportActions ? Object.entries(reportActions) : []), [reportActions]); const isEditingLastReportAction = useMemo(() => editingReportActionID === reportActionEntries.at(-1)?.[0], [editingReportActionID, reportActionEntries]); @@ -192,8 +193,6 @@ function ReportActionCompose({ const {isScrollLayoutTriggered, raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); - const effectiveDraft = shouldUseNarrowLayout ? editingMessage : draftComment; - const [isCommentEmpty, setIsCommentEmpty] = useState(() => { return !effectiveDraft || !!effectiveDraft.match(CONST.REGEX.EMPTY_COMMENT); }); @@ -470,11 +469,17 @@ function ReportActionCompose({ return; } - composerRef.current?.resetHeight(); if (isComposerFullSize) { setIsComposerFullSize(reportID, false); } + if (isEditingInComposer && effectiveDraft && draftComment) { + submitForm(effectiveDraft); + return; + } + + composerRef.current?.resetHeight(); + scheduleOnUI(() => { const {clearWorklet} = composerRefShared.get(); @@ -484,7 +489,7 @@ function ReportActionCompose({ clearWorklet?.(); }); - }, [isSendDisabled, debouncedCommentMaxLengthValidation, isComposerFullSize, reportID, composerRefShared]); + }, [isSendDisabled, debouncedCommentMaxLengthValidation, isComposerFullSize, isEditingInComposer, effectiveDraft, draftComment, reportID, submitForm, composerRefShared]); onSubmitAction = sendMessage; const emojiPositionValues = useMemo( From 6db8fba34af92aa7ce553bee02c4e63a81e29514 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 10 Mar 2026 21:15:55 +0000 Subject: [PATCH 085/233] fix: edit draft not being saved --- .../report/ReportActionCompose/ComposerWithSuggestions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index c24f47bbdc2d..a203ae7d5c1e 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -602,7 +602,7 @@ function ComposerWithSuggestions({ } commentRef.current = newCommentConverted; - if (!!editingReportActionID && shouldUseNarrowLayout && !getEditingState()) { + if (!!editingReportActionID && shouldUseNarrowLayout && getEditingState() !== 'submitted') { if (shouldDebounceSaveComment) { isDraftPendingSaved.current = true; debouncedSaveDraft(newCommentConverted); From 340b98aa48a9fb2e25fa994ba5fe17dda5e57b99 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 10 Mar 2026 22:07:38 +0000 Subject: [PATCH 086/233] fix: manually set the editing report action without debounce --- .../ComposerWithSuggestions.tsx | 34 +++++++++++-------- .../report/ReportActionEditMessageContext.tsx | 11 ++++-- .../report/ReportActionItemMessageEdit.tsx | 6 ++-- 3 files changed, 32 insertions(+), 19 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index a203ae7d5c1e..f7899f1ecbb8 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -261,7 +261,8 @@ function ComposerWithSuggestions({ const composerRef = useRef(null); - const {editingReportActionID, editingReportAction, editingMessage, currentEditMessageSelection, setCurrentEditMessageSelection, getEditingState} = useReportActionActiveEdit(); + const {editingReportActionID, editingReportAction, editingMessage, setEditingMessage, currentEditMessageSelection, setCurrentEditMessageSelection, getEditingState} = + useReportActionActiveEdit(); const [value, setValue] = useState(() => { const initialValue = shouldUseNarrowLayout ? (editingMessage ?? draftComment) : draftComment; @@ -602,7 +603,9 @@ function ComposerWithSuggestions({ } commentRef.current = newCommentConverted; - if (!!editingReportActionID && shouldUseNarrowLayout && getEditingState() !== 'submitted') { + const editingState = getEditingState(); + if (editingState === 'editing' && shouldUseNarrowLayout) { + setEditingMessage(newCommentConverted); if (shouldDebounceSaveComment) { isDraftPendingSaved.current = true; debouncedSaveDraft(newCommentConverted); @@ -625,23 +628,24 @@ function ComposerWithSuggestions({ } }, [ - raiseIsScrollLikelyLayoutTriggered, - selection?.start, - selection.end, + currentEditMessageSelection, + currentUserAccountID, + debouncedSaveDraft, + debouncedSaveReportComment, + editingReportActionID, findNewlyAddedChars, - preferredSkinTone, + getEditingState, preferredLocale, - editingReportActionID, + preferredSkinTone, + raiseIsScrollLikelyLayoutTriggered, + reportID, + selection.end, + selection?.start, + setCurrentEditMessageSelection, + setEditingMessage, + setIsCommentEmpty, shouldUseNarrowLayout, - getEditingState, suggestionsRef, - setIsCommentEmpty, - setCurrentEditMessageSelection, - currentEditMessageSelection, - reportID, - debouncedSaveDraft, - debouncedSaveReportComment, - currentUserAccountID, ], ); diff --git a/src/pages/inbox/report/ReportActionEditMessageContext.tsx b/src/pages/inbox/report/ReportActionEditMessageContext.tsx index 3abadc859973..eb1f73f215bf 100644 --- a/src/pages/inbox/report/ReportActionEditMessageContext.tsx +++ b/src/pages/inbox/report/ReportActionEditMessageContext.tsx @@ -17,6 +17,7 @@ type ReportActionActiveEdit = { }; type ReportActionEditMessageContextValue = ReportActionActiveEdit & { + setEditingMessage: (message: string | null) => void; currentEditMessageSelection: TextSelection | null; setCurrentEditMessageSelection: (selection: TextSelection) => void; getEditingState: () => EditingState | null; @@ -27,6 +28,7 @@ const ReportActionEditMessageContext = createContext { const reportDrafts = reportActionDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`]; @@ -106,7 +108,7 @@ function ReportActionEditMessageContextProvider({reportID, children}: ReportActi return; } - const reportDraftEntry = Object.entries(reportDrafts).find(([, draft]) => draft?.message); + const reportDraftEntry = Object.entries(reportDrafts).find(([, draft]) => draft?.message !== undefined); if (!reportDraftEntry) { reset(); @@ -115,6 +117,10 @@ function ReportActionEditMessageContextProvider({reportID, children}: ReportActi const [reportActionID, draft] = reportDraftEntry; + if (editingStateRef.current !== null) { + return; + } + editingStateRef.current = 'editing'; updateActiveEditState({ editingReportActionID: reportActionID, @@ -130,6 +136,7 @@ function ReportActionEditMessageContextProvider({reportID, children}: ReportActi editingReportActionID, editingReportAction, editingMessage, + setEditingMessage, currentEditMessageSelection, setCurrentEditMessageSelection, getEditingState, diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index 3f1ec47a7f7f..60e99366e00c 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -117,7 +117,7 @@ function ReportActionItemMessageEdit({ return draftMessage; }); - const {currentEditMessageSelection, setCurrentEditMessageSelection} = useReportActionActiveEdit(); + const {currentEditMessageSelection, setCurrentEditMessageSelection, setEditingMessage} = useReportActionActiveEdit(); const defaultSelection = useMemo(() => ({start: draft.length, end: draft.length, positionX: 0, positionY: 0}), [draft.length]); const [selection, setSelectionState] = useState(() => currentEditMessageSelection ?? defaultSelection); @@ -251,11 +251,13 @@ function ReportActionItemMessageEdit({ draftRef.current = newDraft; + setEditingMessage(newDraft); + // We want to escape the draft message to differentiate the HTML from the report action and the HTML the user drafted. debouncedSaveDraft(newDraft); isCommentPendingSaved.current = true; }, - [debouncedSaveDraft, preferredLocale, preferredSkinTone, raiseIsScrollLayoutTriggered, selection?.end, setSelection], + [debouncedSaveDraft, preferredLocale, preferredSkinTone, raiseIsScrollLayoutTriggered, selection?.end, setEditingMessage, setSelection], ); useEffect(() => { From 22a579da64cff41830df993a949a6989932096b1 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 10 Mar 2026 22:24:54 +0000 Subject: [PATCH 087/233] refactor: use editing state ref --- .../ReportActionCompose/ComposerWithSuggestions.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index f7899f1ecbb8..5d8a9ed64247 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -276,9 +276,10 @@ function ComposerWithSuggestions({ // The ref to check whether the comment saving is in progress const isDraftPendingSaved = useRef(false); + const editingState = getEditingState(); useDraftMessageVideoAttributeCache({ draftMessage: value, - isEditing: !!editingReportActionID, + isEditing: editingState === 'editing', editingReportAction, updateDraftMessage: setValue, isEditInProgressRef: isDraftPendingSaved, @@ -290,7 +291,7 @@ function ComposerWithSuggestions({ const commentRef = useRef(value); - const wasEditing = useRef(!!editingReportActionID); + const wasEditing = useRef(editingState === 'editing'); const wasEditingInComposerRef = useRef(shouldUseNarrowLayout); const previousEditingReportActionIDRef = useRef(editingReportActionID ?? null); @@ -318,7 +319,7 @@ function ComposerWithSuggestions({ return; } - const isEditing = !!editingReportActionID; + const isEditing = currentEditingState === 'editing'; const previousEditingReportActionID = previousEditingReportActionIDRef.current; const currentEditingReportActionID = editingReportActionID ?? null; const didChangeEditedAction = isEditing && previousEditingReportActionID && currentEditingReportActionID && previousEditingReportActionID !== currentEditingReportActionID; @@ -603,8 +604,8 @@ function ComposerWithSuggestions({ } commentRef.current = newCommentConverted; - const editingState = getEditingState(); - if (editingState === 'editing' && shouldUseNarrowLayout) { + const currentEditingState = getEditingState(); + if (currentEditingState === 'editing' && shouldUseNarrowLayout) { setEditingMessage(newCommentConverted); if (shouldDebounceSaveComment) { isDraftPendingSaved.current = true; From ff433aa9cc86d01b31e3528c888f4487a1757589 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 10 Mar 2026 22:26:58 +0000 Subject: [PATCH 088/233] refactor: use state instead of ref in render --- .../ComposerWithSuggestions.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 5d8a9ed64247..34be98ff9ac1 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -276,10 +276,9 @@ function ComposerWithSuggestions({ // The ref to check whether the comment saving is in progress const isDraftPendingSaved = useRef(false); - const editingState = getEditingState(); useDraftMessageVideoAttributeCache({ draftMessage: value, - isEditing: editingState === 'editing', + isEditing: !!editingReportActionID, editingReportAction, updateDraftMessage: setValue, isEditInProgressRef: isDraftPendingSaved, @@ -291,7 +290,7 @@ function ComposerWithSuggestions({ const commentRef = useRef(value); - const wasEditing = useRef(editingState === 'editing'); + const wasEditing = useRef(!!editingReportActionID); const wasEditingInComposerRef = useRef(shouldUseNarrowLayout); const previousEditingReportActionIDRef = useRef(editingReportActionID ?? null); @@ -315,11 +314,12 @@ function ComposerWithSuggestions({ ); useEffect(() => { - if (getEditingState() === 'submitted') { + const editingState = getEditingState(); + if (editingState === 'submitted') { return; } - const isEditing = currentEditingState === 'editing'; + const isEditing = editingState === 'editing'; const previousEditingReportActionID = previousEditingReportActionIDRef.current; const currentEditingReportActionID = editingReportActionID ?? null; const didChangeEditedAction = isEditing && previousEditingReportActionID && currentEditingReportActionID && previousEditingReportActionID !== currentEditingReportActionID; @@ -604,8 +604,8 @@ function ComposerWithSuggestions({ } commentRef.current = newCommentConverted; - const currentEditingState = getEditingState(); - if (currentEditingState === 'editing' && shouldUseNarrowLayout) { + const editingState = getEditingState(); + if (editingState === 'editing' && shouldUseNarrowLayout) { setEditingMessage(newCommentConverted); if (shouldDebounceSaveComment) { isDraftPendingSaved.current = true; From 6bad09be1111bbe19e05463940be3ba03672c244 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 10 Mar 2026 22:27:20 +0000 Subject: [PATCH 089/233] refactor: pass along set state action --- .../ComposerWithSuggestions.tsx | 21 ++++++++++--------- .../report/ReportActionEditMessageContext.tsx | 9 ++++---- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 34be98ff9ac1..8de45e28be72 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -376,7 +376,7 @@ function ComposerWithSuggestions({ const nextValue = editingMessage ?? ''; applyComposerValue(nextValue, false); } - }, [applyComposerValue, currentEditMessageSelection, draftComment, editingMessage, editingReportActionID, getEditingState, shouldUseNarrowLayout]); + }, [applyComposerValue, draftComment, editingMessage, editingReportActionID, getEditingState, shouldUseNarrowLayout]); const {superWideRHPRouteKeys} = useWideRHPState(); // When SearchReport is stacked above another RHP, delay autofocus until after the transition completes to avoid animation jank @@ -600,7 +600,7 @@ function ComposerWithSuggestions({ positionY: prevSelection.positionY, })); - setCurrentEditMessageSelection({...currentEditMessageSelection, start: position, end: position}); + setCurrentEditMessageSelection((prevSelection) => ({...prevSelection, start: position, end: position})); } commentRef.current = newCommentConverted; @@ -629,7 +629,6 @@ function ComposerWithSuggestions({ } }, [ - currentEditMessageSelection, currentUserAccountID, debouncedSaveDraft, debouncedSaveReportComment, @@ -719,7 +718,8 @@ function ComposerWithSuggestions({ positionY: prevSelection.positionY, })); - setCurrentEditMessageSelection({...currentEditMessageSelection, start: newStart, end: newEnd}); + setCurrentEditMessageSelection((prevSelection) => ({...prevSelection, start: newStart, end: newEnd})); + updateComment(newText, true); } } @@ -734,9 +734,8 @@ function ComposerWithSuggestions({ onEnterKeyPress, lastReportAction, reportID, - setCurrentEditMessageSelection, - currentEditMessageSelection, updateComment, + setCurrentEditMessageSelection, ], ); @@ -779,12 +778,13 @@ function ComposerWithSuggestions({ (e: CustomSelectionChangeEvent) => { setSelection(e.nativeEvent.selection); - setCurrentEditMessageSelection({ + setCurrentEditMessageSelection((prevSelection) => ({ + ...prevSelection, start: e.nativeEvent.selection.start, end: e.nativeEvent.selection.end, positionX: 0, positionY: 0, - }); + })); if (!composerRef.current?.isFocused()) { return; @@ -1102,12 +1102,13 @@ function ComposerWithSuggestions({ (suggestionSelection: TextSelection) => { const endOfSuggestionSelection = suggestionSelection.end; setSelection(suggestionSelection); - setCurrentEditMessageSelection({ + setCurrentEditMessageSelection((prevSelection) => ({ + ...prevSelection, start: suggestionSelection.start, end: suggestionSelection.end, positionX: 0, positionY: 0, - }); + })); if (endOfSuggestionSelection === undefined) { return; diff --git a/src/pages/inbox/report/ReportActionEditMessageContext.tsx b/src/pages/inbox/report/ReportActionEditMessageContext.tsx index eb1f73f215bf..7ebd71c8de1a 100644 --- a/src/pages/inbox/report/ReportActionEditMessageContext.tsx +++ b/src/pages/inbox/report/ReportActionEditMessageContext.tsx @@ -1,4 +1,5 @@ import React, {createContext, useCallback, useContext, useEffect, useRef, useState} from 'react'; +import type {Dispatch, SetStateAction} from 'react'; import type {TextSelection} from '@components/Composer/types'; import useOnyx from '@hooks/useOnyx'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -17,9 +18,9 @@ type ReportActionActiveEdit = { }; type ReportActionEditMessageContextValue = ReportActionActiveEdit & { - setEditingMessage: (message: string | null) => void; + setEditingMessage: Dispatch>; currentEditMessageSelection: TextSelection | null; - setCurrentEditMessageSelection: (selection: TextSelection) => void; + setCurrentEditMessageSelection: Dispatch>; getEditingState: () => EditingState | null; setEditingState: (state: EditingState | null) => void; }; @@ -78,12 +79,12 @@ function ReportActionEditMessageContextProvider({reportID, children}: ReportActi ); const setCurrentEditMessageSelection = useCallback( - (selection: TextSelection | null) => { + (setSelectionStateAction: SetStateAction) => { if (!editingReportActionID) { return; } - setCurrentEditMessageSelectionState(selection); + setCurrentEditMessageSelectionState(setSelectionStateAction); }, [editingReportActionID], ); From db08232ad6280643d7814b006a88490af986a1bb Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 10 Mar 2026 22:27:33 +0000 Subject: [PATCH 090/233] refactor: unnecessary check for editing state --- .../report/ReportActionCompose/ComposerWithSuggestions.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 8de45e28be72..c13e18f6bfaf 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -463,13 +463,10 @@ function ComposerWithSuggestions({ const debouncedSaveReportComment = useMemo( () => lodashDebounce((selectedReportID: string, newComment: string | null) => { - if (editingReportActionID) { - return; - } saveReportDraftComment(selectedReportID, newComment); isCommentPendingSaved.current = false; }, 1000), - [editingReportActionID], + [], ); useEffect(() => { From 1a44924db45b2f1e76f390e7030a047e06542c80 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 10 Mar 2026 22:31:29 +0000 Subject: [PATCH 091/233] fix: selection issues --- .../ReportActionCompose/ComposerWithSuggestions.tsx | 10 +++------- src/pages/inbox/report/ReportActionItemMessageEdit.tsx | 2 +- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index c13e18f6bfaf..db46cc1c36af 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -591,10 +591,9 @@ function ComposerWithSuggestions({ } setSelection((prevSelection) => ({ + ...prevSelection, start: position, end: position, - positionX: prevSelection.positionX, - positionY: prevSelection.positionY, })); setCurrentEditMessageSelection((prevSelection) => ({...prevSelection, start: position, end: position})); @@ -765,10 +764,11 @@ function ComposerWithSuggestions({ // note: this implementation is only available on non-web RN, thus the wrapping // 'if' block contains a redundant (since the ref is only used on iOS) platform check composerRef.current?.setSelection(positionSnapshot, positionSnapshot); + setCurrentEditMessageSelection((prevSelection) => ({...prevSelection, start: positionSnapshot, end: positionSnapshot})); }); } }, - [clearComposerHeight, updateComment], + [clearComposerHeight, setCurrentEditMessageSelection, updateComment], ); const onSelectionChange = useCallback( @@ -779,8 +779,6 @@ function ComposerWithSuggestions({ ...prevSelection, start: e.nativeEvent.selection.start, end: e.nativeEvent.selection.end, - positionX: 0, - positionY: 0, })); if (!composerRef.current?.isFocused()) { @@ -1103,8 +1101,6 @@ function ComposerWithSuggestions({ ...prevSelection, start: suggestionSelection.start, end: suggestionSelection.end, - positionX: 0, - positionY: 0, })); if (endOfSuggestionSelection === undefined) { diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index 60e99366e00c..962a4b3663f8 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -125,7 +125,7 @@ function ReportActionItemMessageEdit({ const setSelection = useCallback( (newSelection: TextSelection) => { setSelectionState(newSelection); - setCurrentEditMessageSelection({...newSelection, positionX: 0, positionY: 0}); + setCurrentEditMessageSelection((prevSelection) => ({...prevSelection, ...newSelection})); }, [setSelectionState, setCurrentEditMessageSelection], ); From 130be1a8f3abb2547dd5b507e454dbff9f723c70 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 10 Mar 2026 23:07:19 +0000 Subject: [PATCH 092/233] fix: reset to previous draft when switching from narrow to wide --- .../report/ReportActionCompose/ComposerWithSuggestions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index db46cc1c36af..d5c0089831da 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -373,7 +373,7 @@ function ComposerWithSuggestions({ // Editing is ongoing and layout toggled from narrow to wide. if (!shouldUseNarrowLayout && wasEditingInComposerRef.current) { wasEditingInComposerRef.current = false; - const nextValue = editingMessage ?? ''; + const nextValue = draftComment ?? ''; applyComposerValue(nextValue, false); } }, [applyComposerValue, draftComment, editingMessage, editingReportActionID, getEditingState, shouldUseNarrowLayout]); From 1e0fcec6990e0af92fee64c1ef11fbce232ca685 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 10 Mar 2026 23:07:41 +0000 Subject: [PATCH 093/233] fix: onSelectionChange crashing --- .../report/ReportActionCompose/ComposerWithSuggestions.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index d5c0089831da..277253994a92 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -773,12 +773,11 @@ function ComposerWithSuggestions({ const onSelectionChange = useCallback( (e: CustomSelectionChangeEvent) => { - setSelection(e.nativeEvent.selection); - + const newSelection = {...e.nativeEvent.selection}; + setSelection(newSelection); setCurrentEditMessageSelection((prevSelection) => ({ ...prevSelection, - start: e.nativeEvent.selection.start, - end: e.nativeEvent.selection.end, + ...newSelection, })); if (!composerRef.current?.isFocused()) { From 0bc83b9128563efbd30ffd89fee010d9f6a6b05f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 10 Mar 2026 23:07:55 +0000 Subject: [PATCH 094/233] fix: simplify `setSelection` call --- .../report/ReportActionCompose/ComposerWithSuggestions.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 277253994a92..efff0f15123e 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -708,10 +708,9 @@ function ComposerWithSuggestions({ const newEnd = selection.start - lastGraphemeLength; setSelection((prevSelection) => ({ + ...prevSelection, start: newStart, end: newEnd, - positionX: prevSelection.positionX, - positionY: prevSelection.positionY, })); setCurrentEditMessageSelection((prevSelection) => ({...prevSelection, start: newStart, end: newEnd})); From 4da51593256592567fbee3b7f2c4559fd8b797ca Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 10 Mar 2026 23:08:24 +0000 Subject: [PATCH 095/233] refactor: extract imperative composer selection callback --- .../ComposerWithSuggestions.tsx | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index efff0f15123e..3033b12d005d 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -295,6 +295,20 @@ function ComposerWithSuggestions({ const wasEditingInComposerRef = useRef(shouldUseNarrowLayout); const previousEditingReportActionIDRef = useRef(editingReportActionID ?? null); + const updateSelectionImperatively = useCallback((start: number, end: number) => { + if (!isIOSNative) { + return; + } + + // ensure that selection is set imperatively after all state changes are effective + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => { + // note: this implementation is only available on non-web RN, thus the wrapping + // 'if' block contains a redundant (since the ref is only used on iOS) platform check + composerRef.current?.setSelection(start, end); + }); + }, []); + const applyComposerValue = useCallback( (nextValue: string, isEditingInComposer?: boolean) => { const defaultSelection = {start: nextValue.length, end: nextValue.length, positionX: 0, positionY: 0} satisfies TextSelection; @@ -753,21 +767,15 @@ function ComposerWithSuggestions({ updateComment(commentValue, true); - if (isIOSNative && syncSelectionWithOnChangeTextRef.current) { - const positionSnapshot = syncSelectionWithOnChangeTextRef.current.position; - syncSelectionWithOnChangeTextRef.current = null; - - // ensure that selection is set imperatively after all state changes are effective - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => { - // note: this implementation is only available on non-web RN, thus the wrapping - // 'if' block contains a redundant (since the ref is only used on iOS) platform check - composerRef.current?.setSelection(positionSnapshot, positionSnapshot); - setCurrentEditMessageSelection((prevSelection) => ({...prevSelection, start: positionSnapshot, end: positionSnapshot})); - }); + if (!syncSelectionWithOnChangeTextRef.current) { + return; } + + const positionSnapshot = syncSelectionWithOnChangeTextRef.current.position; + syncSelectionWithOnChangeTextRef.current = null; + updateSelectionImperatively(positionSnapshot, positionSnapshot); }, - [clearComposerHeight, setCurrentEditMessageSelection, updateComment], + [clearComposerHeight, updateComment, updateSelectionImperatively], ); const onSelectionChange = useCallback( From 125e106d2718b3e14b7ab2c87134c40a26460731 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 10 Mar 2026 23:08:38 +0000 Subject: [PATCH 096/233] refactor: change default selection type --- .../report/ReportActionCompose/ComposerWithSuggestions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 3033b12d005d..86d00d4c160f 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -311,7 +311,7 @@ function ComposerWithSuggestions({ const applyComposerValue = useCallback( (nextValue: string, isEditingInComposer?: boolean) => { - const defaultSelection = {start: nextValue.length, end: nextValue.length, positionX: 0, positionY: 0} satisfies TextSelection; + const defaultSelection: TextSelection = {start: nextValue.length, end: nextValue.length}; const selectionToApply = isEditingInComposer ? (currentEditMessageSelection ?? defaultSelection) : defaultSelection; commentRef.current = nextValue; From fb0d927d76406eb4af808e35f3495708923ef41b Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 10 Mar 2026 23:08:54 +0000 Subject: [PATCH 097/233] refactor: remove hardcoded selection position value --- .../report/ReportActionCompose/ComposerWithSuggestions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 86d00d4c160f..998c35a4a5d9 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -284,7 +284,7 @@ function ComposerWithSuggestions({ isEditInProgressRef: isDraftPendingSaved, }); - const [selection, setSelection] = useState(() => currentEditMessageSelection ?? {start: value.length, end: value.length, positionX: 0, positionY: 0}); + const [selection, setSelection] = useState(() => currentEditMessageSelection ?? {start: value.length, end: value.length}); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); From bf110c0be1475bfe69bd0f78c01caf8277ca0c23 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 11 Mar 2026 11:15:40 +0000 Subject: [PATCH 098/233] fix: composer text selection on edit --- .../ComposerWithSuggestions.tsx | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 998c35a4a5d9..d4668fe225c2 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -309,22 +309,31 @@ function ComposerWithSuggestions({ }); }, []); + type ApplyComposerValueOptions = { + isEditingInComposer?: boolean; + shouldForceSelectionToEnd?: boolean; + }; + const applyComposerValue = useCallback( - (nextValue: string, isEditingInComposer?: boolean) => { + (nextValue: string, options?: ApplyComposerValueOptions) => { const defaultSelection: TextSelection = {start: nextValue.length, end: nextValue.length}; - const selectionToApply = isEditingInComposer ? (currentEditMessageSelection ?? defaultSelection) : defaultSelection; + const shouldUseEditingSelection = options?.isEditingInComposer ?? false; + const shouldForceSelectionToEnd = options?.shouldForceSelectionToEnd ?? false; + + const selectionToApply = shouldUseEditingSelection && !shouldForceSelectionToEnd ? (currentEditMessageSelection ?? defaultSelection) : defaultSelection; commentRef.current = nextValue; emojisPresentBefore.current = extractEmojis(nextValue); setValue(nextValue); setSelection(selectionToApply); + updateSelectionImperatively(selectionToApply.start, selectionToApply.end ?? selectionToApply.start); - if (isEditingInComposer) { + if (options?.isEditingInComposer) { composerRef.current?.focus(); } }, - [currentEditMessageSelection], + [currentEditMessageSelection, updateSelectionImperatively], ); useEffect(() => { @@ -344,7 +353,7 @@ function ComposerWithSuggestions({ if (wasEditing.current && wasEditingInComposerRef.current) { // Editing just ended in the composer – restore the draft comment. const nextValue = draftComment ?? ''; - applyComposerValue(nextValue, false); + applyComposerValue(nextValue); } wasEditing.current = false; @@ -361,17 +370,17 @@ function ComposerWithSuggestions({ // Wide layout – another editor handles the edit, keep composer draft as-is. return; } - // In narrow layout we always show the message being edited. const nextValue = editingMessage ?? ''; - applyComposerValue(nextValue, true); + // When starting to edit in the composer, always place the cursor at the end of the message. + applyComposerValue(nextValue, {isEditingInComposer: true, shouldForceSelectionToEnd: true}); return; } // We are already in editing mode, but the target message changed. if (didChangeEditedAction && shouldUseNarrowLayout) { const nextValue = editingMessage ?? ''; - applyComposerValue(nextValue, true); + applyComposerValue(nextValue, {isEditingInComposer: true}); return; } @@ -380,7 +389,7 @@ function ComposerWithSuggestions({ wasEditingInComposerRef.current = true; // We just moved from wide to narrow while editing – start editing in the composer. const nextValue = editingMessage ?? ''; - applyComposerValue(nextValue, true); + applyComposerValue(nextValue, {isEditingInComposer: true}); return; } @@ -388,7 +397,7 @@ function ComposerWithSuggestions({ if (!shouldUseNarrowLayout && wasEditingInComposerRef.current) { wasEditingInComposerRef.current = false; const nextValue = draftComment ?? ''; - applyComposerValue(nextValue, false); + applyComposerValue(nextValue); } }, [applyComposerValue, draftComment, editingMessage, editingReportActionID, getEditingState, shouldUseNarrowLayout]); From 246888bfc629f5464036288065cac18ec36ddc54 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 11 Mar 2026 11:25:18 +0000 Subject: [PATCH 099/233] fix: go back to previous selection if editing stops --- .../ComposerWithSuggestions.tsx | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index d4668fe225c2..7cbbd04e4e05 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -294,6 +294,7 @@ function ComposerWithSuggestions({ const wasEditingInComposerRef = useRef(shouldUseNarrowLayout); const previousEditingReportActionIDRef = useRef(editingReportActionID ?? null); + const previousDraftSelectionRef = useRef(null); const updateSelectionImperatively = useCallback((start: number, end: number) => { if (!isIOSNative) { @@ -311,16 +312,18 @@ function ComposerWithSuggestions({ type ApplyComposerValueOptions = { isEditingInComposer?: boolean; - shouldForceSelectionToEnd?: boolean; + shouldMoveSelectionToEnd?: boolean; + selection?: TextSelection | null; }; const applyComposerValue = useCallback( (nextValue: string, options?: ApplyComposerValueOptions) => { const defaultSelection: TextSelection = {start: nextValue.length, end: nextValue.length}; const shouldUseEditingSelection = options?.isEditingInComposer ?? false; - const shouldForceSelectionToEnd = options?.shouldForceSelectionToEnd ?? false; + const shouldForceSelectionToEnd = options?.shouldMoveSelectionToEnd ?? false; + const explicitSelection = options?.selection ?? null; - const selectionToApply = shouldUseEditingSelection && !shouldForceSelectionToEnd ? (currentEditMessageSelection ?? defaultSelection) : defaultSelection; + const selectionToApply = explicitSelection ?? (shouldUseEditingSelection && !shouldForceSelectionToEnd ? (currentEditMessageSelection ?? defaultSelection) : defaultSelection); commentRef.current = nextValue; emojisPresentBefore.current = extractEmojis(nextValue); @@ -351,18 +354,22 @@ function ComposerWithSuggestions({ if (!isEditing) { if (wasEditing.current && wasEditingInComposerRef.current) { - // Editing just ended in the composer – restore the draft comment. + // Editing just ended in the composer – restore the draft comment and its previous selection. const nextValue = draftComment ?? ''; - applyComposerValue(nextValue); + applyComposerValue(nextValue, {selection: previousDraftSelectionRef.current}); } wasEditing.current = false; wasEditingInComposerRef.current = shouldUseNarrowLayout; + previousDraftSelectionRef.current = null; return; } // Editing just started. if (!wasEditing.current) { + // Store the draft selection before switching into edit mode so we can restore it later. + previousDraftSelectionRef.current = selection; + wasEditing.current = true; wasEditingInComposerRef.current = shouldUseNarrowLayout; @@ -373,7 +380,7 @@ function ComposerWithSuggestions({ // In narrow layout we always show the message being edited. const nextValue = editingMessage ?? ''; // When starting to edit in the composer, always place the cursor at the end of the message. - applyComposerValue(nextValue, {isEditingInComposer: true, shouldForceSelectionToEnd: true}); + applyComposerValue(nextValue, {isEditingInComposer: true, shouldMoveSelectionToEnd: true}); return; } @@ -399,7 +406,7 @@ function ComposerWithSuggestions({ const nextValue = draftComment ?? ''; applyComposerValue(nextValue); } - }, [applyComposerValue, draftComment, editingMessage, editingReportActionID, getEditingState, shouldUseNarrowLayout]); + }, [applyComposerValue, draftComment, editingMessage, editingReportActionID, getEditingState, selection, shouldUseNarrowLayout, updateSelectionImperatively]); const {superWideRHPRouteKeys} = useWideRHPState(); // When SearchReport is stacked above another RHP, delay autofocus until after the transition completes to avoid animation jank From 589ea10e3800b0cd7ae65dd1730594236a9057fe Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 11 Mar 2026 12:00:53 +0000 Subject: [PATCH 100/233] fix: remove unnecessary `ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT` key --- src/ONYXKEYS.ts | 4 --- src/libs/actions/App.ts | 1 - src/libs/actions/Composer.ts | 11 ------- src/libs/actions/QueuedOnyxUpdates.ts | 1 - .../index.android.ts | 3 -- .../index.ios.ts | 3 -- .../index.ts | 13 -------- ...uldShowComposeInputKeyboardAwareBuilder.ts | 33 ------------------- .../types.ts | 3 -- src/pages/inbox/ReportScreen.tsx | 17 ---------- .../ComposerWithSuggestions.tsx | 10 +----- .../ReportActionCompose.tsx | 9 +---- .../report/ReportActionItemMessageEdit.tsx | 21 +++--------- src/pages/inbox/report/ReportFooter.tsx | 17 ++-------- src/setup/index.ts | 1 - 15 files changed, 8 insertions(+), 139 deletions(-) delete mode 100644 src/libs/actions/Composer.ts delete mode 100644 src/libs/setShouldShowComposeInputKeyboardAware/index.android.ts delete mode 100644 src/libs/setShouldShowComposeInputKeyboardAware/index.ios.ts delete mode 100644 src/libs/setShouldShowComposeInputKeyboardAware/index.ts delete mode 100644 src/libs/setShouldShowComposeInputKeyboardAware/setShouldShowComposeInputKeyboardAwareBuilder.ts delete mode 100644 src/libs/setShouldShowComposeInputKeyboardAware/types.ts diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 44eeb2f50b74..85eccfba0d25 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -395,9 +395,6 @@ const ONYXKEYS = { /** The policyID of the last workspace whose settings were accessed by the user */ LAST_ACCESSED_WORKSPACE_POLICY_ID: 'lastAccessedWorkspacePolicyID', - /** Whether we should show the compose input or not */ - SHOULD_SHOW_COMPOSE_INPUT: 'shouldShowComposeInput', - /** Is app in beta version */ IS_BETA: 'isBeta', @@ -1357,7 +1354,6 @@ type OnyxValuesMapping = { [ONYXKEYS.HAS_LOADED_APP]: boolean; [ONYXKEYS.WALLET_TRANSFER]: OnyxTypes.WalletTransfer; [ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID]: string; - [ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT]: boolean; [ONYXKEYS.IS_BETA]: boolean; [ONYXKEYS.IS_CHECKING_PUBLIC_ROOM]: boolean; [ONYXKEYS.MY_DOMAIN_SECURITY_GROUPS]: Record; diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts index fc9c32d4920c..eb2bcc527bab 100644 --- a/src/libs/actions/App.ts +++ b/src/libs/actions/App.ts @@ -127,7 +127,6 @@ const KEYS_TO_PRESERVE: OnyxKey[] = [ ONYXKEYS.MODAL, ONYXKEYS.NETWORK, ONYXKEYS.SESSION, - ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT, ONYXKEYS.NVP_TRY_FOCUS_MODE, ONYXKEYS.PREFERRED_THEME, ONYXKEYS.NVP_PREFERRED_LOCALE, diff --git a/src/libs/actions/Composer.ts b/src/libs/actions/Composer.ts deleted file mode 100644 index edd42fb801f2..000000000000 --- a/src/libs/actions/Composer.ts +++ /dev/null @@ -1,11 +0,0 @@ -import Onyx from 'react-native-onyx'; -import ONYXKEYS from '@src/ONYXKEYS'; - -function setShouldShowComposeInput(shouldShowComposeInput: boolean) { - Onyx.set(ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT, shouldShowComposeInput); -} - -export { - // eslint-disable-next-line import/prefer-default-export - setShouldShowComposeInput, -}; diff --git a/src/libs/actions/QueuedOnyxUpdates.ts b/src/libs/actions/QueuedOnyxUpdates.ts index be2a1480ccec..57301313d387 100644 --- a/src/libs/actions/QueuedOnyxUpdates.ts +++ b/src/libs/actions/QueuedOnyxUpdates.ts @@ -48,7 +48,6 @@ function flushQueue(): Promise { ONYXKEYS.IS_CHECKING_PUBLIC_ROOM, ONYXKEYS.MODAL, ONYXKEYS.NETWORK, - ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT, ONYXKEYS.PRESERVED_USER_SESSION, ]); diff --git a/src/libs/setShouldShowComposeInputKeyboardAware/index.android.ts b/src/libs/setShouldShowComposeInputKeyboardAware/index.android.ts deleted file mode 100644 index 68c750b05a5f..000000000000 --- a/src/libs/setShouldShowComposeInputKeyboardAware/index.android.ts +++ /dev/null @@ -1,3 +0,0 @@ -import setShouldShowComposeInputKeyboardAwareBuilder from './setShouldShowComposeInputKeyboardAwareBuilder'; - -export default setShouldShowComposeInputKeyboardAwareBuilder('keyboardDidHide'); diff --git a/src/libs/setShouldShowComposeInputKeyboardAware/index.ios.ts b/src/libs/setShouldShowComposeInputKeyboardAware/index.ios.ts deleted file mode 100644 index 68c750b05a5f..000000000000 --- a/src/libs/setShouldShowComposeInputKeyboardAware/index.ios.ts +++ /dev/null @@ -1,3 +0,0 @@ -import setShouldShowComposeInputKeyboardAwareBuilder from './setShouldShowComposeInputKeyboardAwareBuilder'; - -export default setShouldShowComposeInputKeyboardAwareBuilder('keyboardDidHide'); diff --git a/src/libs/setShouldShowComposeInputKeyboardAware/index.ts b/src/libs/setShouldShowComposeInputKeyboardAware/index.ts deleted file mode 100644 index b3a2e7542148..000000000000 --- a/src/libs/setShouldShowComposeInputKeyboardAware/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import * as Composer from '@userActions/Composer'; -import type SetShouldShowComposeInputKeyboardAware from './types'; - -const setShouldShowComposeInputKeyboardAware: SetShouldShowComposeInputKeyboardAware = (shouldShow) => { - // We want to show the main composer when the edit composer loses focus. - // If it loses focus due to a pressable being pressed, the press event might not be captured. - // To address this, we delay showing the main composer to allow the press event to be completed. - setTimeout(() => { - Composer.setShouldShowComposeInput(shouldShow); - }, 0); -}; - -export default setShouldShowComposeInputKeyboardAware; diff --git a/src/libs/setShouldShowComposeInputKeyboardAware/setShouldShowComposeInputKeyboardAwareBuilder.ts b/src/libs/setShouldShowComposeInputKeyboardAware/setShouldShowComposeInputKeyboardAwareBuilder.ts deleted file mode 100644 index 72df7a730e02..000000000000 --- a/src/libs/setShouldShowComposeInputKeyboardAware/setShouldShowComposeInputKeyboardAwareBuilder.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type {EmitterSubscription} from 'react-native'; -import {Keyboard} from 'react-native'; -import type {KeyboardEventName} from 'react-native/Libraries/Components/Keyboard/Keyboard'; -import * as Composer from '@userActions/Composer'; -import type SetShouldShowComposeInputKeyboardAware from './types'; - -let keyboardEventListener: EmitterSubscription | null = null; - -const setShouldShowComposeInputKeyboardAwareBuilder: (keyboardEvent: KeyboardEventName) => SetShouldShowComposeInputKeyboardAware = - (keyboardEvent: KeyboardEventName) => (shouldShow: boolean) => { - if (keyboardEventListener) { - keyboardEventListener.remove(); - keyboardEventListener = null; - } - - if (!shouldShow) { - Composer.setShouldShowComposeInput(false); - return; - } - - // If keyboard is already hidden, we should show composer immediately because keyboardDidHide event won't be called - if (!Keyboard.isVisible()) { - Composer.setShouldShowComposeInput(true); - return; - } - - keyboardEventListener = Keyboard.addListener(keyboardEvent, () => { - Composer.setShouldShowComposeInput(true); - keyboardEventListener?.remove(); - }); - }; - -export default setShouldShowComposeInputKeyboardAwareBuilder; diff --git a/src/libs/setShouldShowComposeInputKeyboardAware/types.ts b/src/libs/setShouldShowComposeInputKeyboardAware/types.ts deleted file mode 100644 index 7e3a604f562e..000000000000 --- a/src/libs/setShouldShowComposeInputKeyboardAware/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -type SetShouldShowComposeInputKeyboardAware = (shouldShow: boolean) => void; - -export default SetShouldShowComposeInputKeyboardAware; diff --git a/src/pages/inbox/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx index bedb99476089..659df0bd6634 100644 --- a/src/pages/inbox/ReportScreen.tsx +++ b/src/pages/inbox/ReportScreen.tsx @@ -87,7 +87,6 @@ import {cancelSpan, cancelSpansByPrefix} from '@libs/telemetry/activeSpans'; import {getParentReportActionDeletionStatus} from '@libs/TransactionNavigationUtils'; import {isNumeric} from '@libs/ValidationUtils'; import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList} from '@navigation/types'; -import {setShouldShowComposeInput} from '@userActions/Composer'; import { createTransactionThreadReport, navigateToConciergeChat, @@ -652,12 +651,7 @@ function ReportScreen({route, navigation, isInSidePanel = false}: ReportScreenPr useAppFocusEvent(clearNotifications); useEffect(() => { - // eslint-disable-next-line @typescript-eslint/no-deprecated - const interactionTask = InteractionManager.runAfterInteractions(() => { - setShouldShowComposeInput(true); - }); return () => { - interactionTask.cancel(); if (!didSubscribeToReportLeavingEvents.current) { return; } @@ -767,18 +761,7 @@ function ReportScreen({route, navigation, isInSidePanel = false}: ReportScreenPr Navigation.isNavigationReady().then(() => { navigateToConciergeChat(conciergeReportID, introSelected, currentUserAccountID, false); }); - return; } - - // If you already have a report open and are deeplinking to a new report on native, - // the ReportScreen never actually unmounts and the reportID in the route also doesn't change. - // Therefore, we need to compare if the existing reportID is the same as the one in the route - // before deciding that we shouldn't call OpenReport. - if (reportIDFromRoute === lastReportIDFromRoute && (!onyxReportID || onyxReportID === reportIDFromRoute)) { - return; - } - - setShouldShowComposeInput(true); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ route.name, diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 7cbbd04e4e05..fffd7c7d3dc1 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -130,9 +130,6 @@ type ComposerWithSuggestionsProps = Partial & /** Function to handle sending a message */ onEnterKeyPress: () => void; - /** Whether the compose input should show */ - shouldShowComposeInput: OnyxEntry; - /** Function to measure the parent container */ measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void; @@ -160,9 +157,6 @@ type ComposerWithSuggestionsProps = Partial & /** policy ID of the report */ policyID?: string; - /** Whether the main composer was hidden */ - didHideComposerInput?: boolean; - /** Reference to the outer element */ ref?: Ref; }; @@ -224,7 +218,6 @@ function ComposerWithSuggestions({ disabled, setIsCommentEmpty, onEnterKeyPress, - shouldShowComposeInput, measureParentContainer = () => {}, isScrollLikelyLayoutTriggered, raiseIsScrollLikelyLayoutTriggered, @@ -238,7 +231,6 @@ function ComposerWithSuggestions({ // For testing children, - didHideComposerInput, // Fullstory forwardedFSClass, @@ -424,7 +416,7 @@ function ComposerWithSuggestions({ }, [value]); const maxComposerLines = shouldUseNarrowLayout ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; - const shouldAutoFocus = (shouldFocusInputOnScreenFocus || !!draftComment) && shouldShowComposeInput && areAllModalsHidden() && isFocused && !didHideComposerInput; + const shouldAutoFocus = (shouldFocusInputOnScreenFocus || !!draftComment) && areAllModalsHidden() && isFocused; const delayedAutoFocusRouteKeyRef = useRef(null); const valueRef = useRef(value); diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index ee2e55f5f2c1..a3dc5637ccb8 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -111,9 +111,6 @@ type ReportActionComposeProps = Pick void; - /** Whether the main composer was hidden */ - didHideComposerInput?: boolean; - /** Whether the report screen is being displayed in the side panel */ isInSidePanel?: boolean; @@ -139,7 +136,6 @@ function ReportActionCompose({ lastReportAction, onComposerFocus, onComposerBlur, - didHideComposerInput, reportTransactions, transactionThreadReportID, isInSidePanel = false, @@ -156,7 +152,6 @@ function ReportActionCompose({ const personalDetails = usePersonalDetails(); const [blockedFromConcierge] = useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); const [currentDate] = useOnyx(ONYXKEYS.CURRENT_DATE); - const [shouldShowComposeInput = true] = useOnyx(ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT); const {isRestrictedToPreferredPolicy} = usePreferredPolicy(); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`); const [initialModalState] = useOnyx(ONYXKEYS.MODAL); @@ -186,7 +181,7 @@ function ReportActionCompose({ * Updates the Highlight state of the composer */ const [isFocused, setIsFocused] = useState(() => { - return shouldFocusComposerOnScreenFocus && shouldShowComposeInput && !initialModalState?.isVisible && !initialModalState?.willAlertModalBecomeVisible; + return shouldFocusComposerOnScreenFocus && !initialModalState?.isVisible && !initialModalState?.willAlertModalBecomeVisible; }); const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(isComposerFullSize); @@ -626,12 +621,10 @@ function ReportActionCompose({ disabled={isBlockedFromConcierge || isEmojiPickerVisible()} setIsCommentEmpty={setIsCommentEmpty} onEnterKeyPress={sendMessage} - shouldShowComposeInput={shouldShowComposeInput} onFocus={onFocus} onBlur={onBlur} measureParentContainer={measureContainer} onValueChange={onValueChange} - didHideComposerInput={didHideComposerInput} forwardedFSClass={fsClass} /> {shouldDisplayDualDropZone && ( diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index 962a4b3663f8..e37f30e878ad 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -1,7 +1,7 @@ import lodashDebounce from 'lodash/debounce'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {InteractionManager, View} from 'react-native'; -import type {BlurEvent, MeasureInWindowOnSuccessCallback, TextInputKeyPressEvent, TextInputScrollEvent} from 'react-native'; +import type {MeasureInWindowOnSuccessCallback, TextInputKeyPressEvent, TextInputScrollEvent} from 'react-native'; import {useFocusedInputHandler} from 'react-native-keyboard-controller'; import {useSharedValue} from 'react-native-reanimated'; import type {Emoji} from '@assets/emojis/types'; @@ -19,8 +19,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useScrollBlocker from '@hooks/useScrollBlocker'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; -import {setShouldShowComposeInput} from '@libs/actions/Composer'; -import {clearActive, isActive as isEmojiPickerActive, isEmojiPickerVisible} from '@libs/actions/EmojiPickerAction'; +import {clearActive, isActive as isEmojiPickerActive} from '@libs/actions/EmojiPickerAction'; import {composerFocusKeepFocusOn} from '@libs/actions/InputFocus'; import {saveReportActionDraft} from '@libs/actions/Report'; import {isMobileChrome} from '@libs/Browser'; @@ -31,7 +30,6 @@ import focusComposerWithDelay from '@libs/focusComposerWithDelay'; import type {Selection} from '@libs/focusComposerWithDelay/types'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import reportActionItemEventHandler from '@libs/ReportActionItemEventHandler'; -import setShouldShowComposeInputKeyboardAware from '@libs/setShouldShowComposeInputKeyboardAware'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; @@ -197,9 +195,6 @@ function ReportActionItemMessageEdit({ }, true); }, [focus]); - // show the composer after editing is complete for devices that hide the composer during editing. - useEffect(() => () => setShouldShowComposeInput(true), []); - /** * Save the draft of the comment. This debounced so that we're not ceaselessly saving your edit. Saving the draft * allows one to navigate somewhere else and come back to the comment and still have it in edit mode. @@ -461,9 +456,8 @@ function ReportActionItemMessageEdit({ if (isMobileChrome() && reportScrollManager.ref?.current) { reportScrollManager.ref.current.scrollToIndex({index, animated: false}); } - setShouldShowComposeInputKeyboardAware(false); // The last composer that had focus should re-gain focus - setUpComposeFocusManager(); + // setUpComposeFocusManager(); // Clear active report action when another action gets focused if (!isEmojiPickerActive(action.reportActionID)) { @@ -473,14 +467,7 @@ function ReportActionItemMessageEdit({ ReportActionContextMenu.clearActiveReportAction(); } }} - onBlur={(event: BlurEvent) => { - setIsFocused(false); - const relatedTargetId = event.nativeEvent?.target; - if (relatedTargetId === tag.get() || isEmojiPickerVisible()) { - return; - } - setShouldShowComposeInputKeyboardAware(true); - }} + onBlur={() => setIsFocused(false)} onLayout={(event) => { if (!isFocused) { return; diff --git a/src/pages/inbox/report/ReportFooter.tsx b/src/pages/inbox/report/ReportFooter.tsx index fe5ddf9f341a..352eb3ab86ce 100644 --- a/src/pages/inbox/report/ReportFooter.tsx +++ b/src/pages/inbox/report/ReportFooter.tsx @@ -1,6 +1,6 @@ import {isBlockedFromChatSelector} from '@selectors/BlockedFromChat'; import {Str} from 'expensify-common'; -import React, {useEffect, useState} from 'react'; +import React from 'react'; import {Keyboard, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import AnonymousReportFooter from '@components/AnonymousReportFooter'; @@ -91,7 +91,6 @@ function ReportFooter({ const personalDetail = useCurrentUserPersonalDetails(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Lightbulb']); - const [shouldShowComposeInput = false] = useOnyx(ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT); const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); const isAnonymousUser = useIsAnonymousUser(); const [isBlockedFromChat] = useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CHAT, { @@ -200,17 +199,6 @@ function ReportFooter({ }); }; - const [didHideComposerInput, setDidHideComposerInput] = useState(!shouldShowComposeInput); - - useEffect(() => { - if (didHideComposerInput || shouldShowComposeInput) { - return; - } - // This is an intentional one-way latch: once the composer input has been hidden, it stays hidden. - // eslint-disable-next-line react-hooks/set-state-in-effect - setDidHideComposerInput(true); - }, [shouldShowComposeInput, didHideComposerInput]); - return ( <> {!!shouldHideComposer && ( @@ -248,7 +236,7 @@ function ReportFooter({ )} )} - {!shouldHideComposer && (!!shouldShowComposeInput || !isSmallScreenWidth) && ( + {!shouldHideComposer && !isSmallScreenWidth && ( Date: Wed, 11 Mar 2026 12:08:10 +0000 Subject: [PATCH 101/233] fix: always show composer --- src/pages/inbox/report/ReportFooter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportFooter.tsx b/src/pages/inbox/report/ReportFooter.tsx index 352eb3ab86ce..5d11002683ca 100644 --- a/src/pages/inbox/report/ReportFooter.tsx +++ b/src/pages/inbox/report/ReportFooter.tsx @@ -236,7 +236,7 @@ function ReportFooter({ )} )} - {!shouldHideComposer && !isSmallScreenWidth && ( + {!shouldHideComposer && ( Date: Wed, 11 Mar 2026 12:08:27 +0000 Subject: [PATCH 102/233] refactor: remove unused variable --- src/pages/inbox/report/ReportFooter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportFooter.tsx b/src/pages/inbox/report/ReportFooter.tsx index 5d11002683ca..99e07eb0efe0 100644 --- a/src/pages/inbox/report/ReportFooter.tsx +++ b/src/pages/inbox/report/ReportFooter.tsx @@ -87,7 +87,7 @@ function ReportFooter({ const {translate} = useLocalize(); const {windowWidth} = useWindowDimensions(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {isSmallScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); const personalDetail = useCurrentUserPersonalDetails(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Lightbulb']); From cc4d336e775583e7951f2206a42c91e709547df4 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 11 Mar 2026 14:23:04 +0000 Subject: [PATCH 103/233] fix: clear report action drafts when report is switched --- src/pages/inbox/ReportScreen.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/pages/inbox/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx index 659df0bd6634..836be940e7b9 100644 --- a/src/pages/inbox/ReportScreen.tsx +++ b/src/pages/inbox/ReportScreen.tsx @@ -88,6 +88,7 @@ import {getParentReportActionDeletionStatus} from '@libs/TransactionNavigationUt import {isNumeric} from '@libs/ValidationUtils'; import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList} from '@navigation/types'; import { + clearReportActionDrafts, createTransactionThreadReport, navigateToConciergeChat, openReport, @@ -180,6 +181,19 @@ function ReportScreen({route, navigation, isInSidePanel = false}: ReportScreenPr const archivedReportsIdSet = useArchivedReportsIdSet(); + // When the report screen is navigated away from, clear all report action edit drafts + useFocusEffect( + useCallback(() => { + if (!reportIDFromRoute) { + return; + } + + return () => { + clearReportActionDrafts(reportIDFromRoute); + }; + }, [reportIDFromRoute]), + ); + const parentReportAction = useParentReportAction(reportOnyx); const deletedParentAction = isDeletedParentAction(parentReportAction); From e8417cb07a67ca7191aa2b2536000fe622ce1d59 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 11 Mar 2026 14:23:24 +0000 Subject: [PATCH 104/233] refactor: simplify report action draft clearance --- src/libs/actions/Report/index.ts | 11 ----------- .../inbox/report/ContextMenu/ContextMenuActions.tsx | 7 +++---- src/pages/inbox/report/PureReportActionItem.tsx | 9 +++------ .../report/ReportActionCompose/useEditMessage.ts | 4 ++-- src/pages/inbox/report/ReportActionItem.tsx | 9 +-------- 5 files changed, 9 insertions(+), 31 deletions(-) diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index fdd3f5496cf8..d1d4459bb404 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -2741,16 +2741,6 @@ function clearReportActionDrafts(reportID: string | undefined) { Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`, null); } -/** Deletes the draft for a comment report action. */ -function deleteReportActionDraft(reportID: string | undefined, reportAction: ReportAction | null | undefined) { - if (!reportAction) { - return; - } - - const originalReportID = getOriginalReportID(reportID, reportAction, undefined); - Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${originalReportID}`, {[reportAction.reportActionID]: null}); -} - /** Saves the draft for a comment report action. This will put the comment into "edit mode" */ function saveReportActionDraft(reportID: string | undefined, reportAction: ReportAction | null, draftMessage: string) { if (!reportAction) { @@ -7073,7 +7063,6 @@ export { createNewReport, deleteReport, clearReportActionDrafts, - deleteReportActionDraft, deleteReportComment, deleteReportField, dismissTrackExpenseActionableWhisper, diff --git a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx index 0fff2a5c214c..c0e7f7a948dd 100644 --- a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx @@ -172,7 +172,6 @@ import {getTaskCreatedMessage, getTaskReportActionMessage} from '@libs/TaskUtils import {setDownload} from '@userActions/Download'; import { clearReportActionDrafts, - deleteReportActionDraft, explain, markCommentAsUnread, navigateToAndOpenChildReport, @@ -526,10 +525,10 @@ const ContextMenuActions: ContextMenuAction[] = [ clearReportActionDrafts(reportID); if (draftMessage) { - deleteReportActionDraft(reportID, reportAction); - } else { - saveReportActionDraft(reportID, reportAction, Parser.htmlToMarkdown(getActionHtml(reportAction))); + return; } + + saveReportActionDraft(reportID, reportAction, Parser.htmlToMarkdown(getActionHtml(reportAction))); }; if (closePopover) { diff --git a/src/pages/inbox/report/PureReportActionItem.tsx b/src/pages/inbox/report/PureReportActionItem.tsx index 92aa0a7f604a..75dc2152aa6d 100644 --- a/src/pages/inbox/report/PureReportActionItem.tsx +++ b/src/pages/inbox/report/PureReportActionItem.tsx @@ -231,6 +231,7 @@ import type {IgnoreDirection} from '@userActions/ClearReportActionErrors'; import {hideEmojiPicker, isActive} from '@userActions/EmojiPickerAction'; import {acceptJoinRequest, declineJoinRequest} from '@userActions/Policy/Member'; import { + clearReportActionDrafts, createTransactionThreadReport, expandURLPreview, resolveActionableMentionConfirmWhisper, @@ -368,9 +369,6 @@ type PureReportActionItemProps = { /** Original report from which the given reportAction is first created */ originalReport?: OnyxTypes.Report; - /** Function to deletes the draft for a comment report action. */ - deleteReportActionDraft?: (reportID: string | undefined, action: OnyxTypes.ReportAction) => void; - /** Whether the room is archived */ isArchivedRoom?: boolean; @@ -516,7 +514,6 @@ function PureReportActionItem({ blockedFromConcierge, originalReportID = '-1', originalReport, - deleteReportActionDraft = () => {}, isArchivedRoom, isChronosReport, toggleEmojiReaction = () => {}, @@ -708,8 +705,8 @@ function PureReportActionItem({ if (draftMessage === undefined || !isDeletedAction(action)) { return; } - deleteReportActionDraft(reportID, action); - }, [draftMessage, action, reportID, deleteReportActionDraft]); + clearReportActionDrafts(reportID); + }, [draftMessage, action, reportID]); // Hide the message if it is being moderated for a higher offense, or is hidden by a moderator // Removed messages should not be shown anyway and should not need this flow diff --git a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts index c6bd16a5e6a1..c6402930b653 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts @@ -10,7 +10,7 @@ import useOnyx from '@hooks/useOnyx'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportScrollManager from '@hooks/useReportScrollManager'; import {isActive as isEmojiPickerActive} from '@libs/actions/EmojiPickerAction'; -import {deleteReportActionDraft, editReportComment} from '@libs/actions/Report'; +import {clearReportActionDrafts, editReportComment} from '@libs/actions/Report'; import focusEditAfterCancelDelete from '@libs/focusEditAfterCancelDelete'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import {getOriginalReportID} from '@libs/ReportUtils'; @@ -70,7 +70,7 @@ function useEditMessage({reportID, originalReportID, reportAction, shouldScrollT setEditingState('cancelled'); - deleteReportActionDraft(reportID, reportAction); + clearReportActionDrafts(reportID); if (isActive()) { ReportActionComposeFocusManager.clear(true); diff --git a/src/pages/inbox/report/ReportActionItem.tsx b/src/pages/inbox/report/ReportActionItem.tsx index 3fafb482cdcd..4c87bcfccee6 100644 --- a/src/pages/inbox/report/ReportActionItem.tsx +++ b/src/pages/inbox/report/ReportActionItem.tsx @@ -23,13 +23,7 @@ import { isCurrentUserTheOnlyParticipant, } from '@libs/ReportUtils'; import {clearAllRelatedReportActionErrors} from '@userActions/ClearReportActionErrors'; -import { - deleteReportActionDraft, - dismissTrackExpenseActionableWhisper, - resolveActionableMentionWhisper, - resolveActionableReportMentionWhisper, - toggleEmojiReaction, -} from '@userActions/Report'; +import {dismissTrackExpenseActionableWhisper, resolveActionableMentionWhisper, resolveActionableReportMentionWhisper, toggleEmojiReaction} from '@userActions/Report'; import {clearError} from '@userActions/Transaction'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -159,7 +153,6 @@ function ReportActionItem({ blockedFromConcierge={blockedFromConcierge} originalReportID={originalReportID} originalReport={originalReport} - deleteReportActionDraft={deleteReportActionDraft} isArchivedRoom={isArchivedNonExpenseReport(originalReport, isOriginalReportArchived)} isChronosReport={chatIncludesChronosWithID(originalReportID)} toggleEmojiReaction={toggleEmojiReaction} From c63f242daf4d33fa040e93875e07e0fe008bd141 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 12 Mar 2026 10:18:22 +0000 Subject: [PATCH 105/233] fix: editing thread messages --- src/pages/inbox/ReportScreen.tsx | 6 +- .../ReportActionCompose.tsx | 7 +- .../report/ReportActionEditMessageContext.tsx | 91 ++++++++++++++----- 3 files changed, 77 insertions(+), 27 deletions(-) diff --git a/src/pages/inbox/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx index 836be940e7b9..a4611e6d7326 100644 --- a/src/pages/inbox/ReportScreen.tsx +++ b/src/pages/inbox/ReportScreen.tsx @@ -1001,7 +1001,11 @@ function ReportScreen({route, navigation, isInSidePanel = false}: ReportScreenPr return ( // Wide RHP overlays should be rendered only for the report screen displayed in RHP - + ({ + editingReportID: null, editingReportActionID: null, editingReportAction: null, editingMessage: null, @@ -38,15 +40,18 @@ const ReportActionEditMessageContext = createContext(null); const [editingReportActionID, setEditingReportActionID] = useState(null); const [editingReportAction, setEditingReportAction] = useState(null); const [editingMessage, setEditingMessage] = useState(null); @@ -64,6 +69,7 @@ function ReportActionEditMessageContextProvider({reportID, children}: ReportActi const newEditingReportActionID = activeEdit?.editingReportActionID ?? null; const newEditingReportAction = activeEdit?.editingReportAction ?? null; const newEditingMessage = activeEdit?.editingMessage ?? null; + const newEditingReportID = activeEdit?.editingReportID ?? null; if (newEditingReportActionID !== editingReportActionID) { setEditingReportActionID(newEditingReportActionID); @@ -77,19 +83,30 @@ function ReportActionEditMessageContextProvider({reportID, children}: ReportActi }, [editingReportActionID, editingReportAction, editingMessage], ); + if (newEditingReportID !== editingReportID) { + setEditingReportID(newEditingReportID); + } - const setCurrentEditMessageSelection = useCallback( - (setSelectionStateAction: SetStateAction) => { - if (!editingReportActionID) { - return; - } + if (newEditingReportActionID !== editingReportActionID) { + setEditingReportActionID(newEditingReportActionID); + } + if (newEditingReportAction !== editingReportAction) { + setEditingReportAction(newEditingReportAction); + } + if (newEditingMessage !== editingMessage) { + setEditingMessage(newEditingMessage); + } + }; - setCurrentEditMessageSelectionState(setSelectionStateAction); - }, - [editingReportActionID], - ); + const setCurrentEditMessageSelection = (setSelectionStateAction: SetStateAction) => { + if (!editingReportActionID) { + return; + } + + setCurrentEditMessageSelectionState(setSelectionStateAction); + }; - const reset = useCallback(() => { + const reset = () => { if (editingStateRef.current === 'editing') { return; } @@ -103,20 +120,46 @@ function ReportActionEditMessageContextProvider({reportID, children}: ReportActi useEffect(() => { const reportDrafts = reportActionDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`]; - if (!reportDrafts) { - // eslint-disable-next-line react-hooks/set-state-in-effect - reset(); - return; + let parentReportActionDrafts: OnyxTypes.ReportActionsDrafts | undefined; + if (parentReportAction && parentReportID) { + parentReportActionDrafts = reportActionDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${parentReportID}`]; } - const reportDraftEntry = Object.entries(reportDrafts).find(([, draft]) => draft?.message !== undefined); - - if (!reportDraftEntry) { + if (!reportDrafts && !parentReportActionDrafts) { reset(); return; } - const [reportActionID, draft] = reportDraftEntry; + let editReportID: string | undefined; + let reportActionID: string | undefined; + let reportAction: OnyxTypes.ReportAction | undefined; + let draft: OnyxTypes.ReportActionsDraft | undefined; + + if (parentReportAction && parentReportActionDrafts) { + const parentReportActionDraft = parentReportActionDrafts[parentReportAction.reportActionID]; + if (parentReportActionDraft) { + editReportID = parentReportID; + reportActionID = parentReportAction.reportActionID; + reportAction = parentReportAction; + draft = parentReportActionDraft; + } + } + + if (!reportActionID) { + const reportDraftEntry = reportDrafts ? Object.entries(reportDrafts).find(([, d]) => d?.message !== undefined) : undefined; + + if (!reportDraftEntry) { + reset(); + return; + } + + const [reportActionIDOfDraft, reportActionDraft] = reportDraftEntry; + + editReportID = reportID; + reportActionID = reportActionIDOfDraft; + reportAction = reportActions?.[reportActionID]; + draft = reportActionDraft; + } if (editingStateRef.current !== null) { return; @@ -124,16 +167,18 @@ function ReportActionEditMessageContextProvider({reportID, children}: ReportActi editingStateRef.current = 'editing'; updateActiveEditState({ + editingReportID: editReportID ?? null, editingReportActionID: reportActionID, - editingReportAction: reportActions?.[reportActionID] ?? null, - editingMessage: draft.message, + editingReportAction: reportAction ?? null, + editingMessage: draft?.message ?? null, }); - }, [reportActionDrafts, reportActions, reportID, reset, updateActiveEditState]); + }, [parentReportAction, parentReportID, reportActionDrafts, reportActions, reportID, reset, updateActiveEditState]); return ( Date: Thu, 12 Mar 2026 10:18:34 +0000 Subject: [PATCH 106/233] refactor: remove manual memoization --- .../report/ReportActionEditMessageContext.tsx | 23 ++++--------------- 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/src/pages/inbox/report/ReportActionEditMessageContext.tsx b/src/pages/inbox/report/ReportActionEditMessageContext.tsx index 059ad6daeb8f..8dc30eacbf3e 100644 --- a/src/pages/inbox/report/ReportActionEditMessageContext.tsx +++ b/src/pages/inbox/report/ReportActionEditMessageContext.tsx @@ -64,25 +64,12 @@ function ReportActionEditMessageContextProvider({reportID, parentReportID, paren editingStateRef.current = state; }; - const updateActiveEditState = useCallback( - (activeEdit: ReportActionActiveEdit | null) => { - const newEditingReportActionID = activeEdit?.editingReportActionID ?? null; - const newEditingReportAction = activeEdit?.editingReportAction ?? null; - const newEditingMessage = activeEdit?.editingMessage ?? null; + const updateActiveEditState = (activeEdit: ReportActionActiveEdit | null) => { const newEditingReportID = activeEdit?.editingReportID ?? null; + const newEditingReportActionID = activeEdit?.editingReportActionID ?? null; + const newEditingReportAction = activeEdit?.editingReportAction ?? null; + const newEditingMessage = activeEdit?.editingMessage ?? null; - if (newEditingReportActionID !== editingReportActionID) { - setEditingReportActionID(newEditingReportActionID); - } - if (newEditingReportAction !== editingReportAction) { - setEditingReportAction(newEditingReportAction); - } - if (newEditingMessage !== editingMessage) { - setEditingMessage(newEditingMessage); - } - }, - [editingReportActionID, editingReportAction, editingMessage], - ); if (newEditingReportID !== editingReportID) { setEditingReportID(newEditingReportID); } @@ -114,7 +101,7 @@ function ReportActionEditMessageContextProvider({reportID, parentReportID, paren editingStateRef.current = null; updateActiveEditState(null); setCurrentEditMessageSelection(null); - }, [updateActiveEditState, setCurrentEditMessageSelection]); + }; // Initially set the editing report action state when the draft comments change useEffect(() => { From ad44dfd235f1f8c7386ef1ca6794b056b1a789ef Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 12 Mar 2026 11:03:39 +0000 Subject: [PATCH 107/233] fix: use editing message state in `ReportActionItemMessageEdit` --- .../inbox/report/PureReportActionItem.tsx | 1 - .../report/ReportActionItemMessageEdit.tsx | 27 +++++-------------- 2 files changed, 7 insertions(+), 21 deletions(-) diff --git a/src/pages/inbox/report/PureReportActionItem.tsx b/src/pages/inbox/report/PureReportActionItem.tsx index 75dc2152aa6d..828faca44be5 100644 --- a/src/pages/inbox/report/PureReportActionItem.tsx +++ b/src/pages/inbox/report/PureReportActionItem.tsx @@ -1775,7 +1775,6 @@ function PureReportActionItem({ {isEditingInline ? ( ([]); + + const {currentEditMessageSelection, setCurrentEditMessageSelection, editingMessage, setEditingMessage} = useReportActionActiveEdit(); const [draft, setDraft] = useState(() => { - if (draftMessage) { - emojisPresentBefore.current = extractEmojis(draftMessage); + if (editingMessage) { + emojisPresentBefore.current = extractEmojis(editingMessage); } - return draftMessage; + return editingMessage ?? ''; }); - const {currentEditMessageSelection, setCurrentEditMessageSelection, setEditingMessage} = useReportActionActiveEdit(); - const defaultSelection = useMemo(() => ({start: draft.length, end: draft.length, positionX: 0, positionY: 0}), [draft.length]); const [selection, setSelectionState] = useState(() => currentEditMessageSelection ?? defaultSelection); @@ -152,7 +139,7 @@ function ReportActionItemMessageEdit({ const isCommentPendingSaved = useRef(false); useDraftMessageVideoAttributeCache({ - draftMessage, + draftMessage: editingMessage ?? '', isEditing: true, editingReportAction: action, updateDraftMessage: setDraft, From a2c9308d122b67bb09075ad73849e0666234f464 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 12 Mar 2026 11:10:15 +0000 Subject: [PATCH 108/233] refactor: Remove `useEffect` in and simplify `ReportActionEditMessageContext` --- .../ComposerWithSuggestions.tsx | 23 +-- .../report/ReportActionEditMessageContext.tsx | 157 +++++++----------- 2 files changed, 65 insertions(+), 115 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index fffd7c7d3dc1..b70fa2f45159 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -253,7 +253,7 @@ function ComposerWithSuggestions({ const composerRef = useRef(null); - const {editingReportActionID, editingReportAction, editingMessage, setEditingMessage, currentEditMessageSelection, setCurrentEditMessageSelection, getEditingState} = + const {editingState, editingReportActionID, editingReportAction, editingMessage, setEditingMessage, currentEditMessageSelection, setCurrentEditMessageSelection} = useReportActionActiveEdit(); const [value, setValue] = useState(() => { @@ -332,7 +332,6 @@ function ComposerWithSuggestions({ ); useEffect(() => { - const editingState = getEditingState(); if (editingState === 'submitted') { return; } @@ -347,8 +346,7 @@ function ComposerWithSuggestions({ if (!isEditing) { if (wasEditing.current && wasEditingInComposerRef.current) { // Editing just ended in the composer – restore the draft comment and its previous selection. - const nextValue = draftComment ?? ''; - applyComposerValue(nextValue, {selection: previousDraftSelectionRef.current}); + applyComposerValue(draftComment ?? '', {selection: previousDraftSelectionRef.current}); } wasEditing.current = false; @@ -370,16 +368,14 @@ function ComposerWithSuggestions({ return; } // In narrow layout we always show the message being edited. - const nextValue = editingMessage ?? ''; // When starting to edit in the composer, always place the cursor at the end of the message. - applyComposerValue(nextValue, {isEditingInComposer: true, shouldMoveSelectionToEnd: true}); + applyComposerValue(editingMessage ?? '', {isEditingInComposer: true, shouldMoveSelectionToEnd: true}); return; } // We are already in editing mode, but the target message changed. if (didChangeEditedAction && shouldUseNarrowLayout) { - const nextValue = editingMessage ?? ''; - applyComposerValue(nextValue, {isEditingInComposer: true}); + applyComposerValue(editingMessage ?? '', {isEditingInComposer: true}); return; } @@ -387,18 +383,16 @@ function ComposerWithSuggestions({ if (shouldUseNarrowLayout && !wasEditingInComposerRef.current) { wasEditingInComposerRef.current = true; // We just moved from wide to narrow while editing – start editing in the composer. - const nextValue = editingMessage ?? ''; - applyComposerValue(nextValue, {isEditingInComposer: true}); + applyComposerValue(editingMessage ?? '', {isEditingInComposer: true}); return; } // Editing is ongoing and layout toggled from narrow to wide. if (!shouldUseNarrowLayout && wasEditingInComposerRef.current) { wasEditingInComposerRef.current = false; - const nextValue = draftComment ?? ''; - applyComposerValue(nextValue); + applyComposerValue(draftComment ?? ''); } - }, [applyComposerValue, draftComment, editingMessage, editingReportActionID, getEditingState, selection, shouldUseNarrowLayout, updateSelectionImperatively]); + }, [applyComposerValue, draftComment, editingMessage, editingReportActionID, editingState, selection, shouldUseNarrowLayout, updateSelectionImperatively]); const {superWideRHPRouteKeys} = useWideRHPState(); // When SearchReport is stacked above another RHP, delay autofocus until after the transition completes to avoid animation jank @@ -622,7 +616,6 @@ function ComposerWithSuggestions({ } commentRef.current = newCommentConverted; - const editingState = getEditingState(); if (editingState === 'editing' && shouldUseNarrowLayout) { setEditingMessage(newCommentConverted); if (shouldDebounceSaveComment) { @@ -651,8 +644,8 @@ function ComposerWithSuggestions({ debouncedSaveDraft, debouncedSaveReportComment, editingReportActionID, + editingState, findNewlyAddedChars, - getEditingState, preferredLocale, preferredSkinTone, raiseIsScrollLikelyLayoutTriggered, diff --git a/src/pages/inbox/report/ReportActionEditMessageContext.tsx b/src/pages/inbox/report/ReportActionEditMessageContext.tsx index 8dc30eacbf3e..b160265413af 100644 --- a/src/pages/inbox/report/ReportActionEditMessageContext.tsx +++ b/src/pages/inbox/report/ReportActionEditMessageContext.tsx @@ -1,4 +1,5 @@ -import React, {createContext, useContext, useEffect, useRef, useState} from 'react'; +import {useIsFocused} from '@react-navigation/native'; +import React, {createContext, useContext, useState} from 'react'; import type {Dispatch, SetStateAction} from 'react'; import type {TextSelection} from '@components/Composer/types'; import useOnyx from '@hooks/useOnyx'; @@ -22,8 +23,8 @@ type ReportActionEditMessageContextValue = ReportActionActiveEdit & { setEditingMessage: Dispatch>; currentEditMessageSelection: TextSelection | null; setCurrentEditMessageSelection: Dispatch>; - getEditingState: () => EditingState | null; - setEditingState: (state: EditingState | null) => void; + editingState: EditingState | null; + setEditingState: Dispatch>; }; const ReportActionEditMessageContext = createContext({ @@ -34,7 +35,7 @@ const ReportActionEditMessageContext = createContext(null); - const [editingReportActionID, setEditingReportActionID] = useState(null); - const [editingReportAction, setEditingReportAction] = useState(null); + const [editingState, setEditingState] = useState(null); const [editingMessage, setEditingMessage] = useState(null); const [currentEditMessageSelection, setCurrentEditMessageSelectionState] = useState(null); - const editingStateRef = useRef(null); - const getEditingState = () => { - return editingStateRef.current; - }; - const setEditingState = (state: EditingState | null) => { - editingStateRef.current = state; - }; - - const updateActiveEditState = (activeEdit: ReportActionActiveEdit | null) => { - const newEditingReportID = activeEdit?.editingReportID ?? null; - const newEditingReportActionID = activeEdit?.editingReportActionID ?? null; - const newEditingReportAction = activeEdit?.editingReportAction ?? null; - const newEditingMessage = activeEdit?.editingMessage ?? null; - - if (newEditingReportID !== editingReportID) { - setEditingReportID(newEditingReportID); - } - - if (newEditingReportActionID !== editingReportActionID) { - setEditingReportActionID(newEditingReportActionID); - } - if (newEditingReportAction !== editingReportAction) { - setEditingReportAction(newEditingReportAction); - } - if (newEditingMessage !== editingMessage) { - setEditingMessage(newEditingMessage); - } - }; - - const setCurrentEditMessageSelection = (setSelectionStateAction: SetStateAction) => { - if (!editingReportActionID) { - return; - } - - setCurrentEditMessageSelectionState(setSelectionStateAction); - }; - const reset = () => { - if (editingStateRef.current === 'editing') { - return; - } + const reportDrafts = reportActionDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`]; - editingStateRef.current = null; - updateActiveEditState(null); - setCurrentEditMessageSelection(null); - }; + let parentReportDrafts: OnyxTypes.ReportActionsDrafts | undefined; + if (parentReportAction && parentReportID) { + parentReportDrafts = reportActionDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${parentReportID}`]; + } - // Initially set the editing report action state when the draft comments change - useEffect(() => { - const reportDrafts = reportActionDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`]; + let editingReportID: string | null = null; + let editingReportActionID: string | null = null; + let editingReportAction: OnyxTypes.ReportAction | null = null; - let parentReportActionDrafts: OnyxTypes.ReportActionsDrafts | undefined; - if (parentReportAction && parentReportID) { - parentReportActionDrafts = reportActionDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${parentReportID}`]; - } + if (isFocused) { + if (parentReportAction && parentReportDrafts?.[parentReportAction.reportActionID]) { + const parentReportActionDraft = parentReportDrafts[parentReportAction.reportActionID]; - if (!reportDrafts && !parentReportActionDrafts) { - reset(); - return; - } + editingReportID = parentReportID ?? null; + editingReportActionID = parentReportAction.reportActionID; + editingReportAction = parentReportAction; + const nextMessage = parentReportActionDraft?.message ?? null; - let editReportID: string | undefined; - let reportActionID: string | undefined; - let reportAction: OnyxTypes.ReportAction | undefined; - let draft: OnyxTypes.ReportActionsDraft | undefined; - - if (parentReportAction && parentReportActionDrafts) { - const parentReportActionDraft = parentReportActionDrafts[parentReportAction.reportActionID]; - if (parentReportActionDraft) { - editReportID = parentReportID; - reportActionID = parentReportAction.reportActionID; - reportAction = parentReportAction; - draft = parentReportActionDraft; + if (editingState === null) { + setEditingState('editing'); } - } - - if (!reportActionID) { - const reportDraftEntry = reportDrafts ? Object.entries(reportDrafts).find(([, d]) => d?.message !== undefined) : undefined; - - if (!reportDraftEntry) { - reset(); - return; + if (editingMessage == null && editingMessage !== nextMessage) { + setEditingMessage(nextMessage); + } + } else if (reportDrafts) { + const reportDraftEntry = Object.entries(reportDrafts).find(([, draft]) => draft?.message !== undefined); + + if (reportDraftEntry) { + const [reportActionIDOfDraft, reportActionDraft] = reportDraftEntry; + + editingReportID = reportID ?? null; + editingReportActionID = reportActionIDOfDraft; + editingReportAction = reportActions?.[reportActionIDOfDraft] ?? null; + const nextMessage = reportActionDraft?.message ?? null; + + if (editingState === null) { + setEditingState('editing'); + } + if (editingMessage == null && editingMessage !== nextMessage) { + setEditingMessage(nextMessage); + } } - - const [reportActionIDOfDraft, reportActionDraft] = reportDraftEntry; - - editReportID = reportID; - reportActionID = reportActionIDOfDraft; - reportAction = reportActions?.[reportActionID]; - draft = reportActionDraft; } + } - if (editingStateRef.current !== null) { + if (editingReportID == null && editingState !== null) { + setEditingState(null); + setEditingMessage(null); + } + + const setCurrentEditMessageSelection = (setSelectionStateAction: SetStateAction) => { + if (editingState !== 'editing') { return; } - editingStateRef.current = 'editing'; - updateActiveEditState({ - editingReportID: editReportID ?? null, - editingReportActionID: reportActionID, - editingReportAction: reportAction ?? null, - editingMessage: draft?.message ?? null, - }); - }, [parentReportAction, parentReportID, reportActionDrafts, reportActions, reportID, reset, updateActiveEditState]); + setCurrentEditMessageSelectionState(setSelectionStateAction); + }; return ( {children} From 0b1037cf0525ea2b3359bf442a72b1a3c2ac626b Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 12 Mar 2026 12:51:15 +0000 Subject: [PATCH 109/233] feat: allow full size composer while editing --- .../AttachmentPickerWithMenuItems.tsx | 81 +++------------ .../ExpandCollapseComposerButton.tsx | 98 +++++++++++++++++++ .../MessageEditCancelButton.tsx | 8 +- .../ReportActionCompose.tsx | 18 +++- .../report/ReportActionItemMessageEdit.tsx | 5 +- 5 files changed, 134 insertions(+), 76 deletions(-) create mode 100644 src/pages/inbox/report/ReportActionCompose/ExpandCollapseComposerButton.tsx diff --git a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx index 1d1c9f603d0d..f3f526a20343 100644 --- a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx +++ b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx @@ -49,6 +49,7 @@ import ROUTES from '@src/ROUTES'; import type {AnchorPosition} from '@src/styles'; import type * as OnyxTypes from '@src/types/onyx'; import type {FileObject} from '@src/types/utils/Attachment'; +import ExpandCollapseComposerButton from './ExpandCollapseComposerButton'; type MoneyRequestOptions = Record< Exclude, @@ -134,21 +135,7 @@ function AttachmentPickerWithMenuItems({ raiseIsScrollLikelyLayoutTriggered, shouldDisableAttachmentItem, }: AttachmentPickerWithMenuItemsProps) { - const icons = useMemoizedLazyExpensifyIcons([ - 'Cash', - 'Coins', - 'Collapse', - 'Document', - 'Expand', - 'InvoiceGeneric', - 'Location', - 'MoneyCircle', - 'Paperclip', - 'Plus', - 'Receipt', - 'Task', - 'Transfer', - ] as const); + const icons = useMemoizedLazyExpensifyIcons(['Cash', 'Coins', 'Document', 'InvoiceGeneric', 'Location', 'MoneyCircle', 'Paperclip', 'Plus', 'Receipt', 'Task', 'Transfer'] as const); const isFocused = useIsFocused(); const theme = useTheme(); const styles = useThemeStyles(); @@ -460,61 +447,15 @@ function AttachmentPickerWithMenuItems({ - {(isFullComposerAvailable || isComposerFullSize) && ( - - {isComposerFullSize ? ( - - { - e?.preventDefault(); - raiseIsScrollLikelyLayoutTriggered(); - setIsComposerFullSize(reportID, false); - }} - // Keep focus on the composer when Collapse button is clicked. - onMouseDown={(e) => e.preventDefault()} - style={styles.composerSizeButton} - disabled={disabled} - role={CONST.ROLE.BUTTON} - accessibilityLabel={translate('reportActionCompose.collapse')} - sentryLabel={CONST.SENTRY_LABEL.REPORT.ATTACHMENT_PICKER_COLLAPSE_BUTTON} - > - - - - ) : ( - - { - e?.preventDefault(); - raiseIsScrollLikelyLayoutTriggered(); - setIsComposerFullSize(reportID, true); - }} - // Keep focus on the composer when Expand button is clicked. - onMouseDown={(e) => e.preventDefault()} - style={styles.composerSizeButton} - disabled={disabled} - role={CONST.ROLE.BUTTON} - accessibilityLabel={translate('reportActionCompose.expand')} - sentryLabel={CONST.SENTRY_LABEL.REPORT.ATTACHMENT_PICKER_EXPAND_BUTTON} - > - - - - )} - - )} + void; + setIsComposerFullSize: (reportID: string, isFullSize: boolean) => void; + disabled?: boolean; +}; + +function ExpandCollapseComposerButton({ + isFullComposerAvailable, + isComposerFullSize, + reportID, + disabled = false, + raiseIsScrollLikelyLayoutTriggered, + setIsComposerFullSize, + ...restProps +}: ExpandCollapseComposerButtonProps) { + const {translate} = useLocalize(); + const theme = useTheme(); + const styles = useThemeStyles(); + const icons = useMemoizedLazyExpensifyIcons(['Collapse', 'Expand'] as const); + + if (!isFullComposerAvailable && !isComposerFullSize) { + return null; + } + + return ( + // eslint-disable-next-line react/jsx-props-no-spreading + + {isComposerFullSize ? ( + + { + e?.preventDefault(); + raiseIsScrollLikelyLayoutTriggered(); + setIsComposerFullSize(reportID, false); + }} + // Keep focus on the composer when Collapse button is clicked. + onMouseDown={(e) => e.preventDefault()} + style={styles.composerSizeButton} + disabled={disabled} + role={CONST.ROLE.BUTTON} + accessibilityLabel={translate('reportActionCompose.collapse')} + sentryLabel={CONST.SENTRY_LABEL.REPORT.ATTACHMENT_PICKER_COLLAPSE_BUTTON} + > + + + + ) : ( + + { + e?.preventDefault(); + raiseIsScrollLikelyLayoutTriggered(); + setIsComposerFullSize(reportID, true); + }} + // Keep focus on the composer when Expand button is clicked. + onMouseDown={(e) => e.preventDefault()} + style={styles.composerSizeButton} + disabled={disabled} + role={CONST.ROLE.BUTTON} + accessibilityLabel={translate('reportActionCompose.expand')} + sentryLabel={CONST.SENTRY_LABEL.REPORT.ATTACHMENT_PICKER_EXPAND_BUTTON} + > + + + + )} + + ); +} + +export default ExpandCollapseComposerButton; diff --git a/src/pages/inbox/report/ReportActionCompose/MessageEditCancelButton.tsx b/src/pages/inbox/report/ReportActionCompose/MessageEditCancelButton.tsx index 12bdb74b7c4c..92f6ed61f1fe 100644 --- a/src/pages/inbox/report/ReportActionCompose/MessageEditCancelButton.tsx +++ b/src/pages/inbox/report/ReportActionCompose/MessageEditCancelButton.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type {ViewProps} from 'react-native'; import {View} from 'react-native'; import Icon from '@components/Icon'; import {PressableWithFeedback} from '@components/Pressable'; @@ -9,11 +10,11 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; -type MessageEditCancelButtonProps = { +type MessageEditCancelButtonProps = ViewProps & { onCancel: () => void; }; -function MessageEditCancelButton({onCancel}: MessageEditCancelButtonProps) { +function MessageEditCancelButton({onCancel, ...restProps}: MessageEditCancelButtonProps) { const styles = useThemeStyles(); const theme = useTheme(); const {translate} = useLocalize(); @@ -22,7 +23,8 @@ function MessageEditCancelButton({onCancel}: MessageEditCancelButtonProps) { const closeButtonStyles = [styles.composerSizeButton, {marginVertical: styles.composerSizeButton.marginHorizontal}]; return ( - + // eslint-disable-next-line react/jsx-props-no-spreading + {PDFValidationComponent} {isEditingInComposer ? ( - + + + + ) : ( validateAttachments({files})} diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index 13e41c0e3573..e679f8c6a247 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -408,7 +408,10 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy isExceedingMaxLength && styles.borderColorDanger, ]} > - + Date: Thu, 12 Mar 2026 13:04:49 +0000 Subject: [PATCH 110/233] feat: allow editing multiple parent reports --- .../report/ReportActionEditMessageContext.tsx | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionEditMessageContext.tsx b/src/pages/inbox/report/ReportActionEditMessageContext.tsx index b160265413af..bbbdae64b2ef 100644 --- a/src/pages/inbox/report/ReportActionEditMessageContext.tsx +++ b/src/pages/inbox/report/ReportActionEditMessageContext.tsx @@ -2,6 +2,7 @@ import {useIsFocused} from '@react-navigation/native'; import React, {createContext, useContext, useState} from 'react'; import type {Dispatch, SetStateAction} from 'react'; import type {TextSelection} from '@components/Composer/types'; +import useAncestors from '@hooks/useAncestors'; import useOnyx from '@hooks/useOnyx'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; @@ -49,10 +50,12 @@ type ReportActionEditMessageContextProviderProps = { function ReportActionEditMessageContextProvider({reportID, parentReportID, parentReportAction, children}: ReportActionEditMessageContextProviderProps) { const isFocused = useIsFocused(); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { canEvict: false, }); const [reportActionDrafts] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS); + const ancestors = useAncestors(report); const [editingState, setEditingState] = useState(null); const [editingMessage, setEditingMessage] = useState(null); @@ -70,7 +73,33 @@ function ReportActionEditMessageContextProvider({reportID, parentReportID, paren let editingReportAction: OnyxTypes.ReportAction | null = null; if (isFocused) { - if (parentReportAction && parentReportDrafts?.[parentReportAction.reportActionID]) { + const ancestorWithDraft = [...ancestors] + .slice() + .reverse() + .find(({report: ancestorReport, reportAction}) => { + const ancestorDrafts = reportActionDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${ancestorReport.reportID}`]; + const ancestorDraft = ancestorDrafts?.[reportAction.reportActionID]; + + return ancestorDraft?.message !== undefined; + }); + + if (ancestorWithDraft) { + const {report: ancestorReport, reportAction: ancestorReportAction} = ancestorWithDraft; + const ancestorDrafts = reportActionDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${ancestorReport.reportID}`]; + const ancestorReportActionDraft = ancestorDrafts?.[ancestorReportAction.reportActionID]; + + editingReportID = ancestorReport.reportID; + editingReportActionID = ancestorReportAction.reportActionID; + editingReportAction = ancestorReportAction; + const nextMessage = ancestorReportActionDraft?.message ?? null; + + if (editingState === null) { + setEditingState('editing'); + } + if (editingMessage == null && editingMessage !== nextMessage) { + setEditingMessage(nextMessage); + } + } else if (parentReportAction && parentReportDrafts?.[parentReportAction.reportActionID]) { const parentReportActionDraft = parentReportDrafts[parentReportAction.reportActionID]; editingReportID = parentReportID ?? null; From fc0f17cc18635817d1f66f220cc68fa497426521 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 17 Mar 2026 15:05:27 +0100 Subject: [PATCH 111/233] refactor: split context into separate state and actions --- .../ComposerWithSuggestions.tsx | 6 +-- .../ReportActionCompose/useEditMessage.ts | 4 +- .../report/ReportActionEditMessageContext.tsx | 52 ++++++++++++------- .../report/ReportActionItemMessageEdit.tsx | 5 +- 4 files changed, 40 insertions(+), 27 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index c56f1d41bfd9..44d27a6326f4 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -38,7 +38,7 @@ import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManag import {isValidReportIDFromPath, shouldAutoFocusOnKeyPress} from '@libs/ReportUtils'; import updateMultilineInputRange from '@libs/updateMultilineInputRange'; import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; -import {useReportActionActiveEdit} from '@pages/inbox/report/ReportActionEditMessageContext'; +import {useReportActionActiveEdit, useReportActionActiveEditActions} from '@pages/inbox/report/ReportActionEditMessageContext'; import useDraftMessageVideoAttributeCache from '@pages/inbox/report/useDraftMessageVideoAttributeCache'; import {isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; import type {OnEmojiSelected} from '@userActions/EmojiPickerAction'; @@ -253,8 +253,8 @@ function ComposerWithSuggestions({ const composerRef = useRef(null); - const {editingState, editingReportActionID, editingReportAction, editingMessage, setEditingMessage, currentEditMessageSelection, setCurrentEditMessageSelection} = - useReportActionActiveEdit(); + const {editingState, editingReportActionID, editingReportAction, editingMessage, currentEditMessageSelection} = useReportActionActiveEdit(); + const {setEditingMessage, setCurrentEditMessageSelection} = useReportActionActiveEditActions(); const [value, setValue] = useState(() => { const initialValue = shouldUseNarrowLayout ? (editingMessage ?? draftComment) : draftComment; diff --git a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts index c6402930b653..1a87463e27e7 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts @@ -15,7 +15,7 @@ import focusEditAfterCancelDelete from '@libs/focusEditAfterCancelDelete'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import {getOriginalReportID} from '@libs/ReportUtils'; import * as ReportActionContextMenu from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; -import {useReportActionActiveEdit} from '@pages/inbox/report/ReportActionEditMessageContext'; +import {useReportActionActiveEditActions} from '@pages/inbox/report/ReportActionEditMessageContext'; import {draftMessageVideoAttributeCache} from '@pages/inbox/report/useDraftMessageVideoAttributeCache'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; @@ -47,7 +47,7 @@ function useEditMessage({reportID, originalReportID, reportAction, shouldScrollT const isOriginalParentReportArchived = useReportIsArchived(originalParentReportID); const ancestors = useAncestors(originalReport); - const {setEditingState} = useReportActionActiveEdit(); + const {setEditingState} = useReportActionActiveEditActions(); useEffect(() => { // required for keeping last state of isFocused variable diff --git a/src/pages/inbox/report/ReportActionEditMessageContext.tsx b/src/pages/inbox/report/ReportActionEditMessageContext.tsx index bbbdae64b2ef..ecf722468ca1 100644 --- a/src/pages/inbox/report/ReportActionEditMessageContext.tsx +++ b/src/pages/inbox/report/ReportActionEditMessageContext.tsx @@ -21,11 +21,14 @@ type ReportActionActiveEdit = { }; type ReportActionEditMessageContextValue = ReportActionActiveEdit & { - setEditingMessage: Dispatch>; currentEditMessageSelection: TextSelection | null; - setCurrentEditMessageSelection: Dispatch>; editingState: EditingState | null; +}; + +type ReportActionEditMessageContextActions = { setEditingState: Dispatch>; + setEditingMessage: Dispatch>; + setCurrentEditMessageSelection: Dispatch>; }; const ReportActionEditMessageContext = createContext({ @@ -33,11 +36,14 @@ const ReportActionEditMessageContext = createContext({ setEditingState: NOOP, + setEditingMessage: NOOP, + setCurrentEditMessageSelection: NOOP, }); type ReportActionEditMessageContextProviderProps = { @@ -147,22 +153,24 @@ function ReportActionEditMessageContextProvider({reportID, parentReportID, paren setCurrentEditMessageSelectionState(setSelectionStateAction); }; + const reportActionEditMessageContextValue: ReportActionEditMessageContextValue = { + editingState, + editingReportID, + editingReportActionID, + editingReportAction, + editingMessage, + currentEditMessageSelection, + }; + + const actions: ReportActionEditMessageContextActions = { + setEditingState, + setEditingMessage, + setCurrentEditMessageSelection, + }; + return ( - - {children} + + {children} ); } @@ -171,5 +179,9 @@ function useReportActionActiveEdit() { return useContext(ReportActionEditMessageContext); } -export {ReportActionEditMessageContextProvider, useReportActionActiveEdit, ReportActionEditMessageContext}; +function useReportActionActiveEditActions() { + return useContext(ReportActionEditMessageActionsContext); +} + +export {ReportActionEditMessageContextProvider, useReportActionActiveEdit, useReportActionActiveEditActions, ReportActionEditMessageContext}; export type {ReportActionActiveEdit, ReportActionEditMessageContextValue}; diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index e679f8c6a247..3722246f4ff8 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -44,7 +44,7 @@ import SendOrSaveButton from './ReportActionCompose/SendOrSaveButton'; import Suggestions from './ReportActionCompose/Suggestions'; import useDebouncedCommentMaxLengthValidation from './ReportActionCompose/useDebouncedCommentMaxLengthValidation'; import useEditMessage from './ReportActionCompose/useEditMessage'; -import {useReportActionActiveEdit} from './ReportActionEditMessageContext'; +import {useReportActionActiveEdit, useReportActionActiveEditActions} from './ReportActionEditMessageContext'; import shouldUseEmojiPickerSelection from './shouldUseEmojiPickerSelection'; import useDraftMessageVideoAttributeCache from './useDraftMessageVideoAttributeCache'; @@ -96,7 +96,8 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy const tag = useSharedValue(-1); const emojisPresentBefore = useRef([]); - const {currentEditMessageSelection, setCurrentEditMessageSelection, editingMessage, setEditingMessage} = useReportActionActiveEdit(); + const {currentEditMessageSelection, editingMessage} = useReportActionActiveEdit(); + const {setEditingMessage, setCurrentEditMessageSelection} = useReportActionActiveEditActions(); const [draft, setDraft] = useState(() => { if (editingMessage) { emojisPresentBefore.current = extractEmojis(editingMessage); From 8865cb1e85cbc7e00d499770bde223563c402d9f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 17 Mar 2026 15:38:24 +0100 Subject: [PATCH 112/233] fix: update editing state if report action changes --- .../report/ReportActionEditMessageContext.tsx | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/pages/inbox/report/ReportActionEditMessageContext.tsx b/src/pages/inbox/report/ReportActionEditMessageContext.tsx index ecf722468ca1..ed8c9e8c4092 100644 --- a/src/pages/inbox/report/ReportActionEditMessageContext.tsx +++ b/src/pages/inbox/report/ReportActionEditMessageContext.tsx @@ -89,6 +89,15 @@ function ReportActionEditMessageContextProvider({reportID, parentReportID, paren return ancestorDraft?.message !== undefined; }); + const updateMessage = (nextMessage: string | null) => { + const isInitialEdit = editingMessage == null && editingMessage !== nextMessage; + const didReportActionChange = prevEditingReportActionID !== editingReportActionID; + if (isInitialEdit || didReportActionChange) { + setEditingMessage(nextMessage); + setPrevEditingReportActionID(editingReportActionID); + } + }; + if (ancestorWithDraft) { const {report: ancestorReport, reportAction: ancestorReportAction} = ancestorWithDraft; const ancestorDrafts = reportActionDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${ancestorReport.reportID}`]; @@ -97,14 +106,10 @@ function ReportActionEditMessageContextProvider({reportID, parentReportID, paren editingReportID = ancestorReport.reportID; editingReportActionID = ancestorReportAction.reportActionID; editingReportAction = ancestorReportAction; - const nextMessage = ancestorReportActionDraft?.message ?? null; if (editingState === null) { setEditingState('editing'); } - if (editingMessage == null && editingMessage !== nextMessage) { - setEditingMessage(nextMessage); - } } else if (parentReportAction && parentReportDrafts?.[parentReportAction.reportActionID]) { const parentReportActionDraft = parentReportDrafts[parentReportAction.reportActionID]; @@ -116,9 +121,8 @@ function ReportActionEditMessageContextProvider({reportID, parentReportID, paren if (editingState === null) { setEditingState('editing'); } - if (editingMessage == null && editingMessage !== nextMessage) { - setEditingMessage(nextMessage); - } + const nextMessage = ancestorReportActionDraft?.message ?? null; + updateMessage(nextMessage); } else if (reportDrafts) { const reportDraftEntry = Object.entries(reportDrafts).find(([, draft]) => draft?.message !== undefined); @@ -128,14 +132,13 @@ function ReportActionEditMessageContextProvider({reportID, parentReportID, paren editingReportID = reportID ?? null; editingReportActionID = reportActionIDOfDraft; editingReportAction = reportActions?.[reportActionIDOfDraft] ?? null; - const nextMessage = reportActionDraft?.message ?? null; if (editingState === null) { setEditingState('editing'); } - if (editingMessage == null && editingMessage !== nextMessage) { - setEditingMessage(nextMessage); - } + + const nextMessage = reportActionDraft?.message ?? null; + updateMessage(nextMessage); } } } From 34240c3522be9c9eb7050def64234ad34c50747a Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 17 Mar 2026 15:39:15 +0100 Subject: [PATCH 113/233] refactor: remove redundant `parentReportAction` path in favor of ancestors --- src/pages/inbox/ReportScreen.tsx | 6 +----- .../report/ReportActionEditMessageContext.tsx | 20 ++----------------- 2 files changed, 3 insertions(+), 23 deletions(-) diff --git a/src/pages/inbox/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx index a47d2b80c598..881ee186f6c4 100644 --- a/src/pages/inbox/ReportScreen.tsx +++ b/src/pages/inbox/ReportScreen.tsx @@ -1029,11 +1029,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { return ( // Wide RHP overlays should be rendered only for the report screen displayed in RHP - + (null); + const [prevEditingReportActionID, setPrevEditingReportActionID] = useState(null); const [editingMessage, setEditingMessage] = useState(null); const [currentEditMessageSelection, setCurrentEditMessageSelectionState] = useState(null); const reportDrafts = reportActionDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`]; - let parentReportDrafts: OnyxTypes.ReportActionsDrafts | undefined; - if (parentReportAction && parentReportID) { - parentReportDrafts = reportActionDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${parentReportID}`]; - } - let editingReportID: string | null = null; let editingReportActionID: string | null = null; let editingReportAction: OnyxTypes.ReportAction | null = null; @@ -110,17 +104,7 @@ function ReportActionEditMessageContextProvider({reportID, parentReportID, paren if (editingState === null) { setEditingState('editing'); } - } else if (parentReportAction && parentReportDrafts?.[parentReportAction.reportActionID]) { - const parentReportActionDraft = parentReportDrafts[parentReportAction.reportActionID]; - - editingReportID = parentReportID ?? null; - editingReportActionID = parentReportAction.reportActionID; - editingReportAction = parentReportAction; - const nextMessage = parentReportActionDraft?.message ?? null; - if (editingState === null) { - setEditingState('editing'); - } const nextMessage = ancestorReportActionDraft?.message ?? null; updateMessage(nextMessage); } else if (reportDrafts) { From 16f7df3846c0cc1e2c0ab9fb408099f6b1018e73 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 17 Mar 2026 16:02:56 +0100 Subject: [PATCH 114/233] fix: text selection on comment update and refactor --- .../ComposerWithSuggestions.tsx | 25 ++++++------------- .../report/ReportActionEditMessageContext.tsx | 8 +++++- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 44d27a6326f4..b8b51acc4dab 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -282,12 +282,6 @@ function ComposerWithSuggestions({ const commentRef = useRef(value); - const wasEditing = useRef(!!editingReportActionID); - - const wasEditingInComposerRef = useRef(shouldUseNarrowLayout); - const previousEditingReportActionIDRef = useRef(editingReportActionID ?? null); - const previousDraftSelectionRef = useRef(null); - const updateSelectionImperatively = useCallback((start: number, end: number) => { if (!isIOSNative) { return; @@ -331,17 +325,16 @@ function ComposerWithSuggestions({ [currentEditMessageSelection, updateSelectionImperatively], ); + const wasEditing = useRef(!!editingReportActionID); + const wasEditingInComposerRef = useRef(shouldUseNarrowLayout); + const previousDraftSelectionRef = useRef(null); + useEffect(() => { if (editingState === 'submitted') { return; } const isEditing = editingState === 'editing'; - const previousEditingReportActionID = previousEditingReportActionIDRef.current; - const currentEditingReportActionID = editingReportActionID ?? null; - const didChangeEditedAction = isEditing && previousEditingReportActionID && currentEditingReportActionID && previousEditingReportActionID !== currentEditingReportActionID; - - previousEditingReportActionIDRef.current = currentEditingReportActionID; if (!isEditing) { if (wasEditing.current && wasEditingInComposerRef.current) { @@ -373,12 +366,6 @@ function ComposerWithSuggestions({ return; } - // We are already in editing mode, but the target message changed. - if (didChangeEditedAction && shouldUseNarrowLayout) { - applyComposerValue(editingMessage ?? '', {isEditingInComposer: true}); - return; - } - // Editing is ongoing and layout toggled from wide to narrow. if (shouldUseNarrowLayout && !wasEditingInComposerRef.current) { wasEditingInComposerRef.current = true; @@ -392,6 +379,10 @@ function ComposerWithSuggestions({ wasEditingInComposerRef.current = false; applyComposerValue(draftComment ?? ''); } + + if (shouldUseNarrowLayout) { + applyComposerValue(editingMessage ?? '', {isEditingInComposer: true}); + } }, [applyComposerValue, draftComment, editingMessage, editingReportActionID, editingState, selection, shouldUseNarrowLayout, updateSelectionImperatively]); const {superWideRHPRouteKeys} = useWideRHPState(); diff --git a/src/pages/inbox/report/ReportActionEditMessageContext.tsx b/src/pages/inbox/report/ReportActionEditMessageContext.tsx index 2959e8979c06..fe044e89dc6f 100644 --- a/src/pages/inbox/report/ReportActionEditMessageContext.tsx +++ b/src/pages/inbox/report/ReportActionEditMessageContext.tsx @@ -84,11 +84,17 @@ function ReportActionEditMessageContextProvider({reportID, children}: ReportActi }); const updateMessage = (nextMessage: string | null) => { - const isInitialEdit = editingMessage == null && editingMessage !== nextMessage; + if (nextMessage == null) { + return; + } + + const isInitialEdit = editingMessage == null; const didReportActionChange = prevEditingReportActionID !== editingReportActionID; if (isInitialEdit || didReportActionChange) { setEditingMessage(nextMessage); setPrevEditingReportActionID(editingReportActionID); + const defaultSelection: TextSelection = {start: nextMessage.length, end: nextMessage.length}; + setCurrentEditMessageSelectionState(defaultSelection); } }; From 0ca44fce374e440e9cb60f40ec5250f5568c4817 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 17 Mar 2026 16:14:55 +0100 Subject: [PATCH 115/233] fix: TS checks --- .../MoneyRequestReportActionsList.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index b2b500a86f67..e1628aeef4da 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -58,7 +58,7 @@ import { isReportActionVisible, wasMessageReceivedWhileOffline, } from '@libs/ReportActionsUtils'; -import {canUserPerformWriteAction, chatIncludesChronosWithID, getOriginalReportID, getReportLastVisibleActionCreated, isHarvestCreatedExpenseReport, isUnread} from '@libs/ReportUtils'; +import {canUserPerformWriteAction, chatIncludesChronosWithID, getReportLastVisibleActionCreated, isHarvestCreatedExpenseReport, isUnread} from '@libs/ReportUtils'; import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import {isTransactionPendingDelete} from '@libs/TransactionUtils'; @@ -684,8 +684,6 @@ function MoneyRequestReportActionsList({ !isConsecutiveChronosAutomaticTimerAction(visibleReportActions, index, chatIncludesChronosWithID(reportAction?.reportID)) && hasNextActionMadeBySameActor(visibleReportActions, index); - const originalReportID = getOriginalReportID(report.reportID, reportAction, reportActionsObject); - return ( Date: Wed, 18 Mar 2026 11:52:21 +0100 Subject: [PATCH 116/233] refactor: rename callback --- .../report/ReportActionCompose/ReportActionCompose.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 77ccdab0bd2b..04ad95fb393e 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -464,7 +464,7 @@ function ReportActionCompose({ // useSharedValue on web doesn't support functions, so we need to wrap it in an object. const composerRefShared = useSharedValue>({}); - const sendMessage = useCallback(() => { + const submitDraft = useCallback(() => { if (isSendDisabled || !debouncedCommentMaxLengthValidation.flush()) { return; } @@ -490,7 +490,7 @@ function ReportActionCompose({ clearWorklet?.(); }); }, [isSendDisabled, debouncedCommentMaxLengthValidation, isComposerFullSize, isEditingInComposer, effectiveDraft, draftComment, reportID, submitForm, composerRefShared]); - onSubmitAction = sendMessage; + onSubmitAction = submitDraft; const emojiPositionValues = useMemo( () => ({ @@ -638,7 +638,7 @@ function ReportActionCompose({ onClear={submitForm} disabled={isBlockedFromConcierge || isEmojiPickerVisible()} setIsCommentEmpty={setIsCommentEmpty} - onEnterKeyPress={sendMessage} + onEnterKeyPress={submitDraft} onFocus={onFocus} onBlur={onBlur} measureParentContainer={measureContainer} @@ -685,7 +685,7 @@ function ReportActionCompose({ {ErrorModal} From e559a9f8541703ace997226e2c9e1e4fa2b620cc Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 18 Mar 2026 12:13:06 +0100 Subject: [PATCH 117/233] fix: reset whole state on edit end --- src/pages/inbox/report/ReportActionEditMessageContext.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pages/inbox/report/ReportActionEditMessageContext.tsx b/src/pages/inbox/report/ReportActionEditMessageContext.tsx index fe044e89dc6f..b7bfe0e5c2cd 100644 --- a/src/pages/inbox/report/ReportActionEditMessageContext.tsx +++ b/src/pages/inbox/report/ReportActionEditMessageContext.tsx @@ -136,6 +136,8 @@ function ReportActionEditMessageContextProvider({reportID, children}: ReportActi if (editingReportID == null && editingState !== null) { setEditingState(null); setEditingMessage(null); + setPrevEditingReportActionID(null); + setCurrentEditMessageSelectionState(null); } const setCurrentEditMessageSelection = (setSelectionStateAction: SetStateAction) => { From 03c9d879ba33546cf9c67e49600f9343fe2cf06f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 18 Mar 2026 12:14:06 +0100 Subject: [PATCH 118/233] fix: composer height on edit --- .../ReportActionCompose.tsx | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 04ad95fb393e..b69fba1cde1a 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -168,7 +168,19 @@ function ReportActionCompose({ const {editingReportID, editingReportActionID, editingReportAction, editingMessage} = useReportActionActiveEdit(); - const isEditingInComposer = shouldUseNarrowLayout && !!editingReportActionID; + const [didResetComposerHeight, setDidResetComposerHeight] = useState(false); + const isEditingInComposer = shouldUseNarrowLayout && !!editingReportActionID && !didResetComposerHeight; + + console.log({didResetComposerHeight, isEditingInComposer, editingReportActionID}); + + useEffect(() => { + if (editingReportActionID || !didResetComposerHeight) { + return; + } + + setDidResetComposerHeight(false); + }, [didResetComposerHeight, editingReportActionID]); + const effectiveDraft = shouldUseNarrowLayout ? editingMessage : draftComment; const reportActionEntries = useMemo(() => (reportActions ? Object.entries(reportActions) : []), [reportActions]); @@ -351,7 +363,8 @@ function ReportActionCompose({ (draftMessage: string) => { const draftMessageTrimmed = draftMessage.trim(); - if (isEditingInComposer && !attachmentFileRef.current) { + const isSubmittingEdit = isEditingInComposer || didResetComposerHeight; + if (isSubmittingEdit && !attachmentFileRef.current) { publishDraft(draftMessageTrimmed); return; } @@ -398,9 +411,10 @@ function ReportActionCompose({ }, [ isEditingInComposer, - publishDraft, + didResetComposerHeight, shouldShowConciergeIndicator, kickoffWaitingIndicator, + publishDraft, transactionThreadReport, report, reportID, @@ -408,8 +422,8 @@ function ReportActionCompose({ currentUserPersonalDetails.accountID, personalDetail.timezone, isInSidePanel, - onSubmit, scrollOffsetRef, + onSubmit, ], ); @@ -479,6 +493,9 @@ function ReportActionCompose({ } composerRef.current?.resetHeight(); + if (isEditingInComposer) { + setDidResetComposerHeight(true); + } scheduleOnUI(() => { const {clearWorklet} = composerRefShared.get(); From 99f85eb68c82bdf2e54ba439da27d299f98bf9ca Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 18 Mar 2026 12:15:16 +0100 Subject: [PATCH 119/233] remove console.log --- .../inbox/report/ReportActionCompose/ReportActionCompose.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index b69fba1cde1a..205c51502012 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -171,8 +171,6 @@ function ReportActionCompose({ const [didResetComposerHeight, setDidResetComposerHeight] = useState(false); const isEditingInComposer = shouldUseNarrowLayout && !!editingReportActionID && !didResetComposerHeight; - console.log({didResetComposerHeight, isEditingInComposer, editingReportActionID}); - useEffect(() => { if (editingReportActionID || !didResetComposerHeight) { return; From 4142b734ba2c94fb490217e91ddf604786ef42cd Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 18 Mar 2026 13:39:37 +0100 Subject: [PATCH 120/233] fix: TS and ESLint errors --- .../MoneyRequestReportActionsList.tsx | 9 --------- tests/ui/ReportActionItemMessageEditTest.tsx | 1 - tests/utils/ReportActionComposeUtils.tsx | 1 - 3 files changed, 11 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index e1628aeef4da..34f50330041e 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -175,15 +175,6 @@ function MoneyRequestReportActionsList({ const transactionsWithoutPendingDelete = useMemo(() => transactions.filter((t) => !isTransactionPendingDelete(t)), [transactions]); const mostRecentIOUReportActionID = useMemo(() => getMostRecentIOURequestActionID(reportActions), [reportActions]); - // reportActions is passed as an array because it's sorted chronologically for FlatList rendering and pagination. - // However, getOriginalReportID expects the Onyx object format (keyed by reportActionID) for efficient lookups. - const reportActionsObject = useMemo(() => { - const obj: OnyxTypes.ReportActions = {}; - for (const action of reportActions) { - obj[action.reportActionID] = action; - } - return obj; - }, [reportActions]); const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, reportActions ?? [], false, reportTransactionIDs); const firstVisibleReportActionID = useMemo(() => getFirstVisibleReportActionID(reportActions, isOffline), [reportActions, isOffline]); const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`); diff --git a/tests/ui/ReportActionItemMessageEditTest.tsx b/tests/ui/ReportActionItemMessageEditTest.tsx index 5587f9027f79..df672efbe6ab 100644 --- a/tests/ui/ReportActionItemMessageEditTest.tsx +++ b/tests/ui/ReportActionItemMessageEditTest.tsx @@ -51,7 +51,6 @@ TestHelper.setupGlobalFetchMock(); const defaultReport = LHNTestUtils.getFakeReport(); const defaultProps: ReportActionItemMessageEditProps = { action: LHNTestUtils.getFakeReportAction(), - draftMessage: '', reportID: defaultReport.reportID, originalReportID: defaultReport.reportID, index: 0, diff --git a/tests/utils/ReportActionComposeUtils.tsx b/tests/utils/ReportActionComposeUtils.tsx index 763e84b1e5db..c7a42c5a149e 100644 --- a/tests/utils/ReportActionComposeUtils.tsx +++ b/tests/utils/ReportActionComposeUtils.tsx @@ -48,7 +48,6 @@ const renderReportActionCompose = (props?: Partial) => const defaultReportActionItemMessageEditProps: ReportActionItemMessageEditProps = { action: LHNTestUtils.getFakeReportAction(), - draftMessage: '', reportID: defaultReport.reportID, originalReportID: defaultReport.reportID, index: 0, From c090745409b590d1bdce3c8b7ce38907ae506b12 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 18 Mar 2026 14:28:37 +0100 Subject: [PATCH 121/233] fix: send button disabled state --- .../ReportActionCompose.tsx | 88 ++++++++++--------- 1 file changed, 45 insertions(+), 43 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 205c51502012..54ac7d61148b 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -179,8 +179,6 @@ function ReportActionCompose({ setDidResetComposerHeight(false); }, [didResetComposerHeight, editingReportActionID]); - const effectiveDraft = shouldUseNarrowLayout ? editingMessage : draftComment; - const reportActionEntries = useMemo(() => (reportActions ? Object.entries(reportActions) : []), [reportActions]); const isEditingLastReportAction = useMemo(() => editingReportActionID === reportActionEntries.at(-1)?.[0], [editingReportActionID, reportActionEntries]); @@ -201,6 +199,7 @@ function ReportActionCompose({ const {isScrollLayoutTriggered, raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); + const effectiveDraft = isEditingInComposer ? editingMessage : draftComment; const [isCommentEmpty, setIsCommentEmpty] = useState(() => { return !effectiveDraft || !!effectiveDraft.match(CONST.REGEX.EMPTY_COMMENT); }); @@ -354,10 +353,17 @@ function ReportActionCompose({ composerRef, }); + const isSubmittingDraftCommentDisabled = isBlockedFromConcierge || isExceedingMaxLength || isCommentEmpty; + const isSendDisabled = !isEditingInComposer && isSubmittingDraftCommentDisabled; + + // Note: using JS refs is not well supported in reanimated, thus we need to store the function in a shared value + // useSharedValue on web doesn't support functions, so we need to wrap it in an object. + const composerRefShared = useSharedValue>({}); + /** * Add or edit a comment in the composer */ - const submitForm = useCallback( + const validateAndSubmitDraft = useCallback( (draftMessage: string) => { const draftMessageTrimmed = draftMessage.trim(); @@ -425,6 +431,39 @@ function ReportActionCompose({ ], ); + const submitDraftAndClearComposer = useCallback(() => { + if (isSendDisabled || !debouncedCommentMaxLengthValidation.flush()) { + return; + } + + if (isComposerFullSize) { + setIsComposerFullSize(reportID, false); + } + + // If there is a draft comment and we are submitting an edit, we don't want to clear the composer height and the draft comment. + // Therefore, we directly trigger the validation and submission of the draft comment. + if (isEditingInComposer && editingMessage !== null && draftComment) { + validateAndSubmitDraft(editingMessage); + return; + } + + composerRef.current?.resetHeight(); + if (isEditingInComposer) { + setDidResetComposerHeight(true); + } + + scheduleOnUI(() => { + const {clearWorklet} = composerRefShared.get(); + + if (!clearWorklet) { + throw new Error('The composerRef.clearWorklet function is not set yet. This should never happen, and indicates a developer error.'); + } + + clearWorklet?.(); + }); + }, [isSendDisabled, debouncedCommentMaxLengthValidation, isEditingInComposer, editingMessage, draftComment, isComposerFullSize, validateAndSubmitDraft, reportID, composerRefShared]); + onSubmitAction = submitDraftAndClearComposer; + const onTriggerAttachmentPicker = useCallback(() => { isNextModalWillOpenRef.current = true; isKeyboardVisibleWhenShowingModalRef.current = true; @@ -470,43 +509,6 @@ function ReportActionCompose({ const hasReportRecipient = !isEmptyObject(reportRecipient); - const isSendDisabled = !isEditingInComposer && (isBlockedFromConcierge || isExceedingMaxLength || isCommentEmpty); - - // Note: using JS refs is not well supported in reanimated, thus we need to store the function in a shared value - // useSharedValue on web doesn't support functions, so we need to wrap it in an object. - const composerRefShared = useSharedValue>({}); - - const submitDraft = useCallback(() => { - if (isSendDisabled || !debouncedCommentMaxLengthValidation.flush()) { - return; - } - - if (isComposerFullSize) { - setIsComposerFullSize(reportID, false); - } - - if (isEditingInComposer && effectiveDraft && draftComment) { - submitForm(effectiveDraft); - return; - } - - composerRef.current?.resetHeight(); - if (isEditingInComposer) { - setDidResetComposerHeight(true); - } - - scheduleOnUI(() => { - const {clearWorklet} = composerRefShared.get(); - - if (!clearWorklet) { - throw new Error('The composerRef.clearWorklet function is not set yet. This should never happen, and indicates a developer error.'); - } - - clearWorklet?.(); - }); - }, [isSendDisabled, debouncedCommentMaxLengthValidation, isComposerFullSize, isEditingInComposer, effectiveDraft, draftComment, reportID, submitForm, composerRefShared]); - onSubmitAction = submitDraft; - const emojiPositionValues = useMemo( () => ({ secondaryRowHeight: styles.chatItemComposeSecondaryRow.height, @@ -650,10 +652,10 @@ function ReportActionCompose({ isComposerFullSize={isComposerFullSize} setIsFullComposerAvailable={setIsFullComposerAvailable} onPasteFile={(files) => validateAttachments({files})} - onClear={submitForm} + onClear={validateAndSubmitDraft} disabled={isBlockedFromConcierge || isEmojiPickerVisible()} setIsCommentEmpty={setIsCommentEmpty} - onEnterKeyPress={submitDraft} + onEnterKeyPress={submitDraftAndClearComposer} onFocus={onFocus} onBlur={onBlur} measureParentContainer={measureContainer} @@ -700,7 +702,7 @@ function ReportActionCompose({ {ErrorModal} From 8c544f4fc9ac204d98da1d6f7976a17fd97a0ed3 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 18 Mar 2026 14:47:25 +0100 Subject: [PATCH 122/233] fix: send button disabled after editing --- .../ReportActionCompose/ComposerWithSuggestions.tsx | 8 +++++--- .../report/ReportActionCompose/ReportActionCompose.tsx | 5 +++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index b8b51acc4dab..a3b45884c284 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -256,8 +256,9 @@ function ComposerWithSuggestions({ const {editingState, editingReportActionID, editingReportAction, editingMessage, currentEditMessageSelection} = useReportActionActiveEdit(); const {setEditingMessage, setCurrentEditMessageSelection} = useReportActionActiveEditActions(); + const isEditingInComposer = shouldUseNarrowLayout && editingState !== null; const [value, setValue] = useState(() => { - const initialValue = shouldUseNarrowLayout ? (editingMessage ?? draftComment) : draftComment; + const initialValue = isEditingInComposer ? (editingMessage ?? draftComment) : draftComment; if (initialValue) { emojisPresentBefore.current = extractEmojis(initialValue); @@ -383,7 +384,7 @@ function ComposerWithSuggestions({ if (shouldUseNarrowLayout) { applyComposerValue(editingMessage ?? '', {isEditingInComposer: true}); } - }, [applyComposerValue, draftComment, editingMessage, editingReportActionID, editingState, selection, shouldUseNarrowLayout, updateSelectionImperatively]); + }, [applyComposerValue, draftComment, editingMessage, editingState, selection, shouldUseNarrowLayout, updateSelectionImperatively]); const {superWideRHPRouteKeys} = useWideRHPState(); // When SearchReport is stacked above another RHP, delay autofocus until after the transition completes to avoid animation jank @@ -585,7 +586,7 @@ function ComposerWithSuggestions({ const isPrevCommentEmpty = !!commentRef.current.match(/^(\s)*$/); /** Only update isCommentEmpty state if it's different from previous one */ - if (isNewCommentEmpty !== isPrevCommentEmpty) { + if (!isEditingInComposer && isNewCommentEmpty !== isPrevCommentEmpty) { setIsCommentEmpty(isNewCommentEmpty); } emojisPresentBefore.current = emojis; @@ -639,6 +640,7 @@ function ComposerWithSuggestions({ editingReportActionID, editingState, findNewlyAddedChars, + isEditingInComposer, preferredLocale, preferredSkinTone, raiseIsScrollLikelyLayoutTriggered, diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 54ac7d61148b..b930b264418d 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -166,11 +166,12 @@ function ReportActionCompose({ canEvict: false, }); - const {editingReportID, editingReportActionID, editingReportAction, editingMessage} = useReportActionActiveEdit(); + const {editingState, editingReportID, editingReportActionID, editingReportAction, editingMessage} = useReportActionActiveEdit(); const [didResetComposerHeight, setDidResetComposerHeight] = useState(false); - const isEditingInComposer = shouldUseNarrowLayout && !!editingReportActionID && !didResetComposerHeight; + const isEditingInComposer = shouldUseNarrowLayout && editingState !== null && !didResetComposerHeight; + console.log({isEditingInComposer, editingState, editingReportActionID}); useEffect(() => { if (editingReportActionID || !didResetComposerHeight) { return; From 33c37bc15ee16313195fb5edd4dc6d66bae2d6ee Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 18 Mar 2026 14:47:40 +0100 Subject: [PATCH 123/233] refactor: use editing state instead of report action id --- .../report/ReportActionCompose/ComposerWithSuggestions.tsx | 4 ++-- .../inbox/report/ReportActionCompose/ReportActionCompose.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index a3b45884c284..a8d5ade6c6ed 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -271,7 +271,7 @@ function ComposerWithSuggestions({ useDraftMessageVideoAttributeCache({ draftMessage: value, - isEditing: !!editingReportActionID, + isEditing: editingState !== null, editingReportAction, updateDraftMessage: setValue, isEditInProgressRef: isDraftPendingSaved, @@ -326,7 +326,7 @@ function ComposerWithSuggestions({ [currentEditMessageSelection, updateSelectionImperatively], ); - const wasEditing = useRef(!!editingReportActionID); + const wasEditing = useRef(editingState !== null); const wasEditingInComposerRef = useRef(shouldUseNarrowLayout); const previousDraftSelectionRef = useRef(null); diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index b930b264418d..edc045389711 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -173,12 +173,12 @@ function ReportActionCompose({ console.log({isEditingInComposer, editingState, editingReportActionID}); useEffect(() => { - if (editingReportActionID || !didResetComposerHeight) { + if (editingState !== null || !didResetComposerHeight) { return; } setDidResetComposerHeight(false); - }, [didResetComposerHeight, editingReportActionID]); + }, [didResetComposerHeight, editingState]); const reportActionEntries = useMemo(() => (reportActions ? Object.entries(reportActions) : []), [reportActions]); const isEditingLastReportAction = useMemo(() => editingReportActionID === reportActionEntries.at(-1)?.[0], [editingReportActionID, reportActionEntries]); From 4c13fd2ad3d1c4386294ab49e5b8c70554936653 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 18 Mar 2026 14:47:56 +0100 Subject: [PATCH 124/233] fix: remove console.log --- .../inbox/report/ReportActionCompose/ReportActionCompose.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index edc045389711..27e0346cfbf1 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -171,7 +171,6 @@ function ReportActionCompose({ const [didResetComposerHeight, setDidResetComposerHeight] = useState(false); const isEditingInComposer = shouldUseNarrowLayout && editingState !== null && !didResetComposerHeight; - console.log({isEditingInComposer, editingState, editingReportActionID}); useEffect(() => { if (editingState !== null || !didResetComposerHeight) { return; From ac4ef84f660fcfb209ce4aaa3ef7c8e6da245b8c Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 18 Mar 2026 14:53:12 +0100 Subject: [PATCH 125/233] refactor: extract and memoize styles --- .../ReportActionCompose.tsx | 42 ++++++++++++++----- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 27e0346cfbf1..d62da0fc42a2 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -567,6 +567,34 @@ function ReportActionCompose({ const fsClass = FS.getChatFSClass(report); + const containerStyles = useMemo( + () => [ + shouldUseFocusedColor ? styles.chatItemComposeBoxFocusedColor : styles.chatItemComposeBoxColor, + styles.flexRow, + styles.chatItemComposeBox, + isComposerFullSize && styles.chatItemFullComposeBox, + isExceedingMaxLength && styles.borderColorDanger, + ], + [ + shouldUseFocusedColor, + styles.chatItemComposeBoxFocusedColor, + styles.chatItemComposeBoxColor, + styles.flexRow, + styles.chatItemComposeBox, + styles.chatItemFullComposeBox, + styles.borderColorDanger, + isComposerFullSize, + isExceedingMaxLength, + ], + ); + + const editingButtonsContainerStyles = useMemo( + () => [styles.dFlex, styles.alignItemsCenter, styles.flexWrap, styles.justifyContentCenter, {paddingVertical: styles.composerSizeButton.marginHorizontal}], + [styles.alignItemsCenter, styles.composerSizeButton.marginHorizontal, styles.dFlex, styles.flexWrap, styles.justifyContentCenter], + ); + + const expandCollapseComposerButtonStyles = useMemo(() => [styles.flexGrow1, styles.flexShrink0], [styles.flexGrow1, styles.flexShrink0]); + return ( @@ -581,19 +609,11 @@ function ReportActionCompose({ > {PDFValidationComponent} {isEditingInComposer ? ( - + From 9bbe7b3bcc7790114fbce65a5187e2b12ed1960c Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 18 Mar 2026 14:55:51 +0100 Subject: [PATCH 126/233] refactor: simplify `ExpandCollapseComposerButton` component and remove duplicate code --- .../ExpandCollapseComposerButton.tsx | 79 +++++++------------ 1 file changed, 29 insertions(+), 50 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ExpandCollapseComposerButton.tsx b/src/pages/inbox/report/ReportActionCompose/ExpandCollapseComposerButton.tsx index c3a3cdb4e5b9..4485f2c9d136 100644 --- a/src/pages/inbox/report/ReportActionCompose/ExpandCollapseComposerButton.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ExpandCollapseComposerButton.tsx @@ -37,60 +37,39 @@ function ExpandCollapseComposerButton({ return null; } + const shouldCollapse = isComposerFullSize; + const tooltipText = shouldCollapse ? translate('reportActionCompose.collapse') : translate('reportActionCompose.expand'); + const nextComposerFullSizeValue = !shouldCollapse; + const iconSrc = shouldCollapse ? icons.Collapse : icons.Expand; + const sentryLabel = shouldCollapse ? CONST.SENTRY_LABEL.REPORT.ATTACHMENT_PICKER_COLLAPSE_BUTTON : CONST.SENTRY_LABEL.REPORT.ATTACHMENT_PICKER_EXPAND_BUTTON; + return ( // eslint-disable-next-line react/jsx-props-no-spreading - {isComposerFullSize ? ( - - { - e?.preventDefault(); - raiseIsScrollLikelyLayoutTriggered(); - setIsComposerFullSize(reportID, false); - }} - // Keep focus on the composer when Collapse button is clicked. - onMouseDown={(e) => e.preventDefault()} - style={styles.composerSizeButton} - disabled={disabled} - role={CONST.ROLE.BUTTON} - accessibilityLabel={translate('reportActionCompose.collapse')} - sentryLabel={CONST.SENTRY_LABEL.REPORT.ATTACHMENT_PICKER_COLLAPSE_BUTTON} - > - - - - ) : ( - + { + e?.preventDefault(); + raiseIsScrollLikelyLayoutTriggered(); + setIsComposerFullSize(reportID, nextComposerFullSizeValue); + }} + // Keep focus on the composer when Collapse/Expand button is clicked. + onMouseDown={(e) => e.preventDefault()} + style={styles.composerSizeButton} + disabled={disabled} + role={CONST.ROLE.BUTTON} + accessibilityLabel={tooltipText} + sentryLabel={sentryLabel} > - { - e?.preventDefault(); - raiseIsScrollLikelyLayoutTriggered(); - setIsComposerFullSize(reportID, true); - }} - // Keep focus on the composer when Expand button is clicked. - onMouseDown={(e) => e.preventDefault()} - style={styles.composerSizeButton} - disabled={disabled} - role={CONST.ROLE.BUTTON} - accessibilityLabel={translate('reportActionCompose.expand')} - sentryLabel={CONST.SENTRY_LABEL.REPORT.ATTACHMENT_PICKER_EXPAND_BUTTON} - > - - - - )} + + + ); } From 9db5daa2d4c32762c993073031d9d1f5f52b5b54 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 18 Mar 2026 14:57:12 +0100 Subject: [PATCH 127/233] chore: add back JSDoc comment --- src/libs/actions/Report/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 76c774c86416..6e59c327b9da 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -2831,6 +2831,7 @@ function editReportComment( ); } +/** Clears drafts for all comment report action in a report. */ function clearReportActionDrafts(reportID: string | undefined) { Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`, null); } From fcb41fb2584ab39a02b94d6731ce16cfdae1839f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 18 Mar 2026 15:08:45 +0100 Subject: [PATCH 128/233] test: add integration test for task title length validation in ReportActionCompose --- tests/ui/ReportActionComposeTest.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/ui/ReportActionComposeTest.tsx b/tests/ui/ReportActionComposeTest.tsx index 27c6b26db3bf..9159de2d2f14 100644 --- a/tests/ui/ReportActionComposeTest.tsx +++ b/tests/ui/ReportActionComposeTest.tsx @@ -247,5 +247,23 @@ describe('ReportActionCompose Integration Tests', () => { // And the error should be displayed expect(screen.getByText('composer.commentExceededMaxLength')).toBeOnTheScreen(); }); + + it('should not send when task title length exceeds the limit', async () => { + renderReportActionCompose(); + const composer = screen.getByTestId('composer'); + + // Given a task title that exceeds the title character limit + const taskTitle = 'x'.repeat(CONST.TITLE_CHARACTER_LIMIT + 1); + fireEvent.changeText(composer, `[] ${taskTitle}`); + + // When the message is submitted + act(onSubmitAction); + + // Then the message should NOT be sent + expect(mockForceClearInput).toHaveBeenCalledTimes(0); + + // And the task-title-specific error should be displayed + expect(screen.getByText('composer.taskTitleExceededMaxLength')).toBeOnTheScreen(); + }); }); }); From 59f8a181ef58d2fcf06d8d562e173536c6dfbc2d Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 18 Mar 2026 15:12:58 +0100 Subject: [PATCH 129/233] test: add more ui tests --- tests/ui/ReportActionItemMessageEditTest.tsx | 74 ++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/ui/ReportActionItemMessageEditTest.tsx b/tests/ui/ReportActionItemMessageEditTest.tsx index df672efbe6ab..4290637c2d30 100644 --- a/tests/ui/ReportActionItemMessageEditTest.tsx +++ b/tests/ui/ReportActionItemMessageEditTest.tsx @@ -6,14 +6,18 @@ import ComposeProviders from '@components/ComposeProviders'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import {editReportComment} from '@libs/actions/Report'; +import * as ReportActionContextMenu from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; import ReportActionItemMessageEdit from '@pages/inbox/report/ReportActionItemMessageEdit'; import type {ReportActionItemMessageEditProps} from '@pages/inbox/report/ReportActionItemMessageEdit'; +import {draftMessageVideoAttributeCache} from '@pages/inbox/report/useDraftMessageVideoAttributeCache'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Message} from '@src/types/onyx/ReportAction'; import * as LHNTestUtils from '../utils/LHNTestUtils'; import * as TestHelper from '../utils/TestHelper'; const mockEditReportComment = jest.mocked(editReportComment); +const mockShowDeleteModal = jest.mocked(ReportActionContextMenu.showDeleteModal); jest.mock('@libs/actions/Report', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -25,6 +29,16 @@ jest.mock('@libs/actions/Report', () => { }; }); +jest.mock('@pages/inbox/report/ContextMenu/ReportActionContextMenu', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@pages/inbox/report/ContextMenu/ReportActionContextMenu'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + showDeleteModal: jest.fn(), + }; +}); + jest.mock('@hooks/useLocalize', () => jest.fn(() => ({ translate: jest.fn((key: string) => key), @@ -82,6 +96,7 @@ describe('ReportActionCompose Integration Tests', () => { await act(async () => { await Onyx.clear(); }); + draftMessageVideoAttributeCache.clear(); jest.clearAllMocks(); }); @@ -120,5 +135,64 @@ describe('ReportActionCompose Integration Tests', () => { // And the error should be displayed expect(screen.getByText('composer.commentExceededMaxLength')).toBeOnTheScreen(); }); + + it('should open delete modal when saving an empty message', async () => { + renderReportActionItemMessageEdit(); + const composer = screen.getByTestId('composer'); + const saveChangesButton = screen.getByLabelText('common.saveChanges'); + + // Given a message that becomes empty after trimming + fireEvent.changeText(composer, ' '); + + // When the message is saved + fireEvent.press(saveChangesButton); + + // Then the message should NOT be edited + expect(mockEditReportComment).toHaveBeenCalledTimes(0); + + // And the delete confirmation flow should be opened + expect(mockShowDeleteModal).toHaveBeenCalledTimes(1); + }); + + it('should cache and forward video attributes when saving an edited message', async () => { + const videoSource = 'https://example.com/video.mp4'; + const videoHtml = ``; + + const messages = defaultProps.action.message as Message[]; + + renderReportActionItemMessageEdit({ + action: { + ...defaultProps.action, + message: [ + { + ...messages.at(0), + type: 'COMMENT', + html: videoHtml, + text: '[Attachment]', + }, + ], + }, + }); + + const composer = screen.getByTestId('composer'); + const saveChangesButton = screen.getByLabelText('common.saveChanges'); + + // Given a valid edited message + fireEvent.changeText(composer, 'Edited message'); + + // When the message is saved + fireEvent.press(saveChangesButton); + + expect(mockEditReportComment).toHaveBeenCalledTimes(1); + + const editReportCommentArgs = mockEditReportComment.mock.calls.at(0); + const videoAttributeCache = editReportCommentArgs?.[7]; + + expect(videoAttributeCache).toEqual(expect.any(Object)); + expect(videoAttributeCache?.[videoSource]).toEqual(expect.any(String)); + expect(videoAttributeCache?.[videoSource]).toContain('data-name'); + expect(videoAttributeCache?.[videoSource]).toContain('data-expensify-height'); + expect(videoAttributeCache?.[videoSource]).toContain('data-expensify-width'); + }); }); }); From 49845d22b34057e681f948834efc33b2a5de35ee Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 18 Mar 2026 15:33:38 +0100 Subject: [PATCH 130/233] test: add unit tests for new hooks --- ...ebouncedCommentMaxLengthValidation.test.ts | 50 +++++++ ...useDraftMessageVideoAttributeCache.test.ts | 77 ++++++++++ tests/unit/hooks/useEditMessage.test.tsx | 140 ++++++++++++++++++ 3 files changed, 267 insertions(+) create mode 100644 tests/unit/hooks/useDebouncedCommentMaxLengthValidation.test.ts create mode 100644 tests/unit/hooks/useDraftMessageVideoAttributeCache.test.ts create mode 100644 tests/unit/hooks/useEditMessage.test.tsx diff --git a/tests/unit/hooks/useDebouncedCommentMaxLengthValidation.test.ts b/tests/unit/hooks/useDebouncedCommentMaxLengthValidation.test.ts new file mode 100644 index 000000000000..60919154b86c --- /dev/null +++ b/tests/unit/hooks/useDebouncedCommentMaxLengthValidation.test.ts @@ -0,0 +1,50 @@ +import {act, renderHook, waitFor} from '@testing-library/react-native'; +import * as ReportUtils from '@libs/ReportUtils'; +import useDebouncedCommentMaxLengthValidation from '@pages/inbox/report/ReportActionCompose/useDebouncedCommentMaxLengthValidation'; +import CONST from '@src/CONST'; + +jest.mock('@libs/ReportUtils', () => { + return { + getCommentLength: jest.fn(), + }; +}); + +const mockGetCommentLength = jest.mocked(ReportUtils.getCommentLength); + +describe('useDebouncedCommentMaxLengthValidation', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should apply task-title validation when not editing and input matches task pattern', () => { + const {result} = renderHook(() => useDebouncedCommentMaxLengthValidation({reportID: '1', isEditing: false})); + + const title = 'x'.repeat(CONST.TITLE_CHARACTER_LIMIT + 1); + + act(() => { + result.current.debouncedCommentMaxLengthValidation(`[] ${title}`); + }); + + expect(result.current.isTaskTitle).toBe(true); + expect(result.current.exceededMaxLength).toBe(CONST.TITLE_CHARACTER_LIMIT); + expect(result.current.isExceedingMaxLength).toBe(true); + expect(mockGetCommentLength).not.toHaveBeenCalled(); + }); + + it('should apply comment-length validation when editing', async () => { + mockGetCommentLength.mockReturnValue(CONST.MAX_COMMENT_LENGTH + 1); + + const {result} = renderHook(() => useDebouncedCommentMaxLengthValidation({reportID: '1', isEditing: true})); + + act(() => { + result.current.debouncedCommentMaxLengthValidation('x'.repeat(CONST.MAX_COMMENT_LENGTH + 1)); + result.current.debouncedCommentMaxLengthValidation.flush(); + }); + + await waitFor(() => { + expect(result.current.isTaskTitle).toBe(false); + expect(result.current.exceededMaxLength).toBe(CONST.MAX_COMMENT_LENGTH); + expect(result.current.isExceedingMaxLength).toBe(true); + }); + }); +}); diff --git a/tests/unit/hooks/useDraftMessageVideoAttributeCache.test.ts b/tests/unit/hooks/useDraftMessageVideoAttributeCache.test.ts new file mode 100644 index 000000000000..f3c0285dbdfd --- /dev/null +++ b/tests/unit/hooks/useDraftMessageVideoAttributeCache.test.ts @@ -0,0 +1,77 @@ +import {renderHook} from '@testing-library/react-native'; +import Parser from '@libs/Parser'; +import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import useDraftMessageVideoAttributeCache, {draftMessageVideoAttributeCache} from '@pages/inbox/report/useDraftMessageVideoAttributeCache'; + +jest.mock('@libs/ReportActionsUtils', () => ({ + getReportActionHtml: jest.fn(), + isDeletedAction: jest.fn(() => false), +})); + +const mockGetReportActionHtml = jest.mocked(ReportActionsUtils.getReportActionHtml); + +describe('useDraftMessageVideoAttributeCache', () => { + let htmlToMarkdownSpy: jest.SpiedFunction; + + beforeEach(() => { + htmlToMarkdownSpy = jest.spyOn(Parser, 'htmlToMarkdown'); + }); + + afterEach(() => { + draftMessageVideoAttributeCache.clear(); + jest.clearAllMocks(); + }); + + it('should cache video attributes from the original message html when editing', () => { + const reportAction = {reportActionID: '1'} as never; + mockGetReportActionHtml.mockReturnValue(''); + + htmlToMarkdownSpy.mockImplementation((_html, extras) => { + extras?.cacheVideoAttributes?.('https://example.com/video.mp4', ' data-name="v.mp4"'); + return 'original markdown'; + }); + + const updateDraftMessage = jest.fn(); + const isEditInProgressRef = {current: false}; + + renderHook(() => + useDraftMessageVideoAttributeCache({ + draftMessage: 'changed', + isEditing: true, + editingReportAction: reportAction, + updateDraftMessage, + isEditInProgressRef, + }), + ); + + expect(draftMessageVideoAttributeCache.get('https://example.com/video.mp4')).toBe(' data-name="v.mp4"'); + }); + + it('should not call updateDraftMessage when edit is in progress', () => { + const reportAction = {reportActionID: '1'} as never; + mockGetReportActionHtml.mockReturnValue(''); + htmlToMarkdownSpy.mockImplementation((_html, extras) => { + extras?.cacheVideoAttributes?.('https://example.com/video.mp4', ' data-name="v.mp4"'); + return 'original markdown'; + }); + + const updateDraftMessage = jest.fn(); + const isEditInProgressRef = {current: true}; + + const {rerender} = renderHook( + ({draftMessage}) => + useDraftMessageVideoAttributeCache({ + draftMessage, + isEditing: true, + editingReportAction: reportAction, + updateDraftMessage, + isEditInProgressRef, + }), + {initialProps: {draftMessage: 'changed'}}, + ); + + rerender({draftMessage: 'changed again'}); + + expect(updateDraftMessage).toHaveBeenCalledTimes(0); + }); +}); diff --git a/tests/unit/hooks/useEditMessage.test.tsx b/tests/unit/hooks/useEditMessage.test.tsx new file mode 100644 index 000000000000..aa043ec6c2cf --- /dev/null +++ b/tests/unit/hooks/useEditMessage.test.tsx @@ -0,0 +1,140 @@ +import {act, renderHook} from '@testing-library/react-native'; +import Onyx from 'react-native-onyx'; +import * as Report from '@libs/actions/Report'; +import * as ReportActionContextMenu from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import useEditMessage from '@pages/inbox/report/ReportActionCompose/useEditMessage'; +import ONYXKEYS from '@src/ONYXKEYS'; +import * as LHNTestUtils from '../../utils/LHNTestUtils'; + +jest.mock('@libs/actions/Report', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@libs/actions/Report'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + editReportComment: jest.fn(), + clearReportActionDrafts: jest.fn(), + }; +}); + +jest.mock('@pages/inbox/report/ReportActionEditMessageContext', () => ({ + useReportActionActiveEditActions: () => ({ + setEditingState: jest.fn(), + }), +})); + +jest.mock('@pages/inbox/report/ContextMenu/ReportActionContextMenu', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@pages/inbox/report/ContextMenu/ReportActionContextMenu'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + showDeleteModal: jest.fn(), + isActiveReportAction: jest.fn(() => false), + }; +}); + +jest.mock('@hooks/useAncestors', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + __esModule: true, + default: () => [], +})); + +jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + __esModule: true, + default: () => ({email: 'user@test.com'}), +})); + +jest.mock('@hooks/useReportIsArchived', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + __esModule: true, + default: () => false, +})); + +jest.mock('@hooks/useReportScrollManager', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + __esModule: true, + default: () => ({scrollToIndex: jest.fn()}), +})); + +jest.mock('@libs/ReportUtils', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@libs/ReportUtils'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + getOriginalReportID: () => undefined, + }; +}); + +const mockEditReportComment = jest.mocked(Report.editReportComment); +const mockShowDeleteModal = jest.mocked(ReportActionContextMenu.showDeleteModal); + +type HookProps = Parameters[0]; + +type DebouncedValidator = HookProps['debouncedCommentMaxLengthValidation']; + +function makeDebouncedValidator({flushResult}: {flushResult: boolean}): DebouncedValidator { + return { + flush: jest.fn(() => flushResult), + cancel: jest.fn(), + } as unknown as DebouncedValidator; +} + +describe('useEditMessage', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + jest.clearAllMocks(); + }); + + function renderUseEditMessage(overrides?: Partial) { + const report = LHNTestUtils.getFakeReport(); + const reportAction = LHNTestUtils.getFakeReportAction(); + + const props: HookProps = { + reportID: report.reportID, + originalReportID: report.reportID, + reportAction, + isFocused: true, + debouncedCommentMaxLengthValidation: makeDebouncedValidator({flushResult: true}), + composerRef: {current: {blur: jest.fn()} as never}, + ...overrides, + }; + const hook = renderHook(() => useEditMessage(props)); + return {hook, props}; + } + + it('should not publish when validation flush fails', async () => { + const {hook} = renderUseEditMessage({ + debouncedCommentMaxLengthValidation: makeDebouncedValidator({flushResult: false}), + }); + + act(() => { + hook.result.current.publishDraft('Hello'); + }); + + expect(mockEditReportComment).toHaveBeenCalledTimes(0); + expect(mockShowDeleteModal).toHaveBeenCalledTimes(0); + }); + + it('should open delete modal when publishing an empty (trimmed) message', async () => { + const {hook, props} = renderUseEditMessage(); + + act(() => { + hook.result.current.publishDraft(' '); + }); + + expect(mockEditReportComment).toHaveBeenCalledTimes(0); + expect(mockShowDeleteModal).toHaveBeenCalledTimes(1); + + const args = mockShowDeleteModal.mock.calls.at(0); + expect(args?.[1]?.reportActionID).toBe(props.reportAction?.reportActionID); + }); +}); From e71928550f2886bc869ff86065013647c7e8e37b Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 18 Mar 2026 15:34:28 +0100 Subject: [PATCH 131/233] refactor: change file type --- .../hooks/{useEditMessage.test.tsx => useEditMessage.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/unit/hooks/{useEditMessage.test.tsx => useEditMessage.test.ts} (100%) diff --git a/tests/unit/hooks/useEditMessage.test.tsx b/tests/unit/hooks/useEditMessage.test.ts similarity index 100% rename from tests/unit/hooks/useEditMessage.test.tsx rename to tests/unit/hooks/useEditMessage.test.ts From d9849250e2953e7eddc7c1148e05ca5e2ebb2470 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 18 Mar 2026 16:08:32 +0100 Subject: [PATCH 132/233] refactor: fix imports --- .../hooks/useDebouncedCommentMaxLengthValidation.test.ts | 4 ++-- .../unit/hooks/useDraftMessageVideoAttributeCache.test.ts | 4 ++-- tests/unit/hooks/useEditMessage.test.ts | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/unit/hooks/useDebouncedCommentMaxLengthValidation.test.ts b/tests/unit/hooks/useDebouncedCommentMaxLengthValidation.test.ts index 60919154b86c..db004d43d90d 100644 --- a/tests/unit/hooks/useDebouncedCommentMaxLengthValidation.test.ts +++ b/tests/unit/hooks/useDebouncedCommentMaxLengthValidation.test.ts @@ -1,5 +1,5 @@ import {act, renderHook, waitFor} from '@testing-library/react-native'; -import * as ReportUtils from '@libs/ReportUtils'; +import {getCommentLength} from '@libs/ReportUtils'; import useDebouncedCommentMaxLengthValidation from '@pages/inbox/report/ReportActionCompose/useDebouncedCommentMaxLengthValidation'; import CONST from '@src/CONST'; @@ -9,7 +9,7 @@ jest.mock('@libs/ReportUtils', () => { }; }); -const mockGetCommentLength = jest.mocked(ReportUtils.getCommentLength); +const mockGetCommentLength = jest.mocked(getCommentLength); describe('useDebouncedCommentMaxLengthValidation', () => { afterEach(() => { diff --git a/tests/unit/hooks/useDraftMessageVideoAttributeCache.test.ts b/tests/unit/hooks/useDraftMessageVideoAttributeCache.test.ts index f3c0285dbdfd..83a04de88018 100644 --- a/tests/unit/hooks/useDraftMessageVideoAttributeCache.test.ts +++ b/tests/unit/hooks/useDraftMessageVideoAttributeCache.test.ts @@ -1,6 +1,6 @@ import {renderHook} from '@testing-library/react-native'; import Parser from '@libs/Parser'; -import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import {getReportActionHtml} from '@libs/ReportActionsUtils'; import useDraftMessageVideoAttributeCache, {draftMessageVideoAttributeCache} from '@pages/inbox/report/useDraftMessageVideoAttributeCache'; jest.mock('@libs/ReportActionsUtils', () => ({ @@ -8,7 +8,7 @@ jest.mock('@libs/ReportActionsUtils', () => ({ isDeletedAction: jest.fn(() => false), })); -const mockGetReportActionHtml = jest.mocked(ReportActionsUtils.getReportActionHtml); +const mockGetReportActionHtml = jest.mocked(getReportActionHtml); describe('useDraftMessageVideoAttributeCache', () => { let htmlToMarkdownSpy: jest.SpiedFunction; diff --git a/tests/unit/hooks/useEditMessage.test.ts b/tests/unit/hooks/useEditMessage.test.ts index aa043ec6c2cf..607f89f05366 100644 --- a/tests/unit/hooks/useEditMessage.test.ts +++ b/tests/unit/hooks/useEditMessage.test.ts @@ -1,7 +1,7 @@ import {act, renderHook} from '@testing-library/react-native'; import Onyx from 'react-native-onyx'; -import * as Report from '@libs/actions/Report'; -import * as ReportActionContextMenu from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import {editReportComment} from '@libs/actions/Report'; +import {showDeleteModal} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; import useEditMessage from '@pages/inbox/report/ReportActionCompose/useEditMessage'; import ONYXKEYS from '@src/ONYXKEYS'; import * as LHNTestUtils from '../../utils/LHNTestUtils'; @@ -68,8 +68,8 @@ jest.mock('@libs/ReportUtils', () => { }; }); -const mockEditReportComment = jest.mocked(Report.editReportComment); -const mockShowDeleteModal = jest.mocked(ReportActionContextMenu.showDeleteModal); +const mockEditReportComment = jest.mocked(editReportComment); +const mockShowDeleteModal = jest.mocked(showDeleteModal); type HookProps = Parameters[0]; From 9ddf9562f5e435e4ad31efeeed37fdf4a68a131c Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 24 Mar 2026 12:48:23 +0000 Subject: [PATCH 133/233] fix: always delete all edit message drafts --- src/libs/actions/Report/index.ts | 4 ++-- src/pages/inbox/ReportScreen.tsx | 2 +- src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx | 2 +- src/pages/inbox/report/PureReportActionItem.tsx | 2 +- src/pages/inbox/report/ReportActionCompose/useEditMessage.ts | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 075d2efd70c9..782adffad85c 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -2938,8 +2938,8 @@ function editReportComment( } /** Clears drafts for all comment report action in a report. */ -function clearReportActionDrafts(reportID: string | undefined) { - Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`, null); +function clearReportActionDrafts() { + Onyx.setCollection(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS, {}); } /** Saves the draft for a comment report action. This will put the comment into "edit mode" */ diff --git a/src/pages/inbox/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx index 5b7e1ada6912..aa142cf0f78b 100644 --- a/src/pages/inbox/ReportScreen.tsx +++ b/src/pages/inbox/ReportScreen.tsx @@ -193,7 +193,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { } return () => { - clearReportActionDrafts(reportIDFromRoute); + clearReportActionDrafts(); }; }, [reportIDFromRoute]), ); diff --git a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx index 9e251ce5e9bb..19a0e4413f93 100644 --- a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx @@ -529,7 +529,7 @@ const ContextMenuActions: ContextMenuAction[] = [ return; } const editAction = () => { - clearReportActionDrafts(reportID); + clearReportActionDrafts(); if (draftMessage) { return; diff --git a/src/pages/inbox/report/PureReportActionItem.tsx b/src/pages/inbox/report/PureReportActionItem.tsx index 29eb642499b9..4be24e62b4ce 100644 --- a/src/pages/inbox/report/PureReportActionItem.tsx +++ b/src/pages/inbox/report/PureReportActionItem.tsx @@ -708,7 +708,7 @@ function PureReportActionItem({ if (draftMessage === undefined || !isDeletedAction(action)) { return; } - clearReportActionDrafts(reportID); + clearReportActionDrafts(); }, [draftMessage, action, reportID]); // Hide the message if it is being moderated for a higher offense, or is hidden by a moderator diff --git a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts index 1a87463e27e7..d0bc1bddce10 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts @@ -70,7 +70,7 @@ function useEditMessage({reportID, originalReportID, reportAction, shouldScrollT setEditingState('cancelled'); - clearReportActionDrafts(reportID); + clearReportActionDrafts(); if (isActive()) { ReportActionComposeFocusManager.clear(true); From 8324e3386298dd460e65f50aac1d2db23a3d3618 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 24 Mar 2026 19:25:02 +0000 Subject: [PATCH 134/233] fix: `Onyx.merge` and `Onyx.setCollection` not queued in the correct order --- src/libs/actions/Report/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 0d7390697347..4464b4704567 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -2949,7 +2949,12 @@ function saveReportActionDraft(reportID: string | undefined, reportAction: Repor } const originalReportID = getOriginalReportID(reportID, reportAction, undefined); - Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${originalReportID}`, {[reportAction.reportActionID]: {message: draftMessage}}); + + Onyx.setCollection(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS, { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${originalReportID}`]: { + [reportAction.reportActionID]: {message: draftMessage}, + }, + }); } function updateNotificationPreference( From 96dd8514a8a3e9a7999cd7a5b8a18c29752ce67e Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 24 Mar 2026 19:41:37 +0000 Subject: [PATCH 135/233] refactor: other usages of Onyx draft message key --- .../DuplicateTransactionItem.tsx | 15 ++--- .../report/ReportActionItemParentAction.tsx | 55 ++----------------- 2 files changed, 12 insertions(+), 58 deletions(-) diff --git a/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx b/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx index 2c11ebca8d37..9a563cc59c4e 100644 --- a/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx +++ b/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx @@ -8,7 +8,7 @@ import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getOriginalMessage, getReportAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; -import {getOriginalReportID} from '@libs/ReportUtils'; +import {useReportActionActiveEdit} from '@pages/inbox/report/ReportActionEditMessageContext'; import ReportActionItem from '@pages/inbox/report/ReportActionItem'; import {ReportActionItemActionsContext, ReportActionItemStateContext} from '@pages/inbox/report/ReportActionItemContext'; import CONST from '@src/CONST'; @@ -41,10 +41,6 @@ function DuplicateTransactionItem({transaction, index, onPreviewPressed}: Duplic return IOUTransactionID === transaction?.transactionID; }); - const originalReportID = getOriginalReportID(report?.reportID, action, reportActions); - - const [draftMessage] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${originalReportID}`); - const [emojiReactions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${action?.reportActionID}`); const [linkedTransactionRouteError] = useOnyx( @@ -57,13 +53,14 @@ function DuplicateTransactionItem({transaction, index, onPreviewPressed}: Duplic const stateValue = useMemo(() => ({shouldOpenReportInRHP: true}), []); const actionsValue = useMemo(() => ({onPreviewPressed}), [onPreviewPressed]); + const {editingMessage, editingReportAction} = useReportActionActiveEdit(); + + const draftMessage = editingReportAction?.reportActionID === action?.reportActionID ? (editingMessage ?? undefined) : undefined; + if (!action || !report) { return null; } - const reportDraftMessage = draftMessage?.[action.reportActionID]; - const matchingDraftMessage = reportDraftMessage?.message; - return ( @@ -81,7 +78,7 @@ function DuplicateTransactionItem({transaction, index, onPreviewPressed}: Duplic userWalletTierName={userWalletTierName} isUserValidated={isUserValidated} personalDetails={personalDetails} - draftMessage={matchingDraftMessage} + draftMessage={draftMessage} emojiReactions={emojiReactions} linkedTransactionRouteError={linkedTransactionRouteError} userBillingFundID={userBillingFundID} diff --git a/src/pages/inbox/report/ReportActionItemParentAction.tsx b/src/pages/inbox/report/ReportActionItemParentAction.tsx index a9e4a9693ca4..4d159c56ec40 100644 --- a/src/pages/inbox/report/ReportActionItemParentAction.tsx +++ b/src/pages/inbox/report/ReportActionItemParentAction.tsx @@ -1,4 +1,3 @@ -import {getReportActionsForReportIDs} from '@selectors/ReportAction'; import React, {useCallback} from 'react'; import {View} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; @@ -14,16 +13,16 @@ import {getOriginalMessage, isMoneyRequestAction, isTripPreview} from '@libs/Rep import { canCurrentUserOpenReport, canUserPerformWriteAction as canUserPerformWriteActionReportUtils, - getOriginalReportID, isArchivedReport, navigateToLinkedReportAction, shouldExcludeAncestorReportAction, } from '@libs/ReportUtils'; import {navigateToConciergeChatAndDeleteReport} from '@userActions/Report'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetailsList, Report, ReportAction, ReportActionReactions, ReportActions, ReportActionsDrafts, ReportNameValuePairs, Transaction} from '@src/types/onyx'; +import type {PersonalDetailsList, Report, ReportAction, ReportActionReactions, ReportNameValuePairs, Transaction} from '@src/types/onyx'; import AnimatedEmptyStateBackground from './AnimatedEmptyStateBackground'; import RepliesDivider from './RepliesDivider'; +import {useReportActionActiveEdit} from './ReportActionEditMessageContext'; import ReportActionItem from './ReportActionItem'; import ThreadDivider from './ThreadDivider'; @@ -132,43 +131,7 @@ function ReportActionItemParentAction({ [ancestors], ); - const ancestorReportActionsSelector = useCallback( - (allReportActions: OnyxCollection) => { - const reportIDs = ancestors.map((ancestor) => ancestor.report.reportID); - return getReportActionsForReportIDs(allReportActions, reportIDs); - }, - [ancestors], - ); - - const [ancestorsReportActions] = useOnyx( - ONYXKEYS.COLLECTION.REPORT_ACTIONS, - { - selector: ancestorReportActionsSelector, - }, - [ancestors], - ); - - const ancestorDraftSelector = useCallback( - (allDrafts: OnyxCollection) => { - if (!allDrafts) { - return {}; - } - const result: OnyxCollection = {}; - for (const ancestor of ancestors) { - const origID = getOriginalReportID( - ancestor.report.reportID, - ancestor.reportAction, - ancestorsReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${ancestor.report.reportID}`], - ); - const key = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${origID}`; - result[key] = allDrafts[key]; - } - return result; - }, - [ancestors, ancestorsReportActions], - ); - - const [ancestorDraftMessages] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS, {selector: ancestorDraftSelector}, [ancestors, ancestorsReportActions]); + const {editingMessage, editingReportAction} = useReportActionActiveEdit(); const ancestorReactionSelector = useCallback( (allReactions: OnyxCollection) => { @@ -207,14 +170,8 @@ function ReportActionItemParentAction({ const shouldDisplayThreadDivider = !isTripPreview(ancestorReportAction); const isAncestorReportArchived = isArchivedReport(ancestorsReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${ancestorReport.reportID}`]); - const originalReportID = getOriginalReportID( - ancestorReport.reportID, - ancestorReportAction, - ancestorsReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${ancestorReport.reportID}`], - ); - const reportDraftMessages = originalReportID ? ancestorDraftMessages?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${originalReportID}`] : undefined; - const matchingDraftMessage = reportDraftMessages?.[ancestorReportAction.reportActionID]; - const matchingDraftMessageString = matchingDraftMessage?.message; + const draftMessage = editingReportAction?.reportActionID === ancestorReportAction.reportActionID ? (editingMessage ?? undefined) : undefined; + const actionEmojiReactions = ancestorReactions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${ancestorReportAction.reportActionID}`]; return ( @@ -252,7 +209,7 @@ function ReportActionItemParentAction({ userWalletTierName={userWalletTierName} isUserValidated={isUserValidated} personalDetails={personalDetails} - draftMessage={matchingDraftMessageString} + draftMessage={draftMessage} emojiReactions={actionEmojiReactions} linkedTransactionRouteError={linkedTransactionRouteError} userBillingFundID={userBillingFundID} From d01abbd891ed6bb10578f0735adc9f6463de4a2f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 24 Mar 2026 19:56:26 +0000 Subject: [PATCH 136/233] refactor: add back selectors for report actions and drafts --- .../report/ReportActionEditMessageContext.tsx | 62 +++++++++++++++++-- 1 file changed, 57 insertions(+), 5 deletions(-) diff --git a/src/pages/inbox/report/ReportActionEditMessageContext.tsx b/src/pages/inbox/report/ReportActionEditMessageContext.tsx index b7bfe0e5c2cd..a80b5ab10b71 100644 --- a/src/pages/inbox/report/ReportActionEditMessageContext.tsx +++ b/src/pages/inbox/report/ReportActionEditMessageContext.tsx @@ -1,9 +1,11 @@ import {useIsFocused} from '@react-navigation/native'; -import React, {createContext, useContext, useState} from 'react'; +import React, {createContext, useCallback, useContext, useState} from 'react'; import type {Dispatch, SetStateAction} from 'react'; +import type {OnyxCollection} from 'react-native-onyx'; import type {TextSelection} from '@components/Composer/types'; import useAncestors from '@hooks/useAncestors'; import useOnyx from '@hooks/useOnyx'; +import {getOriginalReportID, shouldExcludeAncestorReportAction} from '@libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; @@ -58,8 +60,51 @@ function ReportActionEditMessageContextProvider({reportID, children}: ReportActi const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { canEvict: false, }); - const [reportActionDrafts] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS); - const ancestors = useAncestors(report); + + const ancestors = useAncestors(report, shouldExcludeAncestorReportAction); + + const ancestorReportActionsSelector = useCallback( + (allReportActions: OnyxCollection) => { + if (!allReportActions) { + return {}; + } + const result: OnyxCollection = {}; + for (const ancestor of ancestors) { + const key = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${ancestor.report.reportID}`; + result[key] = allReportActions[key]; + } + return result; + }, + [ancestors], + ); + + const [ancestorsReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, {selector: ancestorReportActionsSelector}, [ancestors]); + + const ancestorDraftSelector = useCallback( + (allDrafts: OnyxCollection) => { + if (!allDrafts) { + return {}; + } + const result: OnyxCollection = {}; + if (reportID) { + const currentDraftKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`; + result[currentDraftKey] = allDrafts[currentDraftKey]; + } + for (const ancestor of ancestors) { + const reportActionsForAncestor = ancestorsReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${ancestor.report.reportID}`]; + const origID = getOriginalReportID(ancestor.report.reportID, ancestor.reportAction, reportActionsForAncestor); + if (!origID) { + continue; + } + const draftKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${origID}`; + result[draftKey] = allDrafts[draftKey]; + } + return result; + }, + [ancestors, ancestorsReportActions, reportID], + ); + + const [reportActionDrafts] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS, {selector: ancestorDraftSelector}, [ancestors, ancestorsReportActions, reportID]); const [editingState, setEditingState] = useState(null); const [prevEditingReportActionID, setPrevEditingReportActionID] = useState(null); @@ -77,7 +122,12 @@ function ReportActionEditMessageContextProvider({reportID, children}: ReportActi .slice() .reverse() .find(({report: ancestorReport, reportAction}) => { - const ancestorDrafts = reportActionDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${ancestorReport.reportID}`]; + const reportActionsForAncestor = ancestorsReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${ancestorReport.reportID}`]; + const origID = getOriginalReportID(ancestorReport.reportID, reportAction, reportActionsForAncestor); + if (!origID) { + return false; + } + const ancestorDrafts = reportActionDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${origID}`]; const ancestorDraft = ancestorDrafts?.[reportAction.reportActionID]; return ancestorDraft?.message !== undefined; @@ -100,7 +150,9 @@ function ReportActionEditMessageContextProvider({reportID, children}: ReportActi if (ancestorWithDraft) { const {report: ancestorReport, reportAction: ancestorReportAction} = ancestorWithDraft; - const ancestorDrafts = reportActionDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${ancestorReport.reportID}`]; + const reportActionsForAncestor = ancestorsReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${ancestorReport.reportID}`]; + const ancestorOrigReportID = getOriginalReportID(ancestorReport.reportID, ancestorReportAction, reportActionsForAncestor); + const ancestorDrafts = ancestorOrigReportID ? reportActionDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${ancestorOrigReportID}`] : undefined; const ancestorReportActionDraft = ancestorDrafts?.[ancestorReportAction.reportActionID]; editingReportID = ancestorReport.reportID; From 4eddd233b88d778c80b17fb4557f80b6aebe5837 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 24 Mar 2026 20:19:26 +0000 Subject: [PATCH 137/233] refactor: remove unnecessary `clearReportActionDrafts` call --- src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx index 19a0e4413f93..62d2f67944ee 100644 --- a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx @@ -529,12 +529,6 @@ const ContextMenuActions: ContextMenuAction[] = [ return; } const editAction = () => { - clearReportActionDrafts(); - - if (draftMessage) { - return; - } - saveReportActionDraft(reportID, reportAction, Parser.htmlToMarkdown(getActionHtml(reportAction))); }; From c0d39090dcbc9c05de2ce461c3622e036997eef0 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 25 Mar 2026 09:40:58 +0000 Subject: [PATCH 138/233] refactor: make `editingState` non-nullable --- .../ComposerWithSuggestions.tsx | 11 +++++----- .../ReportActionCompose.tsx | 4 ++-- .../report/ReportActionEditMessageContext.tsx | 21 +++++++++---------- 3 files changed, 17 insertions(+), 19 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 77669c5e7d32..c753b8793fdb 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -264,7 +264,8 @@ function ComposerWithSuggestions({ const {editingState, editingReportActionID, editingReportAction, editingMessage, currentEditMessageSelection} = useReportActionActiveEdit(); const {setEditingMessage, setCurrentEditMessageSelection} = useReportActionActiveEditActions(); - const isEditingInComposer = shouldUseNarrowLayout && editingState !== null; + const isEditing = editingState !== 'off'; + const isEditingInComposer = shouldUseNarrowLayout && isEditing; const [value, setValue] = useState(() => { const initialValue = isEditingInComposer ? (editingMessage ?? draftComment) : draftComment; @@ -279,7 +280,7 @@ function ComposerWithSuggestions({ useDraftMessageVideoAttributeCache({ draftMessage: value, - isEditing: editingState !== null, + isEditing, editingReportAction, updateDraftMessage: setValue, isEditInProgressRef: isDraftPendingSaved, @@ -334,7 +335,7 @@ function ComposerWithSuggestions({ [currentEditMessageSelection, updateSelectionImperatively], ); - const wasEditing = useRef(editingState !== null); + const wasEditing = useRef(isEditing); const wasEditingInComposerRef = useRef(shouldUseNarrowLayout); const previousDraftSelectionRef = useRef(null); @@ -343,9 +344,7 @@ function ComposerWithSuggestions({ return; } - const isEditing = editingState === 'editing'; - - if (!isEditing) { + if (editingState !== 'editing') { if (wasEditing.current && wasEditingInComposerRef.current) { // Editing just ended in the composer – restore the draft comment and its previous selection. applyComposerValue(draftComment ?? '', {selection: previousDraftSelectionRef.current}); diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index ea33e9099aec..eff19e6699cd 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -169,10 +169,10 @@ function ReportActionCompose({ const {editingState, editingReportID, editingReportActionID, editingReportAction, editingMessage} = useReportActionActiveEdit(); const [didResetComposerHeight, setDidResetComposerHeight] = useState(false); - const isEditingInComposer = shouldUseNarrowLayout && editingState !== null && !didResetComposerHeight; + const isEditingInComposer = shouldUseNarrowLayout && editingState !== 'off' && !didResetComposerHeight; useEffect(() => { - if (editingState !== null || !didResetComposerHeight) { + if (editingState !== 'off' || !didResetComposerHeight) { return; } diff --git a/src/pages/inbox/report/ReportActionEditMessageContext.tsx b/src/pages/inbox/report/ReportActionEditMessageContext.tsx index a80b5ab10b71..ac5526b40bbb 100644 --- a/src/pages/inbox/report/ReportActionEditMessageContext.tsx +++ b/src/pages/inbox/report/ReportActionEditMessageContext.tsx @@ -13,7 +13,7 @@ function NOOP() { return null; } -type EditingState = 'editing' | 'submitted' | 'cancelled'; +type EditingState = 'off' | 'editing' | 'submitted' | 'cancelled'; type ReportActionActiveEdit = { editingReportID: string | null; @@ -24,11 +24,11 @@ type ReportActionActiveEdit = { type ReportActionEditMessageContextValue = ReportActionActiveEdit & { currentEditMessageSelection: TextSelection | null; - editingState: EditingState | null; + editingState: EditingState; }; type ReportActionEditMessageContextActions = { - setEditingState: Dispatch>; + setEditingState: Dispatch>; setEditingMessage: Dispatch>; setCurrentEditMessageSelection: Dispatch>; }; @@ -39,7 +39,7 @@ const ReportActionEditMessageContext = createContext({ @@ -106,7 +106,7 @@ function ReportActionEditMessageContextProvider({reportID, children}: ReportActi const [reportActionDrafts] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS, {selector: ancestorDraftSelector}, [ancestors, ancestorsReportActions, reportID]); - const [editingState, setEditingState] = useState(null); + const [editingState, setEditingState] = useState('off'); const [prevEditingReportActionID, setPrevEditingReportActionID] = useState(null); const [editingMessage, setEditingMessage] = useState(null); const [currentEditMessageSelection, setCurrentEditMessageSelectionState] = useState(null); @@ -138,9 +138,8 @@ function ReportActionEditMessageContextProvider({reportID, children}: ReportActi return; } - const isInitialEdit = editingMessage == null; const didReportActionChange = prevEditingReportActionID !== editingReportActionID; - if (isInitialEdit || didReportActionChange) { + if (didReportActionChange) { setEditingMessage(nextMessage); setPrevEditingReportActionID(editingReportActionID); const defaultSelection: TextSelection = {start: nextMessage.length, end: nextMessage.length}; @@ -159,7 +158,7 @@ function ReportActionEditMessageContextProvider({reportID, children}: ReportActi editingReportActionID = ancestorReportAction.reportActionID; editingReportAction = ancestorReportAction; - if (editingState === null) { + if (editingState === 'off') { setEditingState('editing'); } @@ -175,7 +174,7 @@ function ReportActionEditMessageContextProvider({reportID, children}: ReportActi editingReportActionID = reportActionIDOfDraft; editingReportAction = reportActions?.[reportActionIDOfDraft] ?? null; - if (editingState === null) { + if (editingState === 'off') { setEditingState('editing'); } @@ -185,8 +184,8 @@ function ReportActionEditMessageContextProvider({reportID, children}: ReportActi } } - if (editingReportID == null && editingState !== null) { - setEditingState(null); + if (editingReportID == null && editingState !== 'off') { + setEditingState('off'); setEditingMessage(null); setPrevEditingReportActionID(null); setCurrentEditMessageSelectionState(null); From c1683acb45da0e0199d56e5f7ed264bc38c318a0 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 25 Mar 2026 09:41:22 +0000 Subject: [PATCH 139/233] fix: add missing import in tests --- tests/ui/ReportActionComposeTest.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/ui/ReportActionComposeTest.tsx b/tests/ui/ReportActionComposeTest.tsx index acea21dfa352..b322d2004329 100644 --- a/tests/ui/ReportActionComposeTest.tsx +++ b/tests/ui/ReportActionComposeTest.tsx @@ -5,6 +5,7 @@ import {forceClearInput} from '@libs/ComponentUtils'; import {onSubmitAction} from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import * as LHNTestUtils from '../utils/LHNTestUtils'; import {renderReportActionCompose} from '../utils/ReportActionComposeUtils'; import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; From f33f1a763718008d9b8e4a927c02c50dad9ad7f0 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 25 Mar 2026 09:41:30 +0000 Subject: [PATCH 140/233] refactor: remove unused import --- src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx index 62d2f67944ee..b93c0215a271 100644 --- a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx @@ -514,7 +514,7 @@ const ContextMenuActions: ContextMenuAction[] = [ icon: 'Pencil', shouldShow: ({type, reportAction, isArchivedRoom, isChronosReport, moneyRequestAction}) => type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && (canEditReportAction(reportAction) || canEditReportAction(moneyRequestAction)) && !isArchivedRoom && !isChronosReport, - onPress: (closePopover, {reportID, reportAction, draftMessage, moneyRequestAction, introSelected, betas}) => { + onPress: (closePopover, {reportID, reportAction, moneyRequestAction, introSelected, betas}) => { if (isMoneyRequestAction(reportAction) || isMoneyRequestAction(moneyRequestAction)) { const editExpense = () => { const childReportID = reportAction?.childReportID; From 730a78746fad5c5ab1c769ffd0c961d1eaae0087 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 25 Mar 2026 10:06:50 +0000 Subject: [PATCH 141/233] fix: editing stops after delete message modal opens --- .../ReportActionCompose.tsx | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index eff19e6699cd..ae798176aae8 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -450,9 +450,11 @@ function ReportActionCompose({ return; } - composerRef.current?.resetHeight(); - if (isEditingInComposer) { - setDidResetComposerHeight(true); + if (effectiveDraft !== null && effectiveDraft !== '') { + composerRef.current?.resetHeight(); + if (isEditingInComposer) { + setDidResetComposerHeight(true); + } } scheduleOnUI(() => { @@ -464,7 +466,18 @@ function ReportActionCompose({ clearWorklet?.(); }); - }, [isSendDisabled, debouncedCommentMaxLengthValidation, isEditingInComposer, editingMessage, draftComment, isComposerFullSize, validateAndSubmitDraft, reportID, composerRefShared]); + }, [ + isSendDisabled, + debouncedCommentMaxLengthValidation, + isComposerFullSize, + isEditingInComposer, + editingMessage, + draftComment, + effectiveDraft, + reportID, + validateAndSubmitDraft, + composerRefShared, + ]); onSubmitAction = submitDraftAndClearComposer; const onTriggerAttachmentPicker = useCallback(() => { From 00cb0859197d2ebc3fd46b99b6bd6e3a27108af4 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 25 Mar 2026 10:07:10 +0000 Subject: [PATCH 142/233] refactor: remove `cancelled` editing state --- .../report/ReportActionCompose/ComposerWithSuggestions.tsx | 1 + .../inbox/report/ReportActionCompose/useEditMessage.ts | 6 +++--- src/pages/inbox/report/ReportActionEditMessageContext.tsx | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index c753b8793fdb..ff42da1178d6 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -340,6 +340,7 @@ function ComposerWithSuggestions({ const previousDraftSelectionRef = useRef(null); useEffect(() => { + // If the draft message is already being submitted, do nothing. if (editingState === 'submitted') { return; } diff --git a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts index d0bc1bddce10..95a538e689ad 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts @@ -68,7 +68,7 @@ function useEditMessage({reportID, originalReportID, reportAction, shouldScrollT return; } - setEditingState('cancelled'); + setEditingState('off'); clearReportActionDrafts(); @@ -101,8 +101,6 @@ function useEditMessage({reportID, originalReportID, reportAction, shouldScrollT return; } - setEditingState('submitted'); - const trimmedNewDraft = draftMessage.trim(); // When user tries to save the empty message, it will delete it. Prompt the user to confirm deleting. @@ -112,6 +110,8 @@ function useEditMessage({reportID, originalReportID, reportAction, shouldScrollT return; } + setEditingState('submitted'); + editReportComment( originalReport, reportAction, diff --git a/src/pages/inbox/report/ReportActionEditMessageContext.tsx b/src/pages/inbox/report/ReportActionEditMessageContext.tsx index ac5526b40bbb..1962c3c47677 100644 --- a/src/pages/inbox/report/ReportActionEditMessageContext.tsx +++ b/src/pages/inbox/report/ReportActionEditMessageContext.tsx @@ -13,7 +13,7 @@ function NOOP() { return null; } -type EditingState = 'off' | 'editing' | 'submitted' | 'cancelled'; +type EditingState = 'off' | 'editing' | 'submitted'; type ReportActionActiveEdit = { editingReportID: string | null; From 0b59e757c856f89f3b16fe2a86cb46e4ec9f047f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 25 Mar 2026 10:08:42 +0000 Subject: [PATCH 143/233] fix: remove unused import --- src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx index b93c0215a271..4613ca445032 100644 --- a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx @@ -172,7 +172,6 @@ import { import {getTaskCreatedMessage, getTaskReportActionMessage} from '@libs/TaskUtils'; import {setDownload} from '@userActions/Download'; import { - clearReportActionDrafts, explain, markCommentAsUnread, navigateToAndOpenChildReport, From 4f1fa0889836f1ce744dfa48306522f287374d4f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 25 Mar 2026 11:54:58 +0000 Subject: [PATCH 144/233] refactor: simplify `ComposerWithSuggestions` edit state effect by adding key --- .../ComposerWithSuggestions.tsx | 22 +++++-------------- .../ReportActionCompose.tsx | 2 ++ 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index ff42da1178d6..8691ee00da5a 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -335,34 +335,22 @@ function ComposerWithSuggestions({ [currentEditMessageSelection, updateSelectionImperatively], ); - const wasEditing = useRef(isEditing); + const isFirstEditingRenderRef = useRef(isEditing); const wasEditingInComposerRef = useRef(shouldUseNarrowLayout); const previousDraftSelectionRef = useRef(null); useEffect(() => { // If the draft message is already being submitted, do nothing. - if (editingState === 'submitted') { - return; - } - if (editingState !== 'editing') { - if (wasEditing.current && wasEditingInComposerRef.current) { - // Editing just ended in the composer – restore the draft comment and its previous selection. - applyComposerValue(draftComment ?? '', {selection: previousDraftSelectionRef.current}); - } - - wasEditing.current = false; - wasEditingInComposerRef.current = shouldUseNarrowLayout; - previousDraftSelectionRef.current = null; return; } // Editing just started. - if (!wasEditing.current) { + if (isFirstEditingRenderRef.current) { + isFirstEditingRenderRef.current = false; + // Store the draft selection before switching into edit mode so we can restore it later. previousDraftSelectionRef.current = selection; - - wasEditing.current = true; wasEditingInComposerRef.current = shouldUseNarrowLayout; if (!shouldUseNarrowLayout) { @@ -392,7 +380,7 @@ function ComposerWithSuggestions({ if (shouldUseNarrowLayout) { applyComposerValue(editingMessage ?? '', {isEditingInComposer: true}); } - }, [applyComposerValue, draftComment, editingMessage, editingState, selection, shouldUseNarrowLayout, updateSelectionImperatively]); + }, [applyComposerValue, draftComment, editingMessage, editingState, isEditing, selection, shouldUseNarrowLayout, updateSelectionImperatively]); const {superWideRHPRouteKeys} = useWideRHPState(); // When SearchReport is stacked above another RHP, delay autofocus until after the transition completes to avoid animation jank diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index ae798176aae8..c40c15bb315a 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -170,6 +170,7 @@ function ReportActionCompose({ const [didResetComposerHeight, setDidResetComposerHeight] = useState(false); const isEditingInComposer = shouldUseNarrowLayout && editingState !== 'off' && !didResetComposerHeight; + const composerEditingToggleKey = `${editingState !== 'off'}`; useEffect(() => { if (editingState !== 'off' || !didResetComposerHeight) { @@ -668,6 +669,7 @@ function ReportActionCompose({ /> )} { composerRef.current = ref; composerRefShared.set({ From 7e0cb256c030beed1c7ffa88950eb2afca5f26a0 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 25 Mar 2026 11:58:10 +0000 Subject: [PATCH 145/233] fix: composer toggle key based on report action id --- .../inbox/report/ReportActionCompose/ReportActionCompose.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index c40c15bb315a..0b20e39f1d54 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -170,7 +170,7 @@ function ReportActionCompose({ const [didResetComposerHeight, setDidResetComposerHeight] = useState(false); const isEditingInComposer = shouldUseNarrowLayout && editingState !== 'off' && !didResetComposerHeight; - const composerEditingToggleKey = `${editingState !== 'off'}`; + const composerEditingToggleKey = editingReportActionID; useEffect(() => { if (editingState !== 'off' || !didResetComposerHeight) { From ba5f1c53d8a0ce0943572ba079ca270a75080e46 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 25 Mar 2026 17:40:35 +0000 Subject: [PATCH 146/233] fix: selection issues after publishing an edit --- .../ComposerWithSuggestions.tsx | 21 ++++++++----------- .../ReportActionCompose/useEditMessage.ts | 6 +++--- .../report/ReportActionEditMessageContext.tsx | 19 +++++++++++++---- tests/unit/hooks/useEditMessage.test.ts | 3 ++- 4 files changed, 29 insertions(+), 20 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 8691ee00da5a..92cb1557a453 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -337,7 +337,6 @@ function ComposerWithSuggestions({ const isFirstEditingRenderRef = useRef(isEditing); const wasEditingInComposerRef = useRef(shouldUseNarrowLayout); - const previousDraftSelectionRef = useRef(null); useEffect(() => { // If the draft message is already being submitted, do nothing. @@ -345,12 +344,9 @@ function ComposerWithSuggestions({ return; } - // Editing just started. + // This is the initial render of the composer when editing is turned on if (isFirstEditingRenderRef.current) { isFirstEditingRenderRef.current = false; - - // Store the draft selection before switching into edit mode so we can restore it later. - previousDraftSelectionRef.current = selection; wasEditingInComposerRef.current = shouldUseNarrowLayout; if (!shouldUseNarrowLayout) { @@ -596,13 +592,14 @@ function ComposerWithSuggestions({ syncSelectionWithOnChangeTextRef.current = {position, value: newComment}; } - setSelection((prevSelection) => ({ - ...prevSelection, - start: position, - end: position, - })); - - setCurrentEditMessageSelection((prevSelection) => ({...prevSelection, start: position, end: position})); + if (!isFirstEditingRenderRef.current) { + setSelection((prevSelection) => ({ + ...prevSelection, + start: position, + end: position, + })); + setCurrentEditMessageSelection((prevSelection) => ({...prevSelection, start: position, end: position})); + } } commentRef.current = newCommentConverted; diff --git a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts index 95a538e689ad..7e4627753169 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts @@ -47,7 +47,7 @@ function useEditMessage({reportID, originalReportID, reportAction, shouldScrollT const isOriginalParentReportArchived = useReportIsArchived(originalParentReportID); const ancestors = useAncestors(originalReport); - const {setEditingState} = useReportActionActiveEditActions(); + const {stopEditing, submitEdit} = useReportActionActiveEditActions(); useEffect(() => { // required for keeping last state of isFocused variable @@ -68,7 +68,7 @@ function useEditMessage({reportID, originalReportID, reportAction, shouldScrollT return; } - setEditingState('off'); + stopEditing(); clearReportActionDrafts(); @@ -110,7 +110,7 @@ function useEditMessage({reportID, originalReportID, reportAction, shouldScrollT return; } - setEditingState('submitted'); + submitEdit(); editReportComment( originalReport, diff --git a/src/pages/inbox/report/ReportActionEditMessageContext.tsx b/src/pages/inbox/report/ReportActionEditMessageContext.tsx index 1962c3c47677..4d8878637cf2 100644 --- a/src/pages/inbox/report/ReportActionEditMessageContext.tsx +++ b/src/pages/inbox/report/ReportActionEditMessageContext.tsx @@ -28,9 +28,10 @@ type ReportActionEditMessageContextValue = ReportActionActiveEdit & { }; type ReportActionEditMessageContextActions = { - setEditingState: Dispatch>; setEditingMessage: Dispatch>; setCurrentEditMessageSelection: Dispatch>; + submitEdit: () => void; + stopEditing: () => void; }; const ReportActionEditMessageContext = createContext({ @@ -43,9 +44,10 @@ const ReportActionEditMessageContext = createContext({ - setEditingState: NOOP, setEditingMessage: NOOP, setCurrentEditMessageSelection: NOOP, + submitEdit: NOOP, + stopEditing: NOOP, }); type ReportActionEditMessageContextProviderProps = { @@ -184,11 +186,19 @@ function ReportActionEditMessageContextProvider({reportID, children}: ReportActi } } - if (editingReportID == null && editingState !== 'off') { + const submitEdit = () => { + setEditingState('submitted'); + }; + + const stopEditing = () => { setEditingState('off'); setEditingMessage(null); setPrevEditingReportActionID(null); setCurrentEditMessageSelectionState(null); + }; + + if (editingReportID == null && editingState !== 'off') { + stopEditing(); } const setCurrentEditMessageSelection = (setSelectionStateAction: SetStateAction) => { @@ -209,9 +219,10 @@ function ReportActionEditMessageContextProvider({reportID, children}: ReportActi }; const actions: ReportActionEditMessageContextActions = { - setEditingState, setEditingMessage, setCurrentEditMessageSelection, + submitEdit, + stopEditing, }; return ( diff --git a/tests/unit/hooks/useEditMessage.test.ts b/tests/unit/hooks/useEditMessage.test.ts index 607f89f05366..32f3f7c36154 100644 --- a/tests/unit/hooks/useEditMessage.test.ts +++ b/tests/unit/hooks/useEditMessage.test.ts @@ -19,7 +19,8 @@ jest.mock('@libs/actions/Report', () => { jest.mock('@pages/inbox/report/ReportActionEditMessageContext', () => ({ useReportActionActiveEditActions: () => ({ - setEditingState: jest.fn(), + submitEdit: jest.fn(), + stopEditing: jest.fn(), }), })); From d71b21384981fe44c83b55c3d5bc081d65f89407 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 3 Apr 2026 11:38:03 +0100 Subject: [PATCH 147/233] fix: composer not visible --- src/pages/inbox/report/ReportFooter.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/pages/inbox/report/ReportFooter.tsx b/src/pages/inbox/report/ReportFooter.tsx index 854d54e20a3c..0af7136631ea 100644 --- a/src/pages/inbox/report/ReportFooter.tsx +++ b/src/pages/inbox/report/ReportFooter.tsx @@ -48,8 +48,7 @@ function ReportFooter() { const styles = useThemeStyles(); const {translate} = useLocalize(); const {isOffline} = useNetwork(); - // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth -- isSmallScreenWidth guards composer visibility on mobile during keyboard events, shouldUseNarrowLayout would wrongly hide it in RHP - const {isSmallScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Lightbulb']); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportIDFromRoute}`); @@ -86,7 +85,7 @@ function ReportFooter() { const chatFooterStyles = {...styles.chatFooter, minHeight: !isOffline ? CONST.CHAT_FOOTER_MIN_HEIGHT : 0}; // Happy path — user can compose - if (!shouldHideComposer && !isSmallScreenWidth) { + if (!shouldHideComposer) { return ( From 585c3d3ef0f668bf939baeb222185bbcf6d29a87 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 3 Apr 2026 12:22:15 +0100 Subject: [PATCH 148/233] fix: blur composer when the user stops editing --- .../ReportActionCompose.tsx | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 6977899b7b53..52199536c0ad 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -229,6 +229,13 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { canEvict: false, }); + /** + * Updates the Highlight state of the composer + */ + const [isFocused, setIsFocused] = useState(() => { + return shouldFocusInputOnScreenFocus && !initialModalState?.isVisible && !initialModalState?.willAlertModalBecomeVisible; + }); + const {editingState, editingReportID, editingReportActionID, editingReportAction, editingMessage} = useReportActionActiveEdit(); const [didResetComposerHeight, setDidResetComposerHeight] = useState(false); @@ -243,23 +250,32 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { setDidResetComposerHeight(false); }, [didResetComposerHeight, editingState]); + // Track whether the user was editing a message before + const wasEditingBefore = useRef(false); + useEffect(() => { + if (editingState === 'off') { + return; + } + wasEditingBefore.current = true; + }, [editingState]); + + // Reset composer focus when editing is turned off, but not on the initial chat open. + useEffect(() => { + if (editingState !== 'off' || !wasEditingBefore.current) { + return; + } + + setIsFocused(false); + }, [editingState]); + const reportActionKeys = useMemo(() => (rawReportActions ? Object.keys(rawReportActions) : []), [rawReportActions]); const isEditingLastReportAction = useMemo(() => editingReportActionID === reportActionKeys.at(-1), [editingReportActionID, reportActionKeys]); - const shouldFocusComposerOnScreenFocus = shouldFocusInputOnScreenFocus || !!draftComment; - const [targetReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${effectiveTransactionThreadReportID ?? reportID}`); const reportAncestors = useAncestors(report); const targetReportAncestors = useAncestors(targetReport); const {scrollOffsetRef} = useContext(ActionListContext); - /** - * Updates the Highlight state of the composer - */ - const [isFocused, setIsFocused] = useState(() => { - return shouldFocusComposerOnScreenFocus && !initialModalState?.isVisible && !initialModalState?.willAlertModalBecomeVisible; - }); - const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(isComposerFullSize); const {isScrollLayoutTriggered, raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); @@ -784,7 +800,7 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { onAddActionPressed={onAddActionPressed} onItemSelected={onItemSelected} onCanceledAttachmentPicker={() => { - if (!shouldFocusComposerOnScreenFocus) { + if (!shouldFocusInputOnScreenFocus) { return; } focus(); From 02eabb6100c7ad8da41648897cea31e1bc285ed7 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 3 Apr 2026 12:29:15 +0100 Subject: [PATCH 149/233] fix: only blur composer focus when no draft comment exists --- .../inbox/report/ReportActionCompose/ReportActionCompose.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 52199536c0ad..e2c7e9e6a813 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -261,7 +261,7 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { // Reset composer focus when editing is turned off, but not on the initial chat open. useEffect(() => { - if (editingState !== 'off' || !wasEditingBefore.current) { + if (editingState !== 'off' || !!draftComment || !wasEditingBefore.current) { return; } From c8df1b2ba81b8deeaf59c19abbc7dee84d2e32b8 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 3 Apr 2026 12:34:10 +0100 Subject: [PATCH 150/233] Revert "refactor: simplify `ComposerWithSuggestions` edit state effect by adding key" This reverts commit 4f1fa0889836f1ce744dfa48306522f287374d4f. --- .../ComposerWithSuggestions.tsx | 28 ++++++++++++---- .../ReportActionCompose.tsx | 32 +++++++++---------- 2 files changed, 37 insertions(+), 23 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 92cb1557a453..8107686f0b5d 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -335,18 +335,34 @@ function ComposerWithSuggestions({ [currentEditMessageSelection, updateSelectionImperatively], ); - const isFirstEditingRenderRef = useRef(isEditing); + const wasEditing = useRef(isEditing); const wasEditingInComposerRef = useRef(shouldUseNarrowLayout); + const previousDraftSelectionRef = useRef(null); useEffect(() => { // If the draft message is already being submitted, do nothing. + if (editingState === 'submitted') { + return; + } + if (editingState !== 'editing') { + if (wasEditing.current && wasEditingInComposerRef.current) { + // Editing just ended in the composer – restore the draft comment and its previous selection. + applyComposerValue(draftComment ?? '', {selection: previousDraftSelectionRef.current}); + } + + wasEditing.current = false; + wasEditingInComposerRef.current = shouldUseNarrowLayout; + previousDraftSelectionRef.current = null; return; } - // This is the initial render of the composer when editing is turned on - if (isFirstEditingRenderRef.current) { - isFirstEditingRenderRef.current = false; + // Editing just started. + if (!wasEditing.current) { + // Store the draft selection before switching into edit mode so we can restore it later. + previousDraftSelectionRef.current = selection; + + wasEditing.current = true; wasEditingInComposerRef.current = shouldUseNarrowLayout; if (!shouldUseNarrowLayout) { @@ -376,7 +392,7 @@ function ComposerWithSuggestions({ if (shouldUseNarrowLayout) { applyComposerValue(editingMessage ?? '', {isEditingInComposer: true}); } - }, [applyComposerValue, draftComment, editingMessage, editingState, isEditing, selection, shouldUseNarrowLayout, updateSelectionImperatively]); + }, [applyComposerValue, draftComment, editingMessage, editingState, selection, shouldUseNarrowLayout, updateSelectionImperatively]); const {superWideRHPRouteKeys} = useWideRHPState(); // When SearchReport is stacked above another RHP, delay autofocus until after the transition completes to avoid animation jank @@ -592,7 +608,7 @@ function ComposerWithSuggestions({ syncSelectionWithOnChangeTextRef.current = {position, value: newComment}; } - if (!isFirstEditingRenderRef.current) { + if (!wasEditing.current) { setSelection((prevSelection) => ({ ...prevSelection, start: position, diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index e2c7e9e6a813..102a9ba857c8 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -240,7 +240,6 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { const [didResetComposerHeight, setDidResetComposerHeight] = useState(false); const isEditingInComposer = shouldUseNarrowLayout && editingState !== 'off' && !didResetComposerHeight; - const composerEditingToggleKey = editingReportActionID; useEffect(() => { if (editingState !== 'off' || !didResetComposerHeight) { @@ -250,23 +249,23 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { setDidResetComposerHeight(false); }, [didResetComposerHeight, editingState]); - // Track whether the user was editing a message before - const wasEditingBefore = useRef(false); - useEffect(() => { - if (editingState === 'off') { - return; - } - wasEditingBefore.current = true; - }, [editingState]); + // // Track whether the user was editing a message before + // const wasEditingBefore = useRef(false); + // useEffect(() => { + // if (editingState === 'off') { + // return; + // } + // wasEditingBefore.current = true; + // }, [editingState]); - // Reset composer focus when editing is turned off, but not on the initial chat open. - useEffect(() => { - if (editingState !== 'off' || !!draftComment || !wasEditingBefore.current) { - return; - } + // // Reset composer focus when editing is turned off, but not on the initial chat open. + // useEffect(() => { + // if (editingState !== 'off' || !!draftComment || !wasEditingBefore.current) { + // return; + // } - setIsFocused(false); - }, [editingState]); + // setIsFocused(false); + // }, [draftComment, editingState]); const reportActionKeys = useMemo(() => (rawReportActions ? Object.keys(rawReportActions) : []), [rawReportActions]); const isEditingLastReportAction = useMemo(() => editingReportActionID === reportActionKeys.at(-1), [editingReportActionID, reportActionKeys]); @@ -810,7 +809,6 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { /> )} { composerRef.current = ref; composerRefShared.set({ From eaca09d3f67dc9f97248dacd88aafb465809808f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 3 Apr 2026 12:52:46 +0100 Subject: [PATCH 151/233] fix: natively update the text value to force re-layout --- .../ComposerWithSuggestions.tsx | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 8107686f0b5d..47386779d8ac 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -292,12 +292,24 @@ function ComposerWithSuggestions({ const commentRef = useRef(value); - const updateSelectionImperatively = useCallback((start: number, end: number) => { + const updateValueImperatively = useCallback((nextValue: string) => { if (!isIOSNative) { return; } - // ensure that selection is set imperatively after all state changes are effective + // Ensure that native text value is set imperatively after all state changes are effective + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => { + composerRef.current?.setNativeProps({text: nextValue}); + }); + }, []); + + const updateNativeSelectionValue = useCallback((start: number, end: number) => { + if (!isIOSNative) { + return; + } + + // Ensure that native selection value is set imperatively after all state changes are effective // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { // note: this implementation is only available on non-web RN, thus the wrapping @@ -325,14 +337,18 @@ function ComposerWithSuggestions({ emojisPresentBefore.current = extractEmojis(nextValue); setValue(nextValue); + // We need to manually update the native text prop, + // in order to force a re-calculation of the composer height and layout. + updateValueImperatively(nextValue); + setSelection(selectionToApply); - updateSelectionImperatively(selectionToApply.start, selectionToApply.end ?? selectionToApply.start); + updateNativeSelectionValue(selectionToApply.start, selectionToApply.end ?? selectionToApply.start); if (options?.isEditingInComposer) { composerRef.current?.focus(); } }, - [currentEditMessageSelection, updateSelectionImperatively], + [currentEditMessageSelection, updateNativeSelectionValue, updateValueImperatively], ); const wasEditing = useRef(isEditing); @@ -392,7 +408,7 @@ function ComposerWithSuggestions({ if (shouldUseNarrowLayout) { applyComposerValue(editingMessage ?? '', {isEditingInComposer: true}); } - }, [applyComposerValue, draftComment, editingMessage, editingState, selection, shouldUseNarrowLayout, updateSelectionImperatively]); + }, [applyComposerValue, draftComment, editingMessage, editingState, selection, shouldUseNarrowLayout, updateNativeSelectionValue]); const {superWideRHPRouteKeys} = useWideRHPState(); // When SearchReport is stacked above another RHP, delay autofocus until after the transition completes to avoid animation jank @@ -778,9 +794,9 @@ function ComposerWithSuggestions({ const positionSnapshot = syncSelectionWithOnChangeTextRef.current.position; syncSelectionWithOnChangeTextRef.current = null; - updateSelectionImperatively(positionSnapshot, positionSnapshot); + updateNativeSelectionValue(positionSnapshot, positionSnapshot); }, - [clearComposerHeight, updateComment, updateSelectionImperatively], + [clearComposerHeight, updateComment, updateNativeSelectionValue], ); const onSelectionChange = useCallback( From acfd28c3566e25deff0b5d588cbe12ac64e433f5 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 3 Apr 2026 12:53:19 +0100 Subject: [PATCH 152/233] fix: remove unused code --- .../ReportActionCompose.tsx | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 102a9ba857c8..191bee65d2f8 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -249,24 +249,6 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { setDidResetComposerHeight(false); }, [didResetComposerHeight, editingState]); - // // Track whether the user was editing a message before - // const wasEditingBefore = useRef(false); - // useEffect(() => { - // if (editingState === 'off') { - // return; - // } - // wasEditingBefore.current = true; - // }, [editingState]); - - // // Reset composer focus when editing is turned off, but not on the initial chat open. - // useEffect(() => { - // if (editingState !== 'off' || !!draftComment || !wasEditingBefore.current) { - // return; - // } - - // setIsFocused(false); - // }, [draftComment, editingState]); - const reportActionKeys = useMemo(() => (rawReportActions ? Object.keys(rawReportActions) : []), [rawReportActions]); const isEditingLastReportAction = useMemo(() => editingReportActionID === reportActionKeys.at(-1), [editingReportActionID, reportActionKeys]); From 383ec9f83a5774bb9422263a3980bd72a5d78aaa Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 3 Apr 2026 13:14:10 +0100 Subject: [PATCH 153/233] fix: remove unnecessary duplicate update of composer value --- .../report/ReportActionCompose/ComposerWithSuggestions.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 47386779d8ac..0f600a2dc1a7 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -404,10 +404,6 @@ function ComposerWithSuggestions({ wasEditingInComposerRef.current = false; applyComposerValue(draftComment ?? ''); } - - if (shouldUseNarrowLayout) { - applyComposerValue(editingMessage ?? '', {isEditingInComposer: true}); - } }, [applyComposerValue, draftComment, editingMessage, editingState, selection, shouldUseNarrowLayout, updateNativeSelectionValue]); const {superWideRHPRouteKeys} = useWideRHPState(); From 00bb4666f7dcd65bbd334e77fbd5f0944e1cb366 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 3 Apr 2026 13:17:13 +0100 Subject: [PATCH 154/233] fix: composer resizing without adding react key to composer --- .../ComposerWithSuggestions.tsx | 26 +++++++------------ 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 0f600a2dc1a7..e1253195dc54 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -292,18 +292,6 @@ function ComposerWithSuggestions({ const commentRef = useRef(value); - const updateValueImperatively = useCallback((nextValue: string) => { - if (!isIOSNative) { - return; - } - - // Ensure that native text value is set imperatively after all state changes are effective - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => { - composerRef.current?.setNativeProps({text: nextValue}); - }); - }, []); - const updateNativeSelectionValue = useCallback((start: number, end: number) => { if (!isIOSNative) { return; @@ -322,6 +310,7 @@ function ComposerWithSuggestions({ isEditingInComposer?: boolean; shouldMoveSelectionToEnd?: boolean; selection?: TextSelection | null; + shouldForceNativeValueUpdate?: boolean; }; const applyComposerValue = useCallback( @@ -338,8 +327,11 @@ function ComposerWithSuggestions({ setValue(nextValue); // We need to manually update the native text prop, - // in order to force a re-calculation of the composer height and layout. - updateValueImperatively(nextValue); + // in order to force a re-calculation of the composer height and layout, + // when the composer changes in or out of edit mode. + if (isIOSNative && options?.shouldForceNativeValueUpdate) { + composerRef.current?.setNativeProps({text: nextValue}); + } setSelection(selectionToApply); updateNativeSelectionValue(selectionToApply.start, selectionToApply.end ?? selectionToApply.start); @@ -348,7 +340,7 @@ function ComposerWithSuggestions({ composerRef.current?.focus(); } }, - [currentEditMessageSelection, updateNativeSelectionValue, updateValueImperatively], + [currentEditMessageSelection, updateNativeSelectionValue], ); const wasEditing = useRef(isEditing); @@ -364,7 +356,7 @@ function ComposerWithSuggestions({ if (editingState !== 'editing') { if (wasEditing.current && wasEditingInComposerRef.current) { // Editing just ended in the composer – restore the draft comment and its previous selection. - applyComposerValue(draftComment ?? '', {selection: previousDraftSelectionRef.current}); + applyComposerValue(draftComment ?? '', {selection: previousDraftSelectionRef.current, shouldForceNativeValueUpdate: true}); } wasEditing.current = false; @@ -387,7 +379,7 @@ function ComposerWithSuggestions({ } // In narrow layout we always show the message being edited. // When starting to edit in the composer, always place the cursor at the end of the message. - applyComposerValue(editingMessage ?? '', {isEditingInComposer: true, shouldMoveSelectionToEnd: true}); + applyComposerValue(editingMessage ?? '', {isEditingInComposer: true, shouldMoveSelectionToEnd: true, shouldForceNativeValueUpdate: true}); return; } From 85bc5cbbfd0458eec2d417000af10907c7a37d46 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 3 Apr 2026 13:20:56 +0100 Subject: [PATCH 155/233] fix: restore previous focus after editing stops --- .../report/ReportActionCompose/ComposerWithSuggestions.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index e1253195dc54..9b3b72b8b8b9 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -345,6 +345,7 @@ function ComposerWithSuggestions({ const wasEditing = useRef(isEditing); const wasEditingInComposerRef = useRef(shouldUseNarrowLayout); + const wasComposerFocusedBeforeEditingRef = useRef(false); const previousDraftSelectionRef = useRef(null); useEffect(() => { @@ -357,6 +358,9 @@ function ComposerWithSuggestions({ if (wasEditing.current && wasEditingInComposerRef.current) { // Editing just ended in the composer – restore the draft comment and its previous selection. applyComposerValue(draftComment ?? '', {selection: previousDraftSelectionRef.current, shouldForceNativeValueUpdate: true}); + if (!wasComposerFocusedBeforeEditingRef.current) { + composerRef.current?.blur(); + } } wasEditing.current = false; @@ -367,6 +371,7 @@ function ComposerWithSuggestions({ // Editing just started. if (!wasEditing.current) { + wasComposerFocusedBeforeEditingRef.current = composerRef.current?.isFocused() ?? false; // Store the draft selection before switching into edit mode so we can restore it later. previousDraftSelectionRef.current = selection; From 7f6e7577e7f76521f909b945433d1511de7badc1 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 3 Apr 2026 13:27:52 +0100 Subject: [PATCH 156/233] fix: still update composer value when editingReportAction changes --- .../ReportActionCompose/ComposerWithSuggestions.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 9b3b72b8b8b9..43d17c64ed5d 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -347,6 +347,7 @@ function ComposerWithSuggestions({ const wasEditingInComposerRef = useRef(shouldUseNarrowLayout); const wasComposerFocusedBeforeEditingRef = useRef(false); const previousDraftSelectionRef = useRef(null); + const previousEditingReportActionIDRef = useRef(null); useEffect(() => { // If the draft message is already being submitted, do nothing. @@ -400,8 +401,16 @@ function ComposerWithSuggestions({ if (!shouldUseNarrowLayout && wasEditingInComposerRef.current) { wasEditingInComposerRef.current = false; applyComposerValue(draftComment ?? ''); + return; + } + + // The editing report action and message changed + if (shouldUseNarrowLayout && editingReportActionID !== previousEditingReportActionIDRef.current) { + applyComposerValue(editingMessage ?? '', {isEditingInComposer: true, shouldForceNativeValueUpdate: true}); } - }, [applyComposerValue, draftComment, editingMessage, editingState, selection, shouldUseNarrowLayout, updateNativeSelectionValue]); + + previousEditingReportActionIDRef.current = editingReportActionID; + }, [applyComposerValue, draftComment, editingMessage, editingReportActionID, editingState, selection, shouldUseNarrowLayout, updateNativeSelectionValue]); const {superWideRHPRouteKeys} = useWideRHPState(); // When SearchReport is stacked above another RHP, delay autofocus until after the transition completes to avoid animation jank From 1c3992f6d626d4d01a48c7e1678d947b37246191 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 10 Apr 2026 19:41:38 +0100 Subject: [PATCH 157/233] Update Mobile-Expensify --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 3cbd49364f08..2e73c3e8065b 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 3cbd49364f08cf2a646d7a2f4beca5e11cddaf26 +Subproject commit 2e73c3e8065b85bc564c7657a33edfa18a0fa8b4 From da6c2208ff54da8de64fe5723a24e69aa22b3eaa Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 10 Apr 2026 22:36:46 +0100 Subject: [PATCH 158/233] fix: TS and ESLint errors --- .../report/ReportActionCompose/ComposerContext.ts | 2 +- .../report/ReportActionCompose/ComposerProvider.tsx | 5 ++--- .../ComposerWithSuggestions.tsx | 10 +++++----- .../ComposerWithSuggestions/index.tsx | 3 --- .../report/ReportActionCompose/ReportActionCompose.tsx | 2 +- .../report/ReportActionCompose/useComposerFocus.ts | 2 +- 6 files changed, 10 insertions(+), 14 deletions(-) rename src/pages/inbox/report/ReportActionCompose/{ComposerWithSuggestions => }/ComposerWithSuggestions.tsx (98%) delete mode 100644 src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/index.tsx diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts index 3378fb68bbd0..10231c09cedf 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts +++ b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts @@ -4,7 +4,7 @@ import type {BlurEvent, TextInputSelectionChangeEvent, View} from 'react-native' import type {Emoji} from '@assets/emojis/types'; import type {Mention} from '@components/MentionSuggestions'; import type {FileObject} from '@src/types/utils/Attachment'; -import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; +import type {ComposerRef} from './ComposerWithSuggestions'; type SuggestionsRef = { resetSuggestions: () => void; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx index 64cdd5731924..125f04bcaaa4 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx @@ -15,7 +15,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {FileObject} from '@src/types/utils/Attachment'; import {ComposerActionsContext, ComposerMetaContext, ComposerSendActionsContext, ComposerSendStateContext, ComposerStateContext, ComposerTextContext} from './ComposerContext'; import type {SuggestionsRef} from './ComposerContext'; -import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; +import type {ComposerRef} from './ComposerWithSuggestions'; import useComposerFocus from './useComposerFocus'; const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); @@ -27,7 +27,6 @@ type ComposerProviderProps = { function ComposerProvider({children, reportID}: ComposerProviderProps) { const [blockedFromConcierge] = useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); - const [shouldShowComposeInput = true] = useOnyx(ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT); const [initialModalState] = useOnyx(ONYXKEYS.MODAL); const [draftComment] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); @@ -35,7 +34,7 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { const shouldFocusComposerOnScreenFocus = shouldFocusInputOnScreenFocus || !!draftComment; - const initialFocused = shouldFocusComposerOnScreenFocus && shouldShowComposeInput && !initialModalState?.isVisible && !initialModalState?.willAlertModalBecomeVisible; + const initialFocused = shouldFocusComposerOnScreenFocus && !initialModalState?.isVisible && !initialModalState?.willAlertModalBecomeVisible; const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(isComposerFullSize); const [isMenuVisible, setMenuVisibility] = useState(false); diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx similarity index 98% rename from src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx rename to src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index ff903d77e125..34cfc7b064b7 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -48,11 +48,6 @@ import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManag import {isValidReportIDFromPath, shouldAutoFocusOnKeyPress} from '@libs/ReportUtils'; import updateMultilineInputRange from '@libs/updateMultilineInputRange'; import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; -import getCursorPosition from '@pages/inbox/report/ReportActionCompose/getCursorPosition'; -import getScrollPosition from '@pages/inbox/report/ReportActionCompose/getScrollPosition'; -import type {SuggestionsRef} from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; -import SilentCommentUpdater from '@pages/inbox/report/ReportActionCompose/SilentCommentUpdater'; -import Suggestions from '@pages/inbox/report/ReportActionCompose/Suggestions'; import {isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; import type {OnEmojiSelected} from '@userActions/EmojiPickerAction'; import {inputFocusChange} from '@userActions/InputFocus'; @@ -66,6 +61,11 @@ import type {FileObject} from '@src/types/utils/Attachment'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; // eslint-disable-next-line no-restricted-imports import findNodeHandle from '@src/utils/findNodeHandle'; +import getCursorPosition from './getCursorPosition'; +import getScrollPosition from './getScrollPosition'; +import type {SuggestionsRef} from './ReportActionCompose'; +import SilentCommentUpdater from './SilentCommentUpdater'; +import Suggestions from './Suggestions'; type SyncSelection = { position: number; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/index.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/index.tsx deleted file mode 100644 index f2aebd390ba6..000000000000 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import ComposerWithSuggestions from './ComposerWithSuggestions'; - -export default ComposerWithSuggestions; diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 7f3600739379..964cc6e96b66 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -99,7 +99,7 @@ import type {FileObject} from '@src/types/utils/Attachment'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; import ComposerWithSuggestions from './ComposerWithSuggestions'; -import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; +import type {ComposerRef} from './ComposerWithSuggestions'; import SendButton from './SendButton'; import useAttachmentUploadValidation from './useAttachmentUploadValidation'; diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerFocus.ts b/src/pages/inbox/report/ReportActionCompose/useComposerFocus.ts index 78ca74dba6e3..84dc41c5187e 100644 --- a/src/pages/inbox/report/ReportActionCompose/useComposerFocus.ts +++ b/src/pages/inbox/report/ReportActionCompose/useComposerFocus.ts @@ -3,7 +3,7 @@ import type {RefObject} from 'react'; import type {BlurEvent, View} from 'react-native'; import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; import type {SuggestionsRef} from './ComposerContext'; -import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; +import type {ComposerRef} from './ComposerWithSuggestions'; const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); From e3ca84d85a0dc30c42b060b46cb054214924e1d5 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 10 Apr 2026 22:36:46 +0100 Subject: [PATCH 159/233] fix: apply changes from PR also in newly added `ComposerProvider` and context --- .../ReportActionCompose/ComposerContext.ts | 6 +-- .../ReportActionCompose/ComposerProvider.tsx | 46 ++++++------------- 2 files changed, 17 insertions(+), 35 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts index 10231c09cedf..2d19c9744a11 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts +++ b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts @@ -4,7 +4,7 @@ import type {BlurEvent, TextInputSelectionChangeEvent, View} from 'react-native' import type {Emoji} from '@assets/emojis/types'; import type {Mention} from '@components/MentionSuggestions'; import type {FileObject} from '@src/types/utils/Attachment'; -import type {ComposerRef} from './ComposerWithSuggestions'; +import type {ComposerWithSuggestionsRef} from './ComposerWithSuggestions'; type SuggestionsRef = { resetSuggestions: () => void; @@ -39,7 +39,7 @@ type ComposerActions = { setValue: (v: string) => void; setMenuVisibility: (v: boolean) => void; setIsFullComposerAvailable: (v: boolean) => void; - setComposerRef: (ref: ComposerRef | null) => void; + setComposerRef: (ref: ComposerWithSuggestionsRef | null) => void; focus: () => void; onBlur: (event: BlurEvent) => void; onFocus: () => void; @@ -58,7 +58,7 @@ type ComposerSendActions = { // Frozen — stable refs, set once type ComposerMeta = { containerRef: RefObject; - composerRef: RefObject; + composerRef: RefObject; suggestionsRef: RefObject; actionButtonRef: RefObject; isNextModalWillOpenRef: RefObject; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx index 125f04bcaaa4..3c4ca7b09574 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx @@ -1,13 +1,11 @@ -import lodashDebounce from 'lodash/debounce'; import React, {useRef, useState} from 'react'; import type {View} from 'react-native'; import {useSharedValue} from 'react-native-reanimated'; import {scheduleOnUI} from 'react-native-worklets'; -import useHandleExceedMaxCommentLength from '@hooks/useHandleExceedMaxCommentLength'; -import useHandleExceedMaxTaskTitleLength from '@hooks/useHandleExceedMaxTaskTitleLength'; import useOnyx from '@hooks/useOnyx'; import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import {chatIncludesConcierge} from '@libs/ReportUtils'; +import {useReportActionActiveEdit} from '@pages/inbox/report/ReportActionEditMessageContext'; import {setIsComposerFullSize} from '@userActions/Report'; import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User'; import CONST from '@src/CONST'; @@ -15,8 +13,9 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {FileObject} from '@src/types/utils/Attachment'; import {ComposerActionsContext, ComposerMetaContext, ComposerSendActionsContext, ComposerSendStateContext, ComposerStateContext, ComposerTextContext} from './ComposerContext'; import type {SuggestionsRef} from './ComposerContext'; -import type {ComposerRef} from './ComposerWithSuggestions'; +import type {ComposerWithSuggestionsRef} from './ComposerWithSuggestions'; import useComposerFocus from './useComposerFocus'; +import useDebouncedCommentMaxLengthValidation from './useDebouncedCommentMaxLengthValidation'; const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); @@ -49,38 +48,21 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { const userBlockedFromConcierge = isBlockedFromConciergeUserAction(blockedFromConcierge); const isBlockedFromConcierge = includesConcierge && userBlockedFromConcierge; - const {hasExceededMaxCommentLength, validateCommentMaxLength, setHasExceededMaxCommentLength} = useHandleExceedMaxCommentLength(); - const {hasExceededMaxTaskTitleLength, validateTaskTitleMaxLength, setHasExceededMaxTitleLength} = useHandleExceedMaxTaskTitleLength(); - - let exceededMaxLength: number | null = null; - if (hasExceededMaxTaskTitleLength) { - exceededMaxLength = CONST.TITLE_CHARACTER_LIMIT; - } else if (hasExceededMaxCommentLength) { - exceededMaxLength = CONST.MAX_COMMENT_LENGTH; - } + const {editingReportAction} = useReportActionActiveEdit(); + const {debouncedCommentMaxLengthValidation, exceededMaxLength, isExceedingMaxLength, isTaskTitle} = useDebouncedCommentMaxLengthValidation({ + reportID, + isEditing: !!editingReportAction, + }); const isSendDisabled = isEmpty || isBlockedFromConcierge || !!exceededMaxLength; - const validateMaxLength = (v: string) => { - const taskCommentMatch = v?.match(CONST.REGEX.TASK_TITLE_WITH_OPTIONAL_SHORT_MENTION); - if (taskCommentMatch) { - const title = taskCommentMatch?.[3] ? taskCommentMatch[3].trim().replaceAll('\n', ' ') : ''; - setHasExceededMaxCommentLength(false); - return validateTaskTitleMaxLength(title); - } - setHasExceededMaxTitleLength(false); - return validateCommentMaxLength(v, {reportID}); - }; - - const debouncedValidate = lodashDebounce(validateMaxLength, CONST.TIMING.COMMENT_LENGTH_DEBOUNCE_TIME, {leading: true}); - const containerRef = useRef(null); const suggestionsRef = useRef(null); - const composerRef = useRef(null); + const composerRef = useRef(null); const actionButtonRef = useRef(null); const attachmentFileRef = useRef(null); - const composerRefShared = useSharedValue>({}); + const composerRefShared = useSharedValue>({}); const {isFocused, onBlur, onFocus, focus, onAddActionPressed, onItemSelected, onTriggerAttachmentPicker, isNextModalWillOpenRef} = useComposerFocus({ composerRef, @@ -98,7 +80,7 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { }; const handleSendMessage = () => { - if (isSendDisabled || !debouncedValidate.flush()) { + if (isSendDisabled || !debouncedCommentMaxLengthValidation.flush()) { return; } @@ -118,7 +100,7 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { }); }; - const setComposerRef = (ref: ComposerRef | null) => { + const setComposerRef = (ref: ComposerWithSuggestionsRef | null) => { composerRef.current = ref; composerRefShared.set({ clearWorklet: ref?.clearWorklet, @@ -129,7 +111,7 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { if (v.length === 0 && isComposerFullSize) { setIsComposerFullSize(reportID, false); } - debouncedValidate(v); + debouncedCommentMaxLengthValidation(v); }; const text = value; @@ -143,7 +125,7 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { const composerSendState = { isSendDisabled, exceededMaxLength, - hasExceededMaxTaskTitleLength, + hasExceededMaxTaskTitleLength: isTaskTitle && isExceedingMaxLength, isBlockedFromConcierge, }; From 99c61680cf667da542be6dcd4dcc8bc27e7a0190 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 10 Apr 2026 22:53:35 +0100 Subject: [PATCH 160/233] Update package-lock.json --- package-lock.json | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index d99aad2efc26..a6b83ff7c884 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34857,17 +34857,6 @@ "react-native-blob-util": ">=0.13.7" } }, - "node_modules/react-native-performance": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/react-native-performance/-/react-native-performance-6.0.0.tgz", - "integrity": "sha512-Sca75O8jhqXAnNbqvINnrw248Kv9cIwoGxToD8u2uX+BrkAxxXS+YhClEV5L3JdiOpdNCO1MJ5R9bgs2VkNpFg==", - "license": "MIT", - "optional": true, - "peer": true, - "peerDependencies": { - "react-native": "*" - } - }, "node_modules/react-native-permissions": { "version": "5.4.0", "license": "MIT", From 84af4ce9d3239888452d35b384a08fcf0a420f3b Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 10 Apr 2026 22:56:20 +0100 Subject: [PATCH 161/233] Revert "Update package-lock.json" This reverts commit 99c61680cf667da542be6dcd4dcc8bc27e7a0190. --- package-lock.json | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/package-lock.json b/package-lock.json index a6b83ff7c884..d99aad2efc26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34857,6 +34857,17 @@ "react-native-blob-util": ">=0.13.7" } }, + "node_modules/react-native-performance": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/react-native-performance/-/react-native-performance-6.0.0.tgz", + "integrity": "sha512-Sca75O8jhqXAnNbqvINnrw248Kv9cIwoGxToD8u2uX+BrkAxxXS+YhClEV5L3JdiOpdNCO1MJ5R9bgs2VkNpFg==", + "license": "MIT", + "optional": true, + "peer": true, + "peerDependencies": { + "react-native": "*" + } + }, "node_modules/react-native-permissions": { "version": "5.4.0", "license": "MIT", From acc8ba577d1445201254d2c304a9de1afb39eb2a Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 10 Apr 2026 23:05:29 +0100 Subject: [PATCH 162/233] fix: TS and ESLint errors --- .../inbox/report/ReportActionCompose/useComposerFocus.ts | 4 ++-- src/setup/index.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerFocus.ts b/src/pages/inbox/report/ReportActionCompose/useComposerFocus.ts index 84dc41c5187e..1329f2aae32c 100644 --- a/src/pages/inbox/report/ReportActionCompose/useComposerFocus.ts +++ b/src/pages/inbox/report/ReportActionCompose/useComposerFocus.ts @@ -3,12 +3,12 @@ import type {RefObject} from 'react'; import type {BlurEvent, View} from 'react-native'; import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; import type {SuggestionsRef} from './ComposerContext'; -import type {ComposerRef} from './ComposerWithSuggestions'; +import type {ComposerWithSuggestionsRef} from './ComposerWithSuggestions'; const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); type UseComposerFocusParams = { - composerRef: RefObject; + composerRef: RefObject; suggestionsRef: RefObject; actionButtonRef: RefObject; initialFocused: boolean; diff --git a/src/setup/index.ts b/src/setup/index.ts index 81b73dea4027..b1cd0a809a9d 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -48,7 +48,7 @@ export default function () { [ONYXKEYS.SESSION]: {loading: false}, [ONYXKEYS.ACCOUNT]: CONST.DEFAULT_ACCOUNT_DATA, [ONYXKEYS.NETWORK]: CONST.DEFAULT_NETWORK_DATA, - [ONYXKEYS.RAM_ONLY_IS_SIDEBAR_LOADED]: false, + [ONYXKEYS.IS_SIDEBAR_LOADED]: false, [ONYXKEYS.MODAL]: { isVisible: false, willAlertModalBecomeVisible: false, From a610c28ccf160c2aea94a88625d631fb066d1fe8 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 16 Apr 2026 17:20:24 +0100 Subject: [PATCH 163/233] fix: React Compiler compliance --- src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx b/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx index 5d7c1ec7082a..37660344d0c3 100644 --- a/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx +++ b/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx @@ -55,7 +55,7 @@ function DuplicateTransactionItem({transaction, index, onPreviewPressed}: Duplic const {editingMessage, editingReportAction} = useReportActionActiveEdit(); - const draftMessage = editingReportAction?.reportActionID === action?.reportActionID ? (editingMessage ?? undefined) : undefined; + const draftMessage = editingReportAction && action && editingReportAction.reportActionID === action.reportActionID ? (editingMessage ?? undefined) : undefined; if (!action || !report) { return null; From 810f94a4a4b1eedf2edd00c6cadfc3d275aaa57f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 17 Apr 2026 11:19:04 +0100 Subject: [PATCH 164/233] fix: `ImageSVG` icon fill color not updating on Android --- src/components/ImageSVG/index.android.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/ImageSVG/index.android.tsx b/src/components/ImageSVG/index.android.tsx index 2da157479722..f547d5a96abd 100644 --- a/src/components/ImageSVG/index.android.tsx +++ b/src/components/ImageSVG/index.android.tsx @@ -5,7 +5,6 @@ import getImageRecyclingKey from '@libs/getImageRecyclingKey'; import type ImageSVGProps from './types'; function ImageSVG({src, width = '100%', height = '100%', fill, contentFit = 'cover', style, onLoadEnd}: ImageSVGProps) { - const tintColorProp = fill ? {tintColor: fill} : {}; const isReactComponent = typeof src === 'function'; // Clear memory cache when unmounting images to avoid memory overload @@ -60,8 +59,10 @@ function ImageSVG({src, width = '100%', height = '100%', fill, contentFit = 'cov source={src} recyclingKey={getImageRecyclingKey(src)} style={[{width, height}, style as ExpoImageProps['style']]} - // eslint-disable-next-line react/jsx-props-no-spreading - {...tintColorProp} + tintColor={fill} + // On android, there's an issue where the fill color of the icon does not change, + // unless the component is remounted. (https://github.com/Expensify/App/pull/76741#issuecomment-4245274687) + key={fill} /> ); } From cfd172ddb9793500d2616128b6ef8f97aef4ab18 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 17 Apr 2026 12:10:29 +0100 Subject: [PATCH 165/233] fix: `isCommentEmpty` not updated after editing stopped --- .../ComposerWithSuggestions.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 15748f149313..b39af22b7f6e 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -177,6 +177,10 @@ type SwitchToCurrentReportProps = { }; const {RNTextInputReset} = NativeModules; +function getIsCommentEmpty(comment: string) { + return !!comment.match(/^(\s)*$/); +} + const isIOSNative = getPlatform() === CONST.PLATFORM.IOS; /** @@ -361,6 +365,7 @@ function ComposerWithSuggestions({ if (wasEditing.current && wasEditingInComposerRef.current) { // Editing just ended in the composer – restore the draft comment and its previous selection. applyComposerValue(draftComment ?? '', {selection: previousDraftSelectionRef.current, shouldForceNativeValueUpdate: true}); + setIsCommentEmpty(getIsCommentEmpty(draftComment ?? '')); if (!wasComposerFocusedBeforeEditingRef.current) { composerRef.current?.blur(); } @@ -412,7 +417,7 @@ function ComposerWithSuggestions({ } previousEditingReportActionIDRef.current = editingReportActionID; - }, [applyComposerValue, draftComment, editingMessage, editingReportActionID, editingState, selection, shouldUseNarrowLayout, updateNativeSelectionValue]); + }, [applyComposerValue, draftComment, editingMessage, editingReportActionID, editingState, selection, setIsCommentEmpty, shouldUseNarrowLayout, updateNativeSelectionValue]); const {superWideRHPRouteKeys} = useWideRHPState(); // When SearchReport is stacked above another RHP, delay autofocus until after the transition completes to avoid animation jank @@ -597,9 +602,6 @@ function ComposerWithSuggestions({ const commentWithSpaceInserted = isEmojiInserted ? insertWhiteSpaceAtIndex(effectiveCommentValue, endIndex) : effectiveCommentValue; const {text: emojiConvertedText, emojis, cursorPosition} = replaceAndExtractEmojis(commentWithSpaceInserted, preferredSkinTone, preferredLocale); - const newComment = insertTextVSBetweenDigitAndEmoji(emojiConvertedText); - const textVSOffset = getTextVSCursorOffset(emojiConvertedText, cursorPosition); - if (emojis.length) { const newEmojis = getAddedEmojis(emojis, emojisPresentBefore.current); if (newEmojis.length) { @@ -609,9 +611,13 @@ function ComposerWithSuggestions({ } } } + + const newComment = insertTextVSBetweenDigitAndEmoji(emojiConvertedText); const newCommentConverted = convertToLTRForComposer(newComment); - const isNewCommentEmpty = !!newCommentConverted.match(/^(\s)*$/); - const isPrevCommentEmpty = !!commentRef.current.match(/^(\s)*$/); + + const textVSOffset = getTextVSCursorOffset(emojiConvertedText, cursorPosition); + const isNewCommentEmpty = getIsCommentEmpty(newCommentConverted); + const isPrevCommentEmpty = getIsCommentEmpty(commentRef.current); /** Only update isCommentEmpty state if it's different from previous one */ if (!isEditingInComposer && isNewCommentEmpty !== isPrevCommentEmpty) { From 0b74044ae7cee6723676692db36074b58c66ea58 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 17 Apr 2026 21:37:58 +0100 Subject: [PATCH 166/233] refactor: simplify composer focus in `ReportActionCompose` --- .../ReportActionCompose/ReportActionCompose.tsx | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 191bee65d2f8..1e4b1cb1df18 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -343,13 +343,6 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { return translate('reportActionCompose.writeSomething'); }, [includesConcierge, translate, userBlockedFromConcierge, isExpenseRelatedReport, canUserPerformWriteAction, isEnglishLocale]); - const focus = () => { - if (composerRef.current === null) { - return; - } - composerRef.current?.focus(true); - }; - const isKeyboardVisibleWhenShowingModalRef = useRef(false); const isNextModalWillOpenRef = useRef(false); @@ -784,7 +777,7 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { if (!shouldFocusInputOnScreenFocus) { return; } - focus(); + composerRef.current?.focus(true); }} actionButtonRef={actionButtonRef} shouldDisableAttachmentItem={isExceedingMaxLength} @@ -851,7 +844,7 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { if (activeElementId === CONST.COMPOSER.NATIVE_ID || activeElementId === CONST.EMOJI_PICKER_BUTTON_NATIVE_ID) { return; } - focus(); + composerRef.current?.focus(true); }} onEmojiSelected={(...args) => composerRef.current?.replaceSelectionWithText(...args)} emojiPickerID={report?.reportID} From 489139080be2ade67d082d2579cd51188a6e2da0 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 17 Apr 2026 21:58:52 +0100 Subject: [PATCH 167/233] fix: allow re-focussing input on Android --- src/libs/focusComposerWithDelay/index.ts | 73 ++++++++++++++++++++---- src/libs/focusComposerWithDelay/types.ts | 2 +- 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/src/libs/focusComposerWithDelay/index.ts b/src/libs/focusComposerWithDelay/index.ts index 1f18a4b9d4bc..98e034ae9f20 100644 --- a/src/libs/focusComposerWithDelay/index.ts +++ b/src/libs/focusComposerWithDelay/index.ts @@ -1,20 +1,54 @@ +import {InteractionManager} from 'react-native'; +import {KeyboardController} from 'react-native-keyboard-controller'; import ComposerFocusManager from '@libs/ComposerFocusManager'; +import getPlatform from '@libs/getPlatform'; import isWindowReadyToFocus from '@libs/isWindowReadyToFocus'; import * as EmojiPickerAction from '@userActions/EmojiPickerAction'; import CONST from '@src/CONST'; import setTextInputSelection from './setTextInputSelection'; -import type {FocusComposerWithDelay, InputType} from './types'; +import type {FocusComposerWithDelay, InputType, Selection} from './types'; + +/** + * When the field already has focus, RN's `focus()` often does not show the IME again. + * `KeyboardController.setFocusTo('current')` re-applies focus via native (`requestFocusFromJS` on Android, + * `reloadInputViews` + `focus` on iOS) without blurring first. + * + * @see https://kirillzyusko.github.io/react-native-keyboard-controller/docs/api/keyboard-controller#setfocusto + */ +function requestKeyboardForFocusedComposer(textInput: InputType, forcedSelectionRange?: Selection) { + const platform = getPlatform(); + const isNative = platform === CONST.PLATFORM.IOS || platform === CONST.PLATFORM.ANDROID; + + if (!isNative) { + return; + } + + requestIdleCallback(() => { + KeyboardController.setFocusTo('current'); + if (forcedSelectionRange) { + setTextInputSelection(textInput, forcedSelectionRange); + } + }); +} /** * Create a function that focuses the composer. */ function focusComposerWithDelay(textInput: InputType | null, delay: number = CONST.COMPOSER_FOCUS_DELAY): FocusComposerWithDelay { + function getIsFocused() { + if (textInput && 'isFocused' in textInput) { + return textInput.isFocused(); + } + return false; + } + /** * Focus the text input * @param [shouldDelay] Impose delay before focusing the text input * @param [forcedSelectionRange] Force selection range of text input + * @param [forceKeyboardIfAlreadyFocused] Use KeyboardController so the soft keyboard can show without blur/refocus */ - return (shouldDelay = false, forcedSelectionRange = undefined) => { + return async (shouldDelay = false, forcedSelectionRange = undefined, forceKeyboardIfAlreadyFocused = false) => { // There could be other animations running while we trigger manual focus. // This prevents focus from making those animations janky. if (!textInput || EmojiPickerAction.isEmojiPickerVisible()) { @@ -22,23 +56,40 @@ function focusComposerWithDelay(textInput: InputType | null, delay: number = CON } if (!shouldDelay) { + if (getIsFocused()) { + if (forceKeyboardIfAlreadyFocused) { + requestKeyboardForFocusedComposer(textInput, forcedSelectionRange); + } + return; + } + textInput.focus(); if (forcedSelectionRange) { setTextInputSelection(textInput, forcedSelectionRange); } return; } - Promise.all([ComposerFocusManager.isReadyToFocus(), isWindowReadyToFocus()]).then(() => { - if (!textInput) { + + await Promise.all([ComposerFocusManager.isReadyToFocus(), isWindowReadyToFocus()]); + + if (!textInput) { + return; + } + // When the closing modal has a focused text input focus() needs a delay to properly work. + // Setting 150ms here is a temporary workaround for the Android HybridApp. It should be reverted once we identify the real root cause of this issue: https://github.com/Expensify/App/issues/56311. + setTimeout(() => { + if (getIsFocused()) { + if (forceKeyboardIfAlreadyFocused) { + // Selection is applied synchronously below; only request focus + requestKeyboardForFocusedComposer(textInput); + } return; } - // When the closing modal has a focused text input focus() needs a delay to properly work. - // Setting 150ms here is a temporary workaround for the Android HybridApp. It should be reverted once we identify the real root cause of this issue: https://github.com/Expensify/App/issues/56311. - setTimeout(() => textInput.focus(), delay); - if (forcedSelectionRange) { - setTextInputSelection(textInput, forcedSelectionRange); - } - }); + textInput.focus(); + }, delay); + if (forcedSelectionRange) { + setTextInputSelection(textInput, forcedSelectionRange); + } }; } diff --git a/src/libs/focusComposerWithDelay/types.ts b/src/libs/focusComposerWithDelay/types.ts index 97a1298e8c7a..1447161e88f2 100644 --- a/src/libs/focusComposerWithDelay/types.ts +++ b/src/libs/focusComposerWithDelay/types.ts @@ -7,7 +7,7 @@ type Selection = { positionY?: number; }; -type FocusComposerWithDelay = (shouldDelay?: boolean, forcedSelectionRange?: Selection) => void; +type FocusComposerWithDelay = (shouldDelay?: boolean, forcedSelectionRange?: Selection, forceKeyboardIfAlreadyFocused?: boolean) => Promise; type InputType = TextInput | HTMLTextAreaElement; From 64430aa42baf966b4482dd51cc0e592c164eea7c Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 17 Apr 2026 21:59:45 +0100 Subject: [PATCH 168/233] refactor: use `focusComposerWithDelay` in `refocusComposerAfterPreventFirstResponder` --- ...focusComposerAfterPreventFirstResponder.ts | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/libs/refocusComposerAfterPreventFirstResponder.ts b/src/libs/refocusComposerAfterPreventFirstResponder.ts index 6ac3fca6dcbc..94002b48e73d 100644 --- a/src/libs/refocusComposerAfterPreventFirstResponder.ts +++ b/src/libs/refocusComposerAfterPreventFirstResponder.ts @@ -1,15 +1,21 @@ -import isWindowReadyToFocus from './isWindowReadyToFocus'; +import type {ComposerRef} from '@components/Composer/types'; +import focusComposerWithDelay from './focusComposerWithDelay'; import type {ComposerType} from './ReportActionComposeFocusManager'; import ReportActionComposeFocusManager from './ReportActionComposeFocusManager'; function refocusComposerAfterPreventFirstResponder(composerToRefocusOnClose: ComposerType | undefined) { - return isWindowReadyToFocus().then(() => { - if (composerToRefocusOnClose === 'main') { - ReportActionComposeFocusManager.composerRef.current?.focus(); - } else if (composerToRefocusOnClose === 'edit') { - ReportActionComposeFocusManager.editComposerRef.current?.focus(); - } - }); + let composerRef: ComposerRef | null = null; + if (composerToRefocusOnClose === 'main') { + composerRef = ReportActionComposeFocusManager.composerRef.current; + } else if (composerToRefocusOnClose === 'edit') { + composerRef = ReportActionComposeFocusManager.editComposerRef.current; + } + + return focusComposerWithDelay(composerRef)(true); + + // return isWindowReadyToFocus().then(() => { + // composerRef?.focus(); + // }); } export default refocusComposerAfterPreventFirstResponder; From 17b0a317bcc58aa767be8bca6892a2af8d2914e0 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 17 Apr 2026 22:00:11 +0100 Subject: [PATCH 169/233] fix: composer not focussing in edit mode --- .../ComposerWithSuggestions.tsx | 41 ++++++++++--------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index b39af22b7f6e..b1cca62dc4e7 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -38,6 +38,7 @@ import {canSkipTriggerHotkeys, findCommonSuffixLength, insertText, insertWhiteSp import convertToLTRForComposer from '@libs/convertToLTRForComposer'; import {containsOnlyEmojis, extractEmojis, getAddedEmojis, getTextVSCursorOffset, insertTextVSBetweenDigitAndEmoji, replaceAndExtractEmojis} from '@libs/EmojiUtils'; import focusComposerWithDelay from '@libs/focusComposerWithDelay'; +import type {Selection} from '@libs/focusComposerWithDelay/types'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import getPlatform from '@libs/getPlatform'; import {addKeyDownPressListener, removeKeyDownPressListener} from '@libs/KeyboardShortcut/KeyDownPressListener'; @@ -77,7 +78,7 @@ type NewlyAddedChars = {startIndex: number; endIndex: number; diff: string}; type ComposerWithSuggestionsRef = ComposerRef & { /** Focus the composer */ - focus: (shouldDelay?: boolean) => void; + focus: (shouldDelay?: boolean, forcedSelectionRange?: Selection, forceKeyboardIfAlreadyFocused?: boolean) => void; /** Replace the selection with text */ replaceSelectionWithText: OnEmojiSelected; @@ -298,6 +299,24 @@ function ComposerWithSuggestions({ const commentRef = useRef(value); + const {superWideRHPRouteKeys} = useWideRHPState(); + // When SearchReport is stacked above another RHP, delay autofocus until after the transition completes to avoid animation jank + const shouldDelayAutoFocus = superWideRHPRouteKeys.length > 0 && route.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT; + const shouldDelayAutoFocusRef = useRef(shouldDelayAutoFocus); + shouldDelayAutoFocusRef.current = shouldDelayAutoFocus; + + /** + * Focus the composer text input + * @param [shouldDelay=false] Impose delay before focusing the composer + * @param [forcedSelectionRange] Optional selection to apply after focus + * @param [forceKeyboardIfAlreadyFocused] When already focused, use KeyboardController so the keyboard can show (e.g. edit-in-composer) + */ + const focus = useCallback((shouldDelay = false, forcedSelectionRange?: Selection, forceKeyboardIfAlreadyFocused = false) => { + // If we're stacked above another RHP, wait for the transition to complete before focusing. + const delay = shouldDelayAutoFocusRef.current ? CONST.ANIMATED_TRANSITION : CONST.COMPOSER_FOCUS_DELAY; + focusComposerWithDelay(composerRef.current, delay)(shouldDelay, forcedSelectionRange, forceKeyboardIfAlreadyFocused).catch(() => {}); + }, []); + const updateNativeSelectionValue = useCallback((start: number, end: number) => { if (!isIOSNative) { return; @@ -343,10 +362,10 @@ function ComposerWithSuggestions({ updateNativeSelectionValue(selectionToApply.start, selectionToApply.end ?? selectionToApply.start); if (options?.isEditingInComposer) { - composerRef.current?.focus(); + focus(true, undefined, true); } }, - [currentEditMessageSelection, updateNativeSelectionValue], + [currentEditMessageSelection, focus, updateNativeSelectionValue], ); const wasEditing = useRef(isEditing); @@ -419,12 +438,6 @@ function ComposerWithSuggestions({ previousEditingReportActionIDRef.current = editingReportActionID; }, [applyComposerValue, draftComment, editingMessage, editingReportActionID, editingState, selection, setIsCommentEmpty, shouldUseNarrowLayout, updateNativeSelectionValue]); - const {superWideRHPRouteKeys} = useWideRHPState(); - // When SearchReport is stacked above another RHP, delay autofocus until after the transition completes to avoid animation jank - const shouldDelayAutoFocus = superWideRHPRouteKeys.length > 0 && route.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT; - const shouldDelayAutoFocusRef = useRef(shouldDelayAutoFocus); - shouldDelayAutoFocusRef.current = shouldDelayAutoFocus; - const [modal] = useOnyx(ONYXKEYS.MODAL); const [preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE] = useOnyx(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE); const [editFocused] = useOnyx(ONYXKEYS.INPUT_FOCUSED); @@ -845,16 +858,6 @@ function ComposerWithSuggestions({ return suggestionsRef.current.setShouldBlockSuggestionCalc(false); }, [suggestionsRef]); - /** - * Focus the composer text input - * @param [shouldDelay=false] Impose delay before focusing the composer - */ - const focus = useCallback((shouldDelay = false) => { - // If we're stacked above another RHP, wait for the transition to complete before focusing. - const delay = shouldDelayAutoFocusRef.current ? CONST.ANIMATED_TRANSITION : CONST.COMPOSER_FOCUS_DELAY; - focusComposerWithDelay(composerRef.current, delay)(shouldDelay); - }, []); - /** * In the stacked-RHP SearchReport case we disable the TextInput's immediate `autoFocus` to avoid jank. * Make sure we still trigger a (delayed) manual focus on first render for that route. From ac41780ead1e5119fe3a39133b24d4eb1cbaa212 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 17 Apr 2026 23:50:48 +0100 Subject: [PATCH 170/233] fix: remove unused import --- src/libs/focusComposerWithDelay/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libs/focusComposerWithDelay/index.ts b/src/libs/focusComposerWithDelay/index.ts index 98e034ae9f20..eddf2ad9f3aa 100644 --- a/src/libs/focusComposerWithDelay/index.ts +++ b/src/libs/focusComposerWithDelay/index.ts @@ -1,4 +1,3 @@ -import {InteractionManager} from 'react-native'; import {KeyboardController} from 'react-native-keyboard-controller'; import ComposerFocusManager from '@libs/ComposerFocusManager'; import getPlatform from '@libs/getPlatform'; From 9a285ed95b084df1f097d1afaab40f96d9e20c0d Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 17 Apr 2026 23:59:22 +0100 Subject: [PATCH 171/233] fix: prevent composer blur when edit mode is disabled --- .../ComposerWithSuggestions.tsx | 3 +- .../ReportActionCompose.tsx | 1 - .../ReportActionCompose/useEditMessage.ts | 34 ++----------------- .../report/ReportActionItemMessageEdit.tsx | 1 - tests/unit/hooks/useEditMessage.test.ts | 1 - 5 files changed, 4 insertions(+), 36 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index b1cca62dc4e7..803b4967bd43 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -385,6 +385,7 @@ function ComposerWithSuggestions({ // Editing just ended in the composer – restore the draft comment and its previous selection. applyComposerValue(draftComment ?? '', {selection: previousDraftSelectionRef.current, shouldForceNativeValueUpdate: true}); setIsCommentEmpty(getIsCommentEmpty(draftComment ?? '')); + if (!wasComposerFocusedBeforeEditingRef.current) { composerRef.current?.blur(); } @@ -436,7 +437,7 @@ function ComposerWithSuggestions({ } previousEditingReportActionIDRef.current = editingReportActionID; - }, [applyComposerValue, draftComment, editingMessage, editingReportActionID, editingState, selection, setIsCommentEmpty, shouldUseNarrowLayout, updateNativeSelectionValue]); + }, [applyComposerValue, draftComment, editingMessage, editingReportActionID, editingState, focus, selection, setIsCommentEmpty, shouldUseNarrowLayout, updateNativeSelectionValue]); const [modal] = useOnyx(ONYXKEYS.MODAL); const [preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE] = useOnyx(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE); diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 1e4b1cb1df18..662ca515f02a 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -413,7 +413,6 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { originalReportID, reportAction: editingReportAction, shouldScrollToLastMessage: isEditingLastReportAction, - isFocused, debouncedCommentMaxLengthValidation, composerRef, }); diff --git a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts index 7e4627753169..7a9c6ff89632 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts @@ -1,32 +1,26 @@ // eslint-disable-next-line lodash/import-scope import type {DebouncedFuncLeading} from 'lodash'; import type React from 'react'; -import {useEffect, useRef} from 'react'; -import {InteractionManager} from 'react-native'; import type {ComposerRef} from '@components/Composer/types'; import useAncestors from '@hooks/useAncestors'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useOnyx from '@hooks/useOnyx'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportScrollManager from '@hooks/useReportScrollManager'; -import {isActive as isEmojiPickerActive} from '@libs/actions/EmojiPickerAction'; import {clearReportActionDrafts, editReportComment} from '@libs/actions/Report'; import focusEditAfterCancelDelete from '@libs/focusEditAfterCancelDelete'; -import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import {getOriginalReportID} from '@libs/ReportUtils'; import * as ReportActionContextMenu from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; import {useReportActionActiveEditActions} from '@pages/inbox/report/ReportActionEditMessageContext'; import {draftMessageVideoAttributeCache} from '@pages/inbox/report/useDraftMessageVideoAttributeCache'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; -import KeyboardUtils from '@src/utils/keyboard'; type UseEditMessageProps = { reportID: string | undefined; originalReportID: string | undefined; reportAction: OnyxTypes.ReportAction | null | undefined; shouldScrollToLastMessage?: boolean; - isFocused: boolean; debouncedCommentMaxLengthValidation: DebouncedFuncLeading<(value: string) => boolean>; composerRef: React.RefObject; }; @@ -34,9 +28,8 @@ type UseEditMessageProps = { /** * Delete the draft of the comment being edited. This will take the comment out of "edit mode" with the old content. */ -function useEditMessage({reportID, originalReportID, reportAction, shouldScrollToLastMessage = false, isFocused, debouncedCommentMaxLengthValidation, composerRef}: UseEditMessageProps) { +function useEditMessage({reportID, originalReportID, reportAction, shouldScrollToLastMessage = false, debouncedCommentMaxLengthValidation, composerRef}: UseEditMessageProps) { const reportScrollManager = useReportScrollManager(); - const isFocusedRef = useRef(isFocused); const {email} = useCurrentUserPersonalDetails(); const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${originalReportID}`); @@ -49,20 +42,6 @@ function useEditMessage({reportID, originalReportID, reportAction, shouldScrollT const {stopEditing, submitEdit} = useReportActionActiveEditActions(); - useEffect(() => { - // required for keeping last state of isFocused variable - isFocusedRef.current = isFocused; - }, [isFocused]); - - // We consider the report action active if it's focused, its emoji picker is open or its context menu is open - function isActive(): boolean { - if (!reportAction) { - return false; - } - - return isFocusedRef.current || isEmojiPickerActive(reportAction.reportActionID) || ReportActionContextMenu.isActiveReportAction(reportAction.reportActionID); - } - function deleteDraft(): void { if (!reportAction) { return; @@ -72,18 +51,9 @@ function useEditMessage({reportID, originalReportID, reportAction, shouldScrollT clearReportActionDrafts(); - if (isActive()) { - ReportActionComposeFocusManager.clear(true); - // Wait for report action compose re-mounting on mWeb - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => ReportActionComposeFocusManager.focus()); - } - // Scroll to the last comment after editing to make sure the whole comment is clearly visible in the report. if (shouldScrollToLastMessage) { - KeyboardUtils.dismiss().then(() => { - reportScrollManager.scrollToIndex(0, false); - }); + reportScrollManager.scrollToIndex(0, false); } } diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index 3722246f4ff8..bd0203d77f0b 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -253,7 +253,6 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy originalReportID, reportAction: action, shouldScrollToLastMessage: index === 0, - isFocused, debouncedCommentMaxLengthValidation, composerRef, }); diff --git a/tests/unit/hooks/useEditMessage.test.ts b/tests/unit/hooks/useEditMessage.test.ts index 32f3f7c36154..8b5cc09bf678 100644 --- a/tests/unit/hooks/useEditMessage.test.ts +++ b/tests/unit/hooks/useEditMessage.test.ts @@ -103,7 +103,6 @@ describe('useEditMessage', () => { reportID: report.reportID, originalReportID: report.reportID, reportAction, - isFocused: true, debouncedCommentMaxLengthValidation: makeDebouncedValidator({flushResult: true}), composerRef: {current: {blur: jest.fn()} as never}, ...overrides, From bc3093dedf9c22082387dc1bc3f2f19d0c989fb8 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Sat, 18 Apr 2026 00:21:57 +0100 Subject: [PATCH 172/233] refactor: extract composer toggle logic into separate hook --- .../ComposerWithSuggestions.tsx | 154 ++++-------------- .../ReportActionComposeUtils.ts | 31 ++++ .../useEditComposerToggle.ts | 144 ++++++++++++++++ 3 files changed, 203 insertions(+), 126 deletions(-) create mode 100644 src/pages/inbox/report/ReportActionCompose/ReportActionComposeUtils.ts create mode 100644 src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 803b4967bd43..195c3a1541e9 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -66,8 +66,10 @@ import findNodeHandle from '@src/utils/findNodeHandle'; import getCursorPosition from './getCursorPosition'; import getScrollPosition from './getScrollPosition'; import type {SuggestionsRef} from './ReportActionCompose'; +import ReportActionComposeUtils from './ReportActionComposeUtils'; import SilentCommentUpdater from './SilentCommentUpdater'; import Suggestions from './Suggestions'; +import useEditComposerToggle from './useEditComposerToggle'; type SyncSelection = { position: number; @@ -178,10 +180,6 @@ type SwitchToCurrentReportProps = { }; const {RNTextInputReset} = NativeModules; -function getIsCommentEmpty(comment: string) { - return !!comment.match(/^(\s)*$/); -} - const isIOSNative = getPlatform() === CONST.PLATFORM.IOS; /** @@ -317,127 +315,30 @@ function ComposerWithSuggestions({ focusComposerWithDelay(composerRef.current, delay)(shouldDelay, forcedSelectionRange, forceKeyboardIfAlreadyFocused).catch(() => {}); }, []); - const updateNativeSelectionValue = useCallback((start: number, end: number) => { - if (!isIOSNative) { - return; - } - - // Ensure that native selection value is set imperatively after all state changes are effective - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => { - // note: this implementation is only available on non-web RN, thus the wrapping - // 'if' block contains a redundant (since the ref is only used on iOS) platform check - composerRef.current?.setSelection(start, end); - }); - }, []); - - type ApplyComposerValueOptions = { - isEditingInComposer?: boolean; - shouldMoveSelectionToEnd?: boolean; - selection?: TextSelection | null; - shouldForceNativeValueUpdate?: boolean; - }; - - const applyComposerValue = useCallback( - (nextValue: string, options?: ApplyComposerValueOptions) => { - const defaultSelection: TextSelection = {start: nextValue.length, end: nextValue.length}; - const shouldUseEditingSelection = options?.isEditingInComposer ?? false; - const shouldForceSelectionToEnd = options?.shouldMoveSelectionToEnd ?? false; - const explicitSelection = options?.selection ?? null; - - const selectionToApply = explicitSelection ?? (shouldUseEditingSelection && !shouldForceSelectionToEnd ? (currentEditMessageSelection ?? defaultSelection) : defaultSelection); - - commentRef.current = nextValue; - emojisPresentBefore.current = extractEmojis(nextValue); - - setValue(nextValue); - // We need to manually update the native text prop, - // in order to force a re-calculation of the composer height and layout, - // when the composer changes in or out of edit mode. - if (isIOSNative && options?.shouldForceNativeValueUpdate) { - composerRef.current?.setNativeProps({text: nextValue}); - } - - setSelection(selectionToApply); - updateNativeSelectionValue(selectionToApply.start, selectionToApply.end ?? selectionToApply.start); - - if (options?.isEditingInComposer) { - focus(true, undefined, true); - } - }, - [currentEditMessageSelection, focus, updateNativeSelectionValue], - ); - - const wasEditing = useRef(isEditing); - const wasEditingInComposerRef = useRef(shouldUseNarrowLayout); - const wasComposerFocusedBeforeEditingRef = useRef(false); - const previousDraftSelectionRef = useRef(null); - const previousEditingReportActionIDRef = useRef(null); - - useEffect(() => { - // If the draft message is already being submitted, do nothing. - if (editingState === 'submitted') { - return; - } - - if (editingState !== 'editing') { - if (wasEditing.current && wasEditingInComposerRef.current) { - // Editing just ended in the composer – restore the draft comment and its previous selection. - applyComposerValue(draftComment ?? '', {selection: previousDraftSelectionRef.current, shouldForceNativeValueUpdate: true}); - setIsCommentEmpty(getIsCommentEmpty(draftComment ?? '')); - - if (!wasComposerFocusedBeforeEditingRef.current) { - composerRef.current?.blur(); - } - } - - wasEditing.current = false; - wasEditingInComposerRef.current = shouldUseNarrowLayout; - previousDraftSelectionRef.current = null; - return; - } + const updateIsCommentEmptyOnEditEnd = useCallback(() => { + setIsCommentEmpty(ReportActionComposeUtils.getIsCommentEmpty(draftComment ?? '')); + }, [draftComment, setIsCommentEmpty]); - // Editing just started. - if (!wasEditing.current) { - wasComposerFocusedBeforeEditingRef.current = composerRef.current?.isFocused() ?? false; - // Store the draft selection before switching into edit mode so we can restore it later. - previousDraftSelectionRef.current = selection; + const handleEditFocus = useCallback(() => { + focus(true, undefined, true); + }, [focus]); - wasEditing.current = true; - wasEditingInComposerRef.current = shouldUseNarrowLayout; + const handleEditValueChange = useCallback((nextValue: string) => { + commentRef.current = nextValue; + emojisPresentBefore.current = extractEmojis(nextValue); - if (!shouldUseNarrowLayout) { - // Wide layout – another editor handles the edit, keep composer draft as-is. - return; - } - // In narrow layout we always show the message being edited. - // When starting to edit in the composer, always place the cursor at the end of the message. - applyComposerValue(editingMessage ?? '', {isEditingInComposer: true, shouldMoveSelectionToEnd: true, shouldForceNativeValueUpdate: true}); - return; - } - - // Editing is ongoing and layout toggled from wide to narrow. - if (shouldUseNarrowLayout && !wasEditingInComposerRef.current) { - wasEditingInComposerRef.current = true; - // We just moved from wide to narrow while editing – start editing in the composer. - applyComposerValue(editingMessage ?? '', {isEditingInComposer: true}); - return; - } - - // Editing is ongoing and layout toggled from narrow to wide. - if (!shouldUseNarrowLayout && wasEditingInComposerRef.current) { - wasEditingInComposerRef.current = false; - applyComposerValue(draftComment ?? ''); - return; - } - - // The editing report action and message changed - if (shouldUseNarrowLayout && editingReportActionID !== previousEditingReportActionIDRef.current) { - applyComposerValue(editingMessage ?? '', {isEditingInComposer: true, shouldForceNativeValueUpdate: true}); - } + setValue(nextValue); + }, []); - previousEditingReportActionIDRef.current = editingReportActionID; - }, [applyComposerValue, draftComment, editingMessage, editingReportActionID, editingState, focus, selection, setIsCommentEmpty, shouldUseNarrowLayout, updateNativeSelectionValue]); + const {wasEditingRef} = useEditComposerToggle({ + selection, + draftComment, + composerRef, + onEditEnd: updateIsCommentEmptyOnEditEnd, + onFocus: handleEditFocus, + onValueChange: handleEditValueChange, + onSelectionChange: setSelection, + }); const [modal] = useOnyx(ONYXKEYS.MODAL); const [preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE] = useOnyx(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE); @@ -630,8 +531,8 @@ function ComposerWithSuggestions({ const newCommentConverted = convertToLTRForComposer(newComment); const textVSOffset = getTextVSCursorOffset(emojiConvertedText, cursorPosition); - const isNewCommentEmpty = getIsCommentEmpty(newCommentConverted); - const isPrevCommentEmpty = getIsCommentEmpty(commentRef.current); + const isNewCommentEmpty = ReportActionComposeUtils.getIsCommentEmpty(newCommentConverted); + const isPrevCommentEmpty = ReportActionComposeUtils.getIsCommentEmpty(commentRef.current); /** Only update isCommentEmpty state if it's different from previous one */ if (!isEditingInComposer && isNewCommentEmpty !== isPrevCommentEmpty) { @@ -648,7 +549,7 @@ function ComposerWithSuggestions({ syncSelectionWithOnChangeTextRef.current = {position, value: newComment}; } - if (!wasEditing.current) { + if (!wasEditingRef.current) { setSelection((prevSelection) => ({ ...prevSelection, start: position, @@ -701,6 +602,7 @@ function ComposerWithSuggestions({ setIsCommentEmpty, shouldUseNarrowLayout, suggestionsRef, + wasEditingRef, ], ); @@ -818,9 +720,9 @@ function ComposerWithSuggestions({ const positionSnapshot = syncSelectionWithOnChangeTextRef.current.position; syncSelectionWithOnChangeTextRef.current = null; - updateNativeSelectionValue(positionSnapshot, positionSnapshot); + ReportActionComposeUtils.updateNativeSelectionValue(composerRef, positionSnapshot, positionSnapshot); }, - [clearComposerHeight, updateComment, updateNativeSelectionValue], + [clearComposerHeight, updateComment], ); const onSelectionChange = useCallback( diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionComposeUtils.ts b/src/pages/inbox/report/ReportActionCompose/ReportActionComposeUtils.ts new file mode 100644 index 000000000000..ee9eac06c26a --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionComposeUtils.ts @@ -0,0 +1,31 @@ +import {InteractionManager} from 'react-native'; +import type {ComposerRef} from '@components/Composer/types'; +import getPlatform from '@libs/getPlatform'; +import CONST from '@src/CONST'; + +const isIOSNative = getPlatform() === CONST.PLATFORM.IOS; + +function getIsCommentEmpty(comment: string) { + return !!comment.match(/^(\s)*$/); +} + +const updateNativeSelectionValue = (composerRef: React.RefObject, start: number, end: number) => { + if (!isIOSNative) { + return; + } + + // Ensure that native selection value is set imperatively after all state changes are effective + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => { + // note: this implementation is only available on non-web RN, thus the wrapping + // 'if' block contains a redundant (since the ref is only used on iOS) platform check + composerRef.current?.setSelection(start, end); + }); +}; + +const ReportActionComposeUtils = { + getIsCommentEmpty, + updateNativeSelectionValue, +}; + +export default ReportActionComposeUtils; diff --git a/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts b/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts new file mode 100644 index 000000000000..a7414819d330 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts @@ -0,0 +1,144 @@ +/* eslint-disable no-param-reassign */ +import {useCallback, useEffect, useRef} from 'react'; +import type {RefObject} from 'react'; +import type {ComposerRef, TextSelection} from '@components/Composer/types'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import getPlatform from '@libs/getPlatform'; +import {useReportActionActiveEdit} from '@pages/inbox/report/ReportActionEditMessageContext'; +import CONST from '@src/CONST'; +import ReportActionComposeUtils from './ReportActionComposeUtils'; + +const isIOSNative = getPlatform() === CONST.PLATFORM.IOS; +type UseEditComposerToggleProps = { + selection: TextSelection; + draftComment: string; + composerRef: RefObject; + onEditEnd?: () => void; + onSelectionChange?: (selection: TextSelection) => void; + onFocus?: () => void; + onValueChange?: (value: string) => void; +}; + +/** + * useEditComposerToggle is a hook that manages the editing state of the composer. + * It is used to toggle the editing state of the composer and to apply the changes to the composer. + * It is also used to restore the draft comment and the selection when the editing state is toggled off. + * It is also used to focus the composer when the editing state is toggled on. + * It is also used to update the value of the composer when the editing state is toggled on. + * It is also used to update the selection of the composer when the editing state is toggled on. + */ +function useEditComposerToggle({selection, draftComment, composerRef, onEditEnd, onFocus, onValueChange, onSelectionChange}: UseEditComposerToggleProps) { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + + const {editingState, editingReportActionID, editingMessage, currentEditMessageSelection} = useReportActionActiveEdit(); + const isEditing = editingState !== 'off'; + + const wasEditingRef = useRef(isEditing); + const wasEditingInComposerRef = useRef(shouldUseNarrowLayout); + const wasComposerFocusedBeforeEditingRef = useRef(false); + const previousDraftSelectionRef = useRef(null); + const previousEditingReportActionIDRef = useRef(null); + + type ApplyComposerValueOptions = { + isEditingInComposer?: boolean; + shouldMoveSelectionToEnd?: boolean; + selection?: TextSelection | null; + shouldForceNativeValueUpdate?: boolean; + }; + + const applyComposerValue = useCallback( + (nextValue: string, options?: ApplyComposerValueOptions) => { + const defaultSelection: TextSelection = {start: nextValue.length, end: nextValue.length}; + const shouldUseEditingSelection = options?.isEditingInComposer ?? false; + const shouldForceSelectionToEnd = options?.shouldMoveSelectionToEnd ?? false; + const explicitSelection = options?.selection ?? null; + + const selectionToApply = explicitSelection ?? (shouldUseEditingSelection && !shouldForceSelectionToEnd ? (currentEditMessageSelection ?? defaultSelection) : defaultSelection); + + onValueChange?.(nextValue); + // We need to manually update the native text prop, + // in order to force a re-calculation of the composer height and layout, + // when the composer changes in or out of edit mode. + if (isIOSNative && options?.shouldForceNativeValueUpdate) { + composerRef.current?.setNativeProps({text: nextValue}); + } + + onSelectionChange?.(selectionToApply); + ReportActionComposeUtils.updateNativeSelectionValue(composerRef, selectionToApply.start, selectionToApply.end ?? selectionToApply.start); + + if (options?.isEditingInComposer) { + onFocus?.(); + } + }, + [composerRef, currentEditMessageSelection, onFocus, onSelectionChange, onValueChange], + ); + + useEffect(() => { + // If the draft message is already being submitted, do nothing. + if (editingState === 'submitted') { + return; + } + + if (editingState !== 'editing') { + if (wasEditingRef.current && wasEditingInComposerRef.current) { + // Editing just ended in the composer – restore the draft comment and its previous selection. + applyComposerValue(draftComment ?? '', {selection: previousDraftSelectionRef.current, shouldForceNativeValueUpdate: true}); + onEditEnd?.(); + + if (!wasComposerFocusedBeforeEditingRef.current) { + composerRef.current?.blur(); + } + } + + wasEditingRef.current = false; + wasEditingInComposerRef.current = shouldUseNarrowLayout; + previousDraftSelectionRef.current = null; + return; + } + + // Editing just started. + if (!wasEditingRef.current) { + wasComposerFocusedBeforeEditingRef.current = composerRef.current?.isFocused() ?? false; + // Store the draft selection before switching into edit mode so we can restore it later. + previousDraftSelectionRef.current = selection; + + wasEditingRef.current = true; + wasEditingInComposerRef.current = shouldUseNarrowLayout; + + if (!shouldUseNarrowLayout) { + // Wide layout – another editor handles the edit, keep composer draft as-is. + return; + } + // In narrow layout we always show the message being edited. + // When starting to edit in the composer, always place the cursor at the end of the message. + applyComposerValue(editingMessage ?? '', {isEditingInComposer: true, shouldMoveSelectionToEnd: true, shouldForceNativeValueUpdate: true}); + return; + } + + // Editing is ongoing and layout toggled from wide to narrow. + if (shouldUseNarrowLayout && !wasEditingInComposerRef.current) { + wasEditingInComposerRef.current = true; + // We just moved from wide to narrow while editing – start editing in the composer. + applyComposerValue(editingMessage ?? '', {isEditingInComposer: true}); + return; + } + + // Editing is ongoing and layout toggled from narrow to wide. + if (!shouldUseNarrowLayout && wasEditingInComposerRef.current) { + wasEditingInComposerRef.current = false; + applyComposerValue(draftComment ?? ''); + return; + } + + // The editing report action and message changed + if (shouldUseNarrowLayout && editingReportActionID !== previousEditingReportActionIDRef.current) { + applyComposerValue(editingMessage ?? '', {isEditingInComposer: true, shouldForceNativeValueUpdate: true}); + } + + previousEditingReportActionIDRef.current = editingReportActionID; + }, [applyComposerValue, composerRef, draftComment, editingMessage, editingReportActionID, editingState, selection, onEditEnd, shouldUseNarrowLayout]); + + return {wasEditingRef}; +} + +export default useEditComposerToggle; From 70b73f3654ef07e7da94cb6c788f1cbcb4f088d7 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Sat, 18 Apr 2026 00:22:50 +0100 Subject: [PATCH 173/233] docs: update JSDoc of `useEditComposerToggle` --- .../report/ReportActionCompose/useEditComposerToggle.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts b/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts index a7414819d330..23a6419e97c6 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts @@ -22,10 +22,10 @@ type UseEditComposerToggleProps = { /** * useEditComposerToggle is a hook that manages the editing state of the composer. * It is used to toggle the editing state of the composer and to apply the changes to the composer. - * It is also used to restore the draft comment and the selection when the editing state is toggled off. - * It is also used to focus the composer when the editing state is toggled on. - * It is also used to update the value of the composer when the editing state is toggled on. - * It is also used to update the selection of the composer when the editing state is toggled on. + * Additionally, it is used to restore the draft comment and the selection when the editing state is toggled off, + * to focus the composer when the editing state is toggled on, + * to update the value of the composer when the editing state is toggled on, + * and to update the selection of the composer when the editing state is toggled on. */ function useEditComposerToggle({selection, draftComment, composerRef, onEditEnd, onFocus, onValueChange, onSelectionChange}: UseEditComposerToggleProps) { const {shouldUseNarrowLayout} = useResponsiveLayout(); From 32088ca3a0dc80cd0992731918705bce15e48e56 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 20 Apr 2026 21:31:30 +0100 Subject: [PATCH 174/233] refactor: move `onValueChange` to `ComposerInput` --- .../report/ReportActionCompose/ComposerContext.ts | 2 -- .../report/ReportActionCompose/ComposerInput.tsx | 15 ++++++++++++--- .../ReportActionCompose/ComposerProvider.tsx | 9 --------- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts index c23262631b89..06c6af13996d 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts +++ b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts @@ -67,7 +67,6 @@ type ComposerEditActions = { type ComposerSendActions = { validateAndSubmitDraft: (draftMessage: string) => void; submitDraftAndClearComposer: () => void; - onValueChange: (value: string) => void; }; // Frozen — stable refs, set once @@ -134,7 +133,6 @@ const ComposerEditActionsContext = createContext(defaultEdi const defaultSendActions: ComposerSendActions = { validateAndSubmitDraft: noop, submitDraftAndClearComposer: noop, - onValueChange: noop, }; const ComposerSendActionsContext = createContext(defaultSendActions); diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx index 98020ab69ab4..dbdb6646e5d3 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx @@ -10,6 +10,7 @@ import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; import useParentReportAction from '@hooks/useParentReportAction'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; +import {setIsComposerFullSize} from '@libs/actions/Report'; import FS from '@libs/Fullstory'; import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; import {getCombinedReportActions, getFilteredReportActionsForReportView, getOneTransactionThreadReportID, isMoneyRequestAction, isSentMoneyReportAction} from '@libs/ReportActionsUtils'; @@ -46,15 +47,23 @@ function ComposerInput({reportID, onPasteFile}: ComposerInputProps) { const {translate, preferredLocale} = useLocalize(); const {isOffline} = useNetwork(); const {isMenuVisible} = useComposerState(); - const {isBlockedFromConcierge} = useComposerSendState(); - const {setIsFullComposerAvailable, onBlur, onFocus, setComposerRef} = useComposerActions(); - const {submitDraftAndClearComposer, onValueChange, validateAndSubmitDraft} = useComposerSendActions(); + const {isBlockedFromConcierge, debouncedCommentMaxLengthValidation} = useComposerSendState(); + const {setIsFullComposerAvailable, onBlur, onFocus, setComposerRef, setText} = useComposerActions(); + const {submitDraftAndClearComposer, validateAndSubmitDraft} = useComposerSendActions(); const {containerRef, suggestionsRef, isNextModalWillOpenRef} = useComposerMeta(); const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); const [blockedFromConcierge] = useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); const userBlockedFromConcierge = isBlockedFromConciergeUserAction(blockedFromConcierge); + const onValueChange = (v: string) => { + setText(v); + if (v.length === 0 && isComposerFullSize) { + setIsComposerFullSize(reportID, false); + } + debouncedCommentMaxLengthValidation(v); + }; + const measureContainer = (callback: MeasureInWindowOnSuccessCallback) => { containerRef.current?.measureInWindow(callback); }; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx index 51610687eb03..336cc39785b9 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx @@ -314,14 +314,6 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { }); }; - const onValueChange = (v: string) => { - setText(v); - if (v.length === 0 && isComposerFullSize) { - setIsComposerFullSize(reportID, false); - } - debouncedCommentMaxLengthValidation(v); - }; - const composerState = { isFocused, isMenuVisible, @@ -366,7 +358,6 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { const composerSendActions = { validateAndSubmitDraft, submitDraftAndClearComposer, - onValueChange, }; const composerMeta = { From 2aa7159a9a2917c09741969055eea40a3f2133e2 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 20 Apr 2026 21:38:15 +0100 Subject: [PATCH 175/233] fix: expand/collapse button components --- .../AttachmentPickerWithMenuItems.tsx | 4 +- .../ComposerExpandCollapseButton.tsx | 67 +++------------- .../ExpandCollapseButton.tsx | 77 +++++++++++++++++++ 3 files changed, 90 insertions(+), 58 deletions(-) create mode 100644 src/pages/inbox/report/ReportActionCompose/ExpandCollapseButton.tsx diff --git a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx index 892dd024ce52..b65ef3c7d34f 100644 --- a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx +++ b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx @@ -50,7 +50,7 @@ import {validTransactionDraftIDsSelector} from '@src/selectors/TransactionDraft' import type {AnchorPosition} from '@src/styles'; import type * as OnyxTypes from '@src/types/onyx'; import type {FileObject} from '@src/types/utils/Attachment'; -import ExpandCollapseComposerButton from './ExpandCollapseComposerButton'; +import ExpandCollapseButton from './ExpandCollapseButton'; type MoneyRequestOptions = Record< Exclude, @@ -476,7 +476,7 @@ function AttachmentPickerWithMenuItems({ - - - - { - e?.preventDefault(); - raiseIsScrollLayoutTriggered(); - setIsComposerFullSize(reportID, nextComposerFullSizeValue); - }} - // Keep focus on the composer when Collapse/Expand button is clicked. - onMouseDown={(e) => e.preventDefault()} - style={styles.composerSizeButton} - disabled={isBlockedFromConcierge} - role={CONST.ROLE.BUTTON} - accessibilityLabel={tooltipText} - sentryLabel={sentryLabel} - > - - - - - + ); } diff --git a/src/pages/inbox/report/ReportActionCompose/ExpandCollapseButton.tsx b/src/pages/inbox/report/ReportActionCompose/ExpandCollapseButton.tsx new file mode 100644 index 000000000000..a767a03e61bc --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ExpandCollapseButton.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import {View} from 'react-native'; +import type {ViewProps} from 'react-native'; +import Icon from '@components/Icon'; +import {PressableWithFeedback} from '@components/Pressable'; +import Tooltip from '@components/Tooltip'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; + +type ExpandCollapseButtonProps = ViewProps & { + isFullComposerAvailable: boolean; + isComposerFullSize: boolean; + reportID: string; + raiseIsScrollLikelyLayoutTriggered: () => void; + setIsComposerFullSize: (reportID: string, isFullSize: boolean) => void; + disabled?: boolean; +}; + +function ExpandCollapseButton({ + isFullComposerAvailable, + isComposerFullSize, + reportID, + disabled = false, + raiseIsScrollLikelyLayoutTriggered, + setIsComposerFullSize, + ...restProps +}: ExpandCollapseButtonProps) { + const {translate} = useLocalize(); + const theme = useTheme(); + const styles = useThemeStyles(); + const icons = useMemoizedLazyExpensifyIcons(['Collapse', 'Expand'] as const); + + if (!isFullComposerAvailable && !isComposerFullSize) { + return null; + } + + const shouldCollapse = isComposerFullSize; + const tooltipText = shouldCollapse ? translate('reportActionCompose.collapse') : translate('reportActionCompose.expand'); + const nextComposerFullSizeValue = !shouldCollapse; + const iconSrc = shouldCollapse ? icons.Collapse : icons.Expand; + const sentryLabel = shouldCollapse ? CONST.SENTRY_LABEL.REPORT.ATTACHMENT_PICKER_COLLAPSE_BUTTON : CONST.SENTRY_LABEL.REPORT.ATTACHMENT_PICKER_EXPAND_BUTTON; + + return ( + // eslint-disable-next-line react/jsx-props-no-spreading + + + { + e?.preventDefault(); + raiseIsScrollLikelyLayoutTriggered(); + setIsComposerFullSize(reportID, nextComposerFullSizeValue); + }} + // Keep focus on the composer when Collapse/Expand button is clicked. + onMouseDown={(e) => e.preventDefault()} + style={styles.composerSizeButton} + disabled={disabled} + role={CONST.ROLE.BUTTON} + accessibilityLabel={tooltipText} + sentryLabel={sentryLabel} + > + + + + + ); +} + +export default ExpandCollapseButton; From 4de296f4002a42e0bca3f0e79c5a2e1879c88498 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 20 Apr 2026 21:52:55 +0100 Subject: [PATCH 176/233] fix: missing import of React --- .../inbox/report/ReportActionCompose/ComposerEditingButtons.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerEditingButtons.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerEditingButtons.tsx index c3d47b0eb493..99d3c7e7562a 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerEditingButtons.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerEditingButtons.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import {View} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; import {useComposerEditActions} from './ComposerContext'; From 6c9d19f05c8e46df79eb7ea88def047a42310658 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 20 Apr 2026 22:04:13 +0100 Subject: [PATCH 177/233] fix: add back `KeyboardStateProvider` to `ReportScreenProviders` for tests --- tests/utils/ReportActionComposeUtils.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/utils/ReportActionComposeUtils.tsx b/tests/utils/ReportActionComposeUtils.tsx index e7b7ec2e8e25..d2f631464919 100644 --- a/tests/utils/ReportActionComposeUtils.tsx +++ b/tests/utils/ReportActionComposeUtils.tsx @@ -3,6 +3,7 @@ import type {PropsWithChildren} from 'react'; import ComposeProviders from '@components/ComposeProviders'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import {KeyboardStateProvider} from '@components/withKeyboardState'; import type {ReportActionComposeProps} from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; import ReportActionCompose from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; import {ReportActionEditMessageContextProvider} from '@pages/inbox/report/ReportActionEditMessageContext'; @@ -17,7 +18,7 @@ function ReportActionEditMessageContextProviderForReport({children}: PropsWithCh } function ReportScreenProviders({children}: PropsWithChildren) { - return {children}; + return {children}; } const defaultReportActionComposeProps: ReportActionComposeProps = { From 5f08a7f889ea24e629ed8278b709443c3eae3118 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 20 Apr 2026 22:05:34 +0100 Subject: [PATCH 178/233] fix: add missing React import --- tests/utils/ReportActionComposeUtils.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/utils/ReportActionComposeUtils.tsx b/tests/utils/ReportActionComposeUtils.tsx index d2f631464919..b3481350d638 100644 --- a/tests/utils/ReportActionComposeUtils.tsx +++ b/tests/utils/ReportActionComposeUtils.tsx @@ -1,5 +1,6 @@ import {render} from '@testing-library/react-native'; import type {PropsWithChildren} from 'react'; +import React from 'react'; import ComposeProviders from '@components/ComposeProviders'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; From e43a49362a6f9bdac1ab778b276b7ab9a065fc25 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 20 Apr 2026 23:25:43 +0100 Subject: [PATCH 179/233] fix: use extracted message edit test components --- tests/ui/ReportActionItemMessageEditTest.tsx | 36 +++----------------- tests/utils/ReportActionComposeUtils.tsx | 27 ++------------- 2 files changed, 7 insertions(+), 56 deletions(-) diff --git a/tests/ui/ReportActionItemMessageEditTest.tsx b/tests/ui/ReportActionItemMessageEditTest.tsx index 4290637c2d30..d385258cbb0b 100644 --- a/tests/ui/ReportActionItemMessageEditTest.tsx +++ b/tests/ui/ReportActionItemMessageEditTest.tsx @@ -1,19 +1,13 @@ import type * as NativeNavigation from '@react-navigation/native'; -import {act, fireEvent, render, screen} from '@testing-library/react-native'; -import React from 'react'; +import {act, fireEvent, screen} from '@testing-library/react-native'; import Onyx from 'react-native-onyx'; -import ComposeProviders from '@components/ComposeProviders'; -import {LocaleContextProvider} from '@components/LocaleContextProvider'; -import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import {renderReportActionItemMessageEdit, reportActionComposeTestReportAction} from 'tests/utils/ReportActionComposeUtils'; import {editReportComment} from '@libs/actions/Report'; import * as ReportActionContextMenu from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; -import ReportActionItemMessageEdit from '@pages/inbox/report/ReportActionItemMessageEdit'; -import type {ReportActionItemMessageEditProps} from '@pages/inbox/report/ReportActionItemMessageEdit'; import {draftMessageVideoAttributeCache} from '@pages/inbox/report/useDraftMessageVideoAttributeCache'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Message} from '@src/types/onyx/ReportAction'; -import * as LHNTestUtils from '../utils/LHNTestUtils'; import * as TestHelper from '../utils/TestHelper'; const mockEditReportComment = jest.mocked(editReportComment); @@ -62,28 +56,6 @@ jest.mock('@react-navigation/native', () => ({ TestHelper.setupGlobalFetchMock(); -const defaultReport = LHNTestUtils.getFakeReport(); -const defaultProps: ReportActionItemMessageEditProps = { - action: LHNTestUtils.getFakeReportAction(), - reportID: defaultReport.reportID, - originalReportID: defaultReport.reportID, - index: 0, - isGroupPolicyReport: false, -}; - -const renderReportActionItemMessageEdit = (props?: Partial) => { - return render( - - - , - ); -}; - describe('ReportActionCompose Integration Tests', () => { beforeAll(() => { Onyx.init({ @@ -158,11 +130,11 @@ describe('ReportActionCompose Integration Tests', () => { const videoSource = 'https://example.com/video.mp4'; const videoHtml = ``; - const messages = defaultProps.action.message as Message[]; + const messages = reportActionComposeTestReportAction.message as Message[]; renderReportActionItemMessageEdit({ action: { - ...defaultProps.action, + ...reportActionComposeTestReportAction, message: [ { ...messages.at(0), diff --git a/tests/utils/ReportActionComposeUtils.tsx b/tests/utils/ReportActionComposeUtils.tsx index b3481350d638..d7f69c529484 100644 --- a/tests/utils/ReportActionComposeUtils.tsx +++ b/tests/utils/ReportActionComposeUtils.tsx @@ -13,6 +13,7 @@ import ReportActionItemMessageEdit from '@pages/inbox/report/ReportActionItemMes import * as LHNTestUtils from './LHNTestUtils'; const reportActionComposeTestReport = LHNTestUtils.getFakeReport(); +const reportActionComposeTestReportAction = LHNTestUtils.getFakeReportAction(); function ReportActionEditMessageContextProviderForReport({children}: PropsWithChildren) { return {children}; @@ -45,7 +46,7 @@ const renderReportActionCompose = (props?: Partial) => }; const defaultReportActionItemMessageEditProps: ReportActionItemMessageEditProps = { - action: LHNTestUtils.getFakeReportAction(), + action: reportActionComposeTestReportAction, reportID: reportActionComposeTestReport.reportID, originalReportID: reportActionComposeTestReport.reportID, index: 0, @@ -65,26 +66,4 @@ const renderReportActionItemMessageEdit = (props?: Partial, - reportActionItemMessageEditProps?: Partial, -) => { - return render( - - - - , - ); -}; - -export {ReportActionComposeWrapper, renderReportActionCompose, renderReportActionItemMessageEdit, renderReportActionMessageEditComponents, reportActionComposeTestReport}; +export {ReportActionComposeWrapper, renderReportActionCompose, renderReportActionItemMessageEdit, reportActionComposeTestReport, reportActionComposeTestReportAction}; From 43311490e21392fd31ef97f04e5733babff68dc4 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 20 Apr 2026 23:56:20 +0100 Subject: [PATCH 180/233] fix: tests --- tests/ui/ReportActionComposeTest.tsx | 43 ++++++++++-- tests/ui/ReportActionItemMessageEditTest.tsx | 62 ++++++++++++++++-- tests/utils/ReportActionComposeUtils.tsx | 69 -------------------- 3 files changed, 97 insertions(+), 77 deletions(-) delete mode 100644 tests/utils/ReportActionComposeUtils.tsx diff --git a/tests/ui/ReportActionComposeTest.tsx b/tests/ui/ReportActionComposeTest.tsx index 9f0ec04e570e..b4d1aa3cacd0 100644 --- a/tests/ui/ReportActionComposeTest.tsx +++ b/tests/ui/ReportActionComposeTest.tsx @@ -1,10 +1,18 @@ import type * as NativeNavigation from '@react-navigation/native'; -import {act, fireEvent, screen, waitFor} from '@testing-library/react-native'; +import {act, fireEvent, render, screen, waitFor} from '@testing-library/react-native'; +import React from 'react'; +import type {PropsWithChildren} from 'react'; import Onyx from 'react-native-onyx'; +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import {KeyboardStateProvider} from '@components/withKeyboardState'; +import type {ReportActionComposeProps} from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; +import ReportActionCompose from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; +import {ReportActionEditMessageContextProvider} from '@pages/inbox/report/ReportActionEditMessageContext'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import * as LHNTestUtils from '../utils/LHNTestUtils'; -import {renderReportActionCompose, reportActionComposeTestReport} from '../utils/ReportActionComposeUtils'; import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; @@ -46,6 +54,33 @@ jest.mock('@react-navigation/native', () => ({ TestHelper.setupGlobalFetchMock(); +const defaultReport = LHNTestUtils.getFakeReport(); +const defaultProps: ReportActionComposeProps = { + reportID: defaultReport.reportID, +}; + +function ReportActionEditMessageContextProviderForReport({children}: PropsWithChildren) { + return {children}; +} + +function ReportScreenProviders({children}: PropsWithChildren) { + return {children}; +} + +const renderReportActionCompose = (props?: Partial) => { + // eslint-disable-next-line react/jsx-props-no-spreading + return render( + + + , + ); +}; + // Helper function to simulate text selection const simulateSelection = (composer: ReturnType, start: number, end: number) => { fireEvent(composer, 'selectionChange', { @@ -63,7 +98,7 @@ describe('ReportActionCompose Integration Tests', () => { beforeEach(async () => { await act(async () => { - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportActionComposeTestReport.reportID}`, reportActionComposeTestReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${defaultReport.reportID}`, defaultReport); }); }); @@ -391,7 +426,7 @@ describe('ReportActionCompose Integration Tests', () => { await waitFor( () => { // And the task-title-specific error should be displayed - expect(screen.getByText('composer.commentExceededMaxLength')).toBeOnTheScreen(); + expect(screen.getByText('composer.taskTitleExceededMaxLength')).toBeOnTheScreen(); }, {timeout: CONST.TIMING.COMMENT_LENGTH_DEBOUNCE_TIME + 500}, ); diff --git a/tests/ui/ReportActionItemMessageEditTest.tsx b/tests/ui/ReportActionItemMessageEditTest.tsx index d385258cbb0b..5b7a9734f16c 100644 --- a/tests/ui/ReportActionItemMessageEditTest.tsx +++ b/tests/ui/ReportActionItemMessageEditTest.tsx @@ -1,13 +1,21 @@ import type * as NativeNavigation from '@react-navigation/native'; -import {act, fireEvent, screen} from '@testing-library/react-native'; +import {act, fireEvent, render, screen} from '@testing-library/react-native'; +import type {PropsWithChildren} from 'react'; import Onyx from 'react-native-onyx'; -import {renderReportActionItemMessageEdit, reportActionComposeTestReportAction} from 'tests/utils/ReportActionComposeUtils'; +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import {KeyboardStateProvider} from '@components/withKeyboardState'; import {editReportComment} from '@libs/actions/Report'; import * as ReportActionContextMenu from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import {ReportActionEditMessageContextProvider} from '@pages/inbox/report/ReportActionEditMessageContext'; +import type {ReportActionItemMessageEditProps} from '@pages/inbox/report/ReportActionItemMessageEdit'; +import ReportActionItemMessageEdit from '@pages/inbox/report/ReportActionItemMessageEdit'; import {draftMessageVideoAttributeCache} from '@pages/inbox/report/useDraftMessageVideoAttributeCache'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Message} from '@src/types/onyx/ReportAction'; +import * as LHNTestUtils from '../utils/LHNTestUtils'; import * as TestHelper from '../utils/TestHelper'; const mockEditReportComment = jest.mocked(editReportComment); @@ -56,6 +64,52 @@ jest.mock('@react-navigation/native', () => ({ TestHelper.setupGlobalFetchMock(); +const defaultReport = LHNTestUtils.getFakeReport(); +const defaultReportAction = LHNTestUtils.getFakeReportAction(); +const defaultProps: ReportActionItemMessageEditProps = { + action: defaultReportAction, + reportID: defaultReport.reportID, + originalReportID: defaultReport.reportID, + index: 0, + isGroupPolicyReport: false, +}; + +function ReportActionEditMessageContextProviderForReport({children}: PropsWithChildren) { + return {children}; +} + +function ReportScreenProviders({children}: PropsWithChildren) { + return {children}; +} + +const renderReportActionItemMessageEdit = (props?: Partial) => { + return render( + + + , + ); +}; + +// const renderReportActionItemMessageEdit = (props?: Partial) => { +// return render( +// +// {/* */} +// +// {/* , */} +// , +// ); +// }; + describe('ReportActionCompose Integration Tests', () => { beforeAll(() => { Onyx.init({ @@ -130,11 +184,11 @@ describe('ReportActionCompose Integration Tests', () => { const videoSource = 'https://example.com/video.mp4'; const videoHtml = ``; - const messages = reportActionComposeTestReportAction.message as Message[]; + const messages = defaultReportAction.message as Message[]; renderReportActionItemMessageEdit({ action: { - ...reportActionComposeTestReportAction, + ...defaultReportAction, message: [ { ...messages.at(0), diff --git a/tests/utils/ReportActionComposeUtils.tsx b/tests/utils/ReportActionComposeUtils.tsx deleted file mode 100644 index d7f69c529484..000000000000 --- a/tests/utils/ReportActionComposeUtils.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import {render} from '@testing-library/react-native'; -import type {PropsWithChildren} from 'react'; -import React from 'react'; -import ComposeProviders from '@components/ComposeProviders'; -import {LocaleContextProvider} from '@components/LocaleContextProvider'; -import OnyxListItemProvider from '@components/OnyxListItemProvider'; -import {KeyboardStateProvider} from '@components/withKeyboardState'; -import type {ReportActionComposeProps} from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; -import ReportActionCompose from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; -import {ReportActionEditMessageContextProvider} from '@pages/inbox/report/ReportActionEditMessageContext'; -import type {ReportActionItemMessageEditProps} from '@pages/inbox/report/ReportActionItemMessageEdit'; -import ReportActionItemMessageEdit from '@pages/inbox/report/ReportActionItemMessageEdit'; -import * as LHNTestUtils from './LHNTestUtils'; - -const reportActionComposeTestReport = LHNTestUtils.getFakeReport(); -const reportActionComposeTestReportAction = LHNTestUtils.getFakeReportAction(); - -function ReportActionEditMessageContextProviderForReport({children}: PropsWithChildren) { - return {children}; -} - -function ReportScreenProviders({children}: PropsWithChildren) { - return {children}; -} - -const defaultReportActionComposeProps: ReportActionComposeProps = { - reportID: reportActionComposeTestReport.reportID, -}; - -function ReportActionComposeWrapper(props?: Partial) { - return ( - - - - ); -} - -const renderReportActionCompose = (props?: Partial) => { - // eslint-disable-next-line react/jsx-props-no-spreading - return render(); -}; - -const defaultReportActionItemMessageEditProps: ReportActionItemMessageEditProps = { - action: reportActionComposeTestReportAction, - reportID: reportActionComposeTestReport.reportID, - originalReportID: reportActionComposeTestReport.reportID, - index: 0, - isGroupPolicyReport: false, -}; - -const renderReportActionItemMessageEdit = (props?: Partial) => { - return render( - - - , - ); -}; - -export {ReportActionComposeWrapper, renderReportActionCompose, renderReportActionItemMessageEdit, reportActionComposeTestReport, reportActionComposeTestReportAction}; From 337cccc36ffc12c8fa9a981f91a27b82d941a7f7 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 21 Apr 2026 11:03:47 +0100 Subject: [PATCH 181/233] revert: reassure test changes --- tests/perf-test/ReportActionCompose.perf-test.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/perf-test/ReportActionCompose.perf-test.tsx b/tests/perf-test/ReportActionCompose.perf-test.tsx index bf981fb7b482..28306dda484c 100644 --- a/tests/perf-test/ReportActionCompose.perf-test.tsx +++ b/tests/perf-test/ReportActionCompose.perf-test.tsx @@ -3,13 +3,17 @@ import React from 'react'; import Onyx from 'react-native-onyx'; import type Animated from 'react-native-reanimated'; import {measureRenders} from 'reassure'; +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import {KeyboardStateProvider} from '@components/withKeyboardState'; import type {EmojiPickerRef} from '@libs/actions/EmojiPickerAction'; import type Navigation from '@libs/Navigation/Navigation'; import {setHasRadio} from '@libs/NetworkState'; +import ReportActionCompose from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Report} from '@src/types/onyx'; -import {ReportActionComposeWrapper} from '../utils/ReportActionComposeUtils'; import {translateLocal} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; @@ -84,6 +88,14 @@ beforeEach(() => { const mockEvent = {preventDefault: jest.fn()}; +function ReportActionComposeWrapper() { + return ( + + + + ); +} + test('[ReportActionCompose] should render Composer with text input interactions', async () => { const scenario = async () => { // Query for the composer From 0f59b45cf51346c99fda9c2ab8ba7d9e83f7fab6 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 21 Apr 2026 17:58:09 +0100 Subject: [PATCH 182/233] fix: don't remove edit state when screen is not focused --- .../report/ReportActionEditMessageContext.tsx | 107 +++++++++--------- 1 file changed, 51 insertions(+), 56 deletions(-) diff --git a/src/pages/inbox/report/ReportActionEditMessageContext.tsx b/src/pages/inbox/report/ReportActionEditMessageContext.tsx index 4d8878637cf2..5f745ff1e458 100644 --- a/src/pages/inbox/report/ReportActionEditMessageContext.tsx +++ b/src/pages/inbox/report/ReportActionEditMessageContext.tsx @@ -1,4 +1,3 @@ -import {useIsFocused} from '@react-navigation/native'; import React, {createContext, useCallback, useContext, useState} from 'react'; import type {Dispatch, SetStateAction} from 'react'; import type {OnyxCollection} from 'react-native-onyx'; @@ -56,8 +55,6 @@ type ReportActionEditMessageContextProviderProps = { }; function ReportActionEditMessageContextProvider({reportID, children}: ReportActionEditMessageContextProviderProps) { - const isFocused = useIsFocused(); - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { canEvict: false, @@ -119,70 +116,68 @@ function ReportActionEditMessageContextProvider({reportID, children}: ReportActi let editingReportActionID: string | null = null; let editingReportAction: OnyxTypes.ReportAction | null = null; - if (isFocused) { - const ancestorWithDraft = [...ancestors] - .slice() - .reverse() - .find(({report: ancestorReport, reportAction}) => { - const reportActionsForAncestor = ancestorsReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${ancestorReport.reportID}`]; - const origID = getOriginalReportID(ancestorReport.reportID, reportAction, reportActionsForAncestor); - if (!origID) { - return false; - } - const ancestorDrafts = reportActionDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${origID}`]; - const ancestorDraft = ancestorDrafts?.[reportAction.reportActionID]; - - return ancestorDraft?.message !== undefined; - }); - - const updateMessage = (nextMessage: string | null) => { - if (nextMessage == null) { - return; + const ancestorWithDraft = [...ancestors] + .slice() + .reverse() + .find(({report: ancestorReport, reportAction}) => { + const reportActionsForAncestor = ancestorsReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${ancestorReport.reportID}`]; + const origID = getOriginalReportID(ancestorReport.reportID, reportAction, reportActionsForAncestor); + if (!origID) { + return false; } + const ancestorDrafts = reportActionDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${origID}`]; + const ancestorDraft = ancestorDrafts?.[reportAction.reportActionID]; - const didReportActionChange = prevEditingReportActionID !== editingReportActionID; - if (didReportActionChange) { - setEditingMessage(nextMessage); - setPrevEditingReportActionID(editingReportActionID); - const defaultSelection: TextSelection = {start: nextMessage.length, end: nextMessage.length}; - setCurrentEditMessageSelectionState(defaultSelection); - } - }; + return ancestorDraft?.message !== undefined; + }); - if (ancestorWithDraft) { - const {report: ancestorReport, reportAction: ancestorReportAction} = ancestorWithDraft; - const reportActionsForAncestor = ancestorsReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${ancestorReport.reportID}`]; - const ancestorOrigReportID = getOriginalReportID(ancestorReport.reportID, ancestorReportAction, reportActionsForAncestor); - const ancestorDrafts = ancestorOrigReportID ? reportActionDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${ancestorOrigReportID}`] : undefined; - const ancestorReportActionDraft = ancestorDrafts?.[ancestorReportAction.reportActionID]; + const updateMessage = (nextMessage: string | null) => { + if (nextMessage == null) { + return; + } - editingReportID = ancestorReport.reportID; - editingReportActionID = ancestorReportAction.reportActionID; - editingReportAction = ancestorReportAction; + const didReportActionChange = prevEditingReportActionID !== editingReportActionID; + if (didReportActionChange) { + setEditingMessage(nextMessage); + setPrevEditingReportActionID(editingReportActionID); + const defaultSelection: TextSelection = {start: nextMessage.length, end: nextMessage.length}; + setCurrentEditMessageSelectionState(defaultSelection); + } + }; - if (editingState === 'off') { - setEditingState('editing'); - } + if (ancestorWithDraft) { + const {report: ancestorReport, reportAction: ancestorReportAction} = ancestorWithDraft; + const reportActionsForAncestor = ancestorsReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${ancestorReport.reportID}`]; + const ancestorOrigReportID = getOriginalReportID(ancestorReport.reportID, ancestorReportAction, reportActionsForAncestor); + const ancestorDrafts = ancestorOrigReportID ? reportActionDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${ancestorOrigReportID}`] : undefined; + const ancestorReportActionDraft = ancestorDrafts?.[ancestorReportAction.reportActionID]; - const nextMessage = ancestorReportActionDraft?.message ?? null; - updateMessage(nextMessage); - } else if (reportDrafts) { - const reportDraftEntry = Object.entries(reportDrafts).find(([, draft]) => draft?.message !== undefined); + editingReportID = ancestorReport.reportID; + editingReportActionID = ancestorReportAction.reportActionID; + editingReportAction = ancestorReportAction; - if (reportDraftEntry) { - const [reportActionIDOfDraft, reportActionDraft] = reportDraftEntry; + if (editingState === 'off') { + setEditingState('editing'); + } - editingReportID = reportID ?? null; - editingReportActionID = reportActionIDOfDraft; - editingReportAction = reportActions?.[reportActionIDOfDraft] ?? null; + const nextMessage = ancestorReportActionDraft?.message ?? null; + updateMessage(nextMessage); + } else if (reportDrafts) { + const reportDraftEntry = Object.entries(reportDrafts).find(([, draft]) => draft?.message !== undefined); - if (editingState === 'off') { - setEditingState('editing'); - } + if (reportDraftEntry) { + const [reportActionIDOfDraft, reportActionDraft] = reportDraftEntry; - const nextMessage = reportActionDraft?.message ?? null; - updateMessage(nextMessage); + editingReportID = reportID ?? null; + editingReportActionID = reportActionIDOfDraft; + editingReportAction = reportActions?.[reportActionIDOfDraft] ?? null; + + if (editingState === 'off') { + setEditingState('editing'); } + + const nextMessage = reportActionDraft?.message ?? null; + updateMessage(nextMessage); } } From 05a45afe6a4a2903b5032caa9c1eb7c2e4aaa8b4 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 21 Apr 2026 18:00:56 +0100 Subject: [PATCH 183/233] fix: clear report action drafts on mount and unmount --- src/pages/inbox/ReportScreen.tsx | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/src/pages/inbox/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx index e6b6d55a13fc..a7e012c95d4a 100644 --- a/src/pages/inbox/ReportScreen.tsx +++ b/src/pages/inbox/ReportScreen.tsx @@ -1,6 +1,5 @@ import {PortalHost} from '@gorhom/portal'; -import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback} from 'react'; +import React, {useEffect} from 'react'; import type {ViewStyle} from 'react-native'; import {View} from 'react-native'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -51,17 +50,13 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const screenWrapperStyle: ViewStyle[] = [styles.appContent, styles.flex1, {marginTop: viewportOffsetTop}]; // When the report screen is navigated away from, clear all report action edit drafts - useFocusEffect( - useCallback(() => { - if (!reportIDFromRoute) { - return; - } + useEffect(() => { + clearReportActionDrafts(); - return () => { - clearReportActionDrafts(); - }; - }, [reportIDFromRoute]), - ); + return () => { + clearReportActionDrafts(); + }; + }, []); useSubmitToDestinationVisible( [CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_AND_OPEN_REPORT, CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_ONLY], From 26338f0f970d1dc27ac724533e2bcffbe936dca6 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 21 Apr 2026 18:23:08 +0100 Subject: [PATCH 184/233] fix: context menu opening in edit mode --- src/pages/inbox/report/PureReportActionItem.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/pages/inbox/report/PureReportActionItem.tsx b/src/pages/inbox/report/PureReportActionItem.tsx index 9565dc81a718..f39736c6f403 100644 --- a/src/pages/inbox/report/PureReportActionItem.tsx +++ b/src/pages/inbox/report/PureReportActionItem.tsx @@ -633,6 +633,11 @@ function PureReportActionItem({ const disabledActions = useMemo(() => (!canWriteInReport(report) ? RestrictedReadOnlyContextMenuActions : []), [report]); + const hasErrors = !isEmptyValueObject(action.errors); + const isContextMenuDisabled = useMemo(() => { + return draftMessage !== undefined || hasErrors || !shouldDisplayContextMenuValue; + }, [draftMessage, hasErrors, shouldDisplayContextMenuValue]); + /** * Show the ReportActionContextMenu modal popover. * @@ -641,7 +646,7 @@ function PureReportActionItem({ const showPopover = useCallback( (event: GestureResponderEvent | MouseEvent) => { // Block menu on the message being Edited or if the report action item has errors - if (draftMessage !== undefined || !isEmptyValueObject(action.errors) || !shouldDisplayContextMenuValue) { + if (isContextMenuDisabled) { return; } @@ -674,17 +679,15 @@ function PureReportActionItem({ }); }, [ - draftMessage, - action.errors, action.reportActionID, reportID, toggleContextMenuFromActiveReportAction, originalReportID, - shouldDisplayContextMenuValue, disabledActions, isArchivedRoom, isChronosReport, handleShowContextMenu, + isContextMenuDisabled, isThreadReportParentAction, ], ); @@ -1354,7 +1357,7 @@ function PureReportActionItem({ onPressIn={() => shouldUseNarrowLayout && canUseTouchScreen() && ControlSelection.block()} onPressOut={() => ControlSelection.unblock()} onSecondaryInteraction={showPopover} - preventDefaultContextMenu={draftMessage === undefined && !hasErrors} + preventDefaultContextMenu={!isContextMenuDisabled} withoutFocusOnSecondaryInteraction accessibilityLabel={accessibilityLabel} accessibilityHint={translate('accessibilityHints.chatMessage')} From 4c01dfff5d060342cddb90433466c9b860b4cd71 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 21 Apr 2026 18:23:50 +0100 Subject: [PATCH 185/233] refactor: move draftMessage derivation to `PureReportActionItem` --- .../SearchList/ListItem/ChatListItem.tsx | 1 - .../DuplicateTransactionItem.tsx | 6 ---- .../BaseReportActionContextMenu.tsx | 6 ---- .../report/ContextMenu/ContextMenuActions.tsx | 3 +- .../PopoverReportActionContextMenu.tsx | 7 +--- .../ContextMenu/ReportActionContextMenu.ts | 2 -- .../inbox/report/PureReportActionItem.tsx | 34 ++++++++----------- src/pages/inbox/report/ReportActionItem.tsx | 8 ----- .../report/ReportActionItemParentAction.tsx | 6 ---- .../report/ReportActionsListItemRenderer.tsx | 5 --- 10 files changed, 16 insertions(+), 62 deletions(-) diff --git a/src/components/Search/SearchList/ListItem/ChatListItem.tsx b/src/components/Search/SearchList/ListItem/ChatListItem.tsx index e3c17257038f..74f67ea26463 100644 --- a/src/components/Search/SearchList/ListItem/ChatListItem.tsx +++ b/src/components/Search/SearchList/ListItem/ChatListItem.tsx @@ -82,7 +82,6 @@ function ChatListItem({ index={item.index ?? 0} isFirstVisibleReportAction={false} shouldDisplayContextMenu={false} - shouldShowDraftMessage={false} shouldShowBorder userWalletTierName={userWalletTierName} isUserValidated={isUserValidated} diff --git a/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx b/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx index 37660344d0c3..d21d7dab3892 100644 --- a/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx +++ b/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx @@ -8,7 +8,6 @@ import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getOriginalMessage, getReportAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; -import {useReportActionActiveEdit} from '@pages/inbox/report/ReportActionEditMessageContext'; import ReportActionItem from '@pages/inbox/report/ReportActionItem'; import {ReportActionItemActionsContext, ReportActionItemStateContext} from '@pages/inbox/report/ReportActionItemContext'; import CONST from '@src/CONST'; @@ -53,10 +52,6 @@ function DuplicateTransactionItem({transaction, index, onPreviewPressed}: Duplic const stateValue = useMemo(() => ({shouldOpenReportInRHP: true}), []); const actionsValue = useMemo(() => ({onPreviewPressed}), [onPreviewPressed]); - const {editingMessage, editingReportAction} = useReportActionActiveEdit(); - - const draftMessage = editingReportAction && action && editingReportAction.reportActionID === action.reportActionID ? (editingMessage ?? undefined) : undefined; - if (!action || !report) { return null; } @@ -77,7 +72,6 @@ function DuplicateTransactionItem({transaction, index, onPreviewPressed}: Duplic userWalletTierName={userWalletTierName} isUserValidated={isUserValidated} personalDetails={personalDetails} - draftMessage={draftMessage} emojiReactions={emojiReactions} linkedTransactionRouteError={linkedTransactionRouteError} userBillingFundID={userBillingFundID} diff --git a/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx index 969ff12cdb6b..8219548e27d2 100755 --- a/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx +++ b/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx @@ -71,9 +71,6 @@ type BaseReportActionContextMenuProps = { /** The copy selection. */ selection?: string; - /** Draft message - if this is set the comment is in 'edit' mode */ - draftMessage?: string; - /** String representing the context menu type [LINK, REPORT_ACTION] which controls context menu choices */ type?: ContextMenuType; @@ -123,7 +120,6 @@ function BaseReportActionContextMenu({ isUnreadChat = false, isThreadReportParentAction = false, selection = '', - draftMessage = '', reportActionID, reportID, originalReportID, @@ -349,7 +345,6 @@ function BaseReportActionContextMenu({ }, reportAction: { reportActionID: reportAction?.reportActionID, - draftMessage, isThreadReportParentAction, }, callbacks: { @@ -384,7 +379,6 @@ function BaseReportActionContextMenu({ reportID, originalReportID, report, - draftMessage, selection, close: () => setShouldKeepOpen(false), transitionActionSheetState, diff --git a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx index 280223f27075..7209a58b96c4 100644 --- a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx @@ -274,7 +274,6 @@ type ContextMenuActionPayload = { currentUserAccountID: number; report: OnyxEntry; policy?: OnyxEntry; - draftMessage: string; selection: string; close: () => void; transitionActionSheetState: (params: {type: string; payload?: Record}) => void; @@ -311,7 +310,7 @@ type ContextMenuActionPayload = { conciergeReportID: string | undefined; }; -type OnPress = (closePopover: boolean, payload: ContextMenuActionPayload, selection?: string, reportID?: string, draftMessage?: string) => void; +type OnPress = (closePopover: boolean, payload: ContextMenuActionPayload, selection?: string, reportID?: string) => void; type RenderContent = (closePopover: boolean, payload: ContextMenuActionPayload) => React.ReactElement; diff --git a/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx b/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx index c918824154a3..f123270cac55 100644 --- a/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx +++ b/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx @@ -54,7 +54,6 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro const reportActionIDRef = useRef(undefined); const originalReportIDRef = useRef(undefined); const selectionRef = useRef(''); - const reportActionDraftMessageRef = useRef(undefined); const isReportArchived = useReportIsArchived(reportIDRef.current); const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportIDRef.current}`); const reportActionsRef = useRef(reportActions); @@ -174,7 +173,6 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro * @param reportID - Active Report Id * @param reportActionID - ReportAction for ContextMenu * @param originalReportID - The current Report Id of the reportAction - * @param draftMessage - ReportAction draft message * @param [onShow] - Run a callback when Menu is shown * @param [onHide] - Run a callback when Menu is hidden * @param isArchivedRoom - Whether the provided report is an archived room @@ -203,7 +201,7 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro } const {reportID, originalReportID, isArchivedRoom = false, isChronos = false, isPinnedChat = false, isUnreadChat = false} = currentReport; - const {reportActionID, draftMessage, isThreadReportParentAction: isThreadReportParentActionParam = false} = reportAction; + const {reportActionID, isThreadReportParentAction: isThreadReportParentActionParam = false} = reportAction; const {onShow = () => {}, onHide = () => {}, setIsEmojiPickerActive = () => {}} = callbacks; setIsContextMenuOpening(true); setIsWithoutOverlay(withoutOverlay); @@ -249,7 +247,6 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro originalReportIDRef.current = originalReportID || undefined; selectionRef.current = selection; setIsPopoverVisible(true); - reportActionDraftMessageRef.current = draftMessage; setIsRoomArchived(isArchivedRoom); setIsChronosReportEnabled(isChronos); setIsChatPinned(isPinnedChat); @@ -303,7 +300,6 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro } selectionRef.current = ''; - reportActionDraftMessageRef.current = undefined; setIsPopoverVisible(false); transitionActionSheetState({ @@ -482,7 +478,6 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro type={typeRef.current} reportID={reportIDRef.current} reportActionID={reportActionIDRef.current} - draftMessage={reportActionDraftMessageRef.current} selection={selectionRef.current} isArchivedRoom={isRoomArchived} isChronosReport={isChronosReportEnabled} diff --git a/src/pages/inbox/report/ContextMenu/ReportActionContextMenu.ts b/src/pages/inbox/report/ContextMenu/ReportActionContextMenu.ts index 2da4ac2bbffc..28135387132b 100644 --- a/src/pages/inbox/report/ContextMenu/ReportActionContextMenu.ts +++ b/src/pages/inbox/report/ContextMenu/ReportActionContextMenu.ts @@ -32,7 +32,6 @@ type ShowContextMenuParams = { }; reportAction?: { reportActionID?: string; - draftMessage?: string; isThreadReportParentAction?: boolean; }; callbacks?: { @@ -118,7 +117,6 @@ function hideContextMenu(shouldDelay?: boolean, onHideCallback = () => {}, param * @param reportID - Active Report Id * @param reportActionID - ReportActionID for ContextMenu * @param originalReportID - The current Report Id of the reportAction - * @param draftMessage - ReportAction draft message * @param [onShow=() => {}] - Run a callback when Menu is shown * @param [onHide=() => {}] - Run a callback when Menu is hidden * @param isArchivedRoom - Whether the provided report is an archived room diff --git a/src/pages/inbox/report/PureReportActionItem.tsx b/src/pages/inbox/report/PureReportActionItem.tsx index f39736c6f403..556a000d2a05 100644 --- a/src/pages/inbox/report/PureReportActionItem.tsx +++ b/src/pages/inbox/report/PureReportActionItem.tsx @@ -158,6 +158,7 @@ import MiniReportActionContextMenu from './ContextMenu/MiniReportActionContextMe import type {ContextMenuAnchor} from './ContextMenu/ReportActionContextMenu'; import {hideContextMenu, hideDeleteModal, isActiveReportAction, showContextMenu} from './ContextMenu/ReportActionContextMenu'; import LinkPreviewer from './LinkPreviewer'; +import {useReportActionActiveEdit} from './ReportActionEditMessageContext'; import ReportActionItemBasicMessage from './ReportActionItemBasicMessage'; import ReportActionItemContentCreated from './ReportActionItemContentCreated'; import ReportActionItemDraft from './ReportActionItemDraft'; @@ -227,9 +228,6 @@ type PureReportActionItemProps = { /** Whether context menu should be displayed */ shouldDisplayContextMenu?: boolean; - /** ReportAction draft message */ - draftMessage?: string; - /** The IOU/Expense report we are paying */ iouReport?: OnyxTypes.Report; @@ -377,7 +375,6 @@ function PureReportActionItem({ shouldUseThreadDividerLine = false, shouldDisplayContextMenu = true, parentReportActionForTransactionThread, - draftMessage, iouReport, taskReport, linkedReport, @@ -436,10 +433,14 @@ function PureReportActionItem({ const composerTextInputRef = useRef(null); const popoverAnchorRef = useRef>(null); const downloadedPreviews = useRef([]); - const prevDraftMessage = usePrevious(draftMessage); const isReportActionLinked = linkedReportActionID && action.reportActionID && linkedReportActionID === action.reportActionID; const [isReportActionActive, setIsReportActionActive] = useState(!!isReportActionLinked); const isReportArchived = useReportIsArchived(reportID); + + const {editingMessage, editingReportAction} = useReportActionActiveEdit(); + const draftMessage = editingReportAction && action && editingReportAction.reportActionID === action.reportActionID ? (editingMessage ?? undefined) : undefined; + + const prevDraftMessage = usePrevious(draftMessage); const isEditingInline = !shouldUseNarrowLayout && draftMessage !== undefined; const isHarvestCreatedExpenseReport = isHarvestCreatedExpenseReportUtils(reportNameValuePairsOrigin, reportNameValuePairsOriginalID); @@ -666,7 +667,6 @@ function PureReportActionItem({ }, reportAction: { reportActionID: action.reportActionID, - draftMessage, isThreadReportParentAction, }, callbacks: { @@ -680,15 +680,15 @@ function PureReportActionItem({ }, [ action.reportActionID, - reportID, - toggleContextMenuFromActiveReportAction, - originalReportID, disabledActions, + handleShowContextMenu, isArchivedRoom, isChronosReport, - handleShowContextMenu, isContextMenuDisabled, isThreadReportParentAction, + originalReportID, + reportID, + toggleContextMenuFromActiveReportAction, ], ); @@ -725,10 +725,9 @@ function PureReportActionItem({ * Get the content of ReportActionItem * @param hovered whether the ReportActionItem is hovered * @param isWhisper whether the report action is a whisper - * @param hasErrors whether the report action has any errors * @returns child component(s) */ - const renderItemContent = (hovered = false, isWhisper = false, hasErrors = false): React.JSX.Element => { + const renderItemContent = (hovered = false, isWhisper = false): React.JSX.Element => { let children; const moneyRequestOriginalMessage = isMoneyRequestAction(action) ? getOriginalMessage(action) : undefined; const moneyRequestActionType = moneyRequestOriginalMessage?.type; @@ -1204,12 +1203,11 @@ function PureReportActionItem({ * Get ReportActionItem with a proper wrapper * @param hovered whether the ReportActionItem is hovered * @param isWhisper whether the ReportActionItem is a whisper - * @param hasErrors whether the report action has any errors * @returns report action item */ - const renderReportActionItem = (hovered: boolean, isWhisper: boolean, hasErrors: boolean): React.JSX.Element => { - const content = renderItemContent(hovered || isContextMenuActive || isEmojiPickerActive, isWhisper, hasErrors); + const renderReportActionItem = (hovered: boolean, isWhisper: boolean): React.JSX.Element => { + const content = renderItemContent(hovered || isContextMenuActive || isEmojiPickerActive, isWhisper); if (isEmptyHTML(content) || (!shouldRenderViewBasedOnAction && !isClosedExpenseReportWithNoExpenses)) { return emptyHTML; @@ -1293,8 +1291,6 @@ function PureReportActionItem({ if (isWhisperActionTargetedToOthers(action)) { return null; } - - const hasErrors = !isEmptyValueObject(action.errors); const whisperedTo = getWhisperedTo(action); const isMultipleParticipant = whisperedTo.length > 1; @@ -1389,7 +1385,6 @@ function PureReportActionItem({ disabledActions={disabledActions} isVisible={hovered && draftMessage === undefined && !hasErrors} isThreadReportParentAction={isThreadReportParentAction} - draftMessage={draftMessage} isChronosReport={isChronosReport} checkIfContextMenuActive={toggleContextMenuFromActiveReportAction} setIsEmojiPickerActive={setIsEmojiPickerActive} @@ -1439,7 +1434,7 @@ function PureReportActionItem({ /> )} - {renderReportActionItem(!!hovered || !!isReportActionLinked, isWhisper, hasErrors)} + {renderReportActionItem(!!hovered || !!isReportActionLinked, isWhisper)} , )} @@ -1488,7 +1483,6 @@ export default memo(PureReportActionItem, (prevProps, nextProps) => { deepEqual(prevProps.report?.fieldList, nextProps.report?.fieldList) && deepEqual(prevProps.transactionThreadReport, nextProps.transactionThreadReport) && deepEqual(prevParentReportAction, nextParentReportAction) && - prevProps.draftMessage === nextProps.draftMessage && prevProps.iouReport?.reportID === nextProps.iouReport?.reportID && deepEqual(prevProps.emojiReactions, nextProps.emojiReactions) && deepEqual(prevProps.linkedTransactionRouteError, nextProps.linkedTransactionRouteError) && diff --git a/src/pages/inbox/report/ReportActionItem.tsx b/src/pages/inbox/report/ReportActionItem.tsx index 396169cbb4af..86270ff68dbd 100644 --- a/src/pages/inbox/report/ReportActionItem.tsx +++ b/src/pages/inbox/report/ReportActionItem.tsx @@ -25,12 +25,6 @@ import type {PureReportActionItemProps} from './PureReportActionItem'; import PureReportActionItem from './PureReportActionItem'; type ReportActionItemProps = Omit & { - /** Whether to show the draft message or not */ - shouldShowDraftMessage?: boolean; - - /** Draft message for the report action */ - draftMessage?: string; - /** Emoji reactions for the report action */ emojiReactions?: OnyxEntry; @@ -53,7 +47,6 @@ type ReportActionItemProps = Omit) => { if (!allReactions) { @@ -172,8 +169,6 @@ function ReportActionItemParentAction({ const shouldDisplayThreadDivider = !isTripPreview(ancestorReportAction); const isAncestorReportArchived = isArchivedReport(ancestorsReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${ancestorReport.reportID}`]); - const draftMessage = editingReportAction?.reportActionID === ancestorReportAction.reportActionID ? (editingMessage ?? undefined) : undefined; - const actionEmojiReactions = ancestorReactions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${ancestorReportAction.reportActionID}`]; return ( @@ -212,7 +207,6 @@ function ReportActionItemParentAction({ userWalletTierName={userWalletTierName} isUserValidated={isUserValidated} personalDetails={personalDetails} - draftMessage={draftMessage} emojiReactions={actionEmojiReactions} linkedTransactionRouteError={linkedTransactionRouteError} userBillingFundID={userBillingFundID} diff --git a/src/pages/inbox/report/ReportActionsListItemRenderer.tsx b/src/pages/inbox/report/ReportActionsListItemRenderer.tsx index e4af3490dfe3..c2374c2b4faf 100644 --- a/src/pages/inbox/report/ReportActionsListItemRenderer.tsx +++ b/src/pages/inbox/report/ReportActionsListItemRenderer.tsx @@ -6,7 +6,6 @@ import {isChatThread} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetailsList, Report, ReportAction} from '@src/types/onyx'; -import {useReportActionActiveEdit} from './ReportActionEditMessageContext'; import ReportActionItem from './ReportActionItem'; import ReportActionItemParentAction from './ReportActionItemParentAction'; @@ -101,9 +100,6 @@ function ReportActionsListItemRenderer({ reportNameValuePairsOrigin, reportNameValuePairsOriginalID, }: ReportActionsListItemRendererProps) { - const {editingReportActionID, editingMessage} = useReportActionActiveEdit(); - const draftMessage = !!editingReportActionID && editingReportActionID === reportAction.reportActionID ? (editingMessage ?? undefined) : undefined; - const originalMessage = useMemo(() => getOriginalMessage(reportAction), [reportAction]); const [emojiReactions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportAction.reportActionID}`); @@ -222,7 +218,6 @@ function ReportActionsListItemRenderer({ userWalletTierName={userWalletTierName} isUserValidated={isUserValidated} personalDetails={personalDetails} - draftMessage={draftMessage} emojiReactions={emojiReactions} userBillingFundID={userBillingFundID} isTryNewDotNVPDismissed={isTryNewDotNVPDismissed} From 7e1db3793879925f3798d14aec38f05eeadd54c6 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 21 Apr 2026 20:17:53 +0100 Subject: [PATCH 186/233] fix: cursor not updating when emoji is picked --- .../ComposerWithSuggestions.tsx | 18 ++++++++---------- .../useEditComposerToggle.ts | 2 -- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 69e66b2dd96f..8c1eb7694e83 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -319,7 +319,7 @@ function ComposerWithSuggestions({ setValue(nextValue); }, []); - const {wasEditingRef} = useEditComposerToggle({ + useEditComposerToggle({ selection, draftComment, composerRef, @@ -530,14 +530,13 @@ function ComposerWithSuggestions({ syncSelectionWithOnChangeTextRef.current = {position, value: newComment}; } - if (!wasEditingRef.current) { - setSelection((prevSelection) => ({ - ...prevSelection, - start: position, - end: position, - })); - setCurrentEditMessageSelection((prevSelection) => ({...prevSelection, start: position, end: position})); - } + // Keep selection in sync after emoji conversion / insertion while editing (e.g. emoji picker on web); + setSelection((prevSelection) => ({ + ...prevSelection, + start: position, + end: position, + })); + setCurrentEditMessageSelection((prevSelection) => ({...prevSelection, start: position, end: position})); } commentRef.current = newCommentConverted; @@ -581,7 +580,6 @@ function ComposerWithSuggestions({ setCurrentEditMessageSelection, setEditingMessage, shouldUseNarrowLayout, - wasEditingRef, ], ); diff --git a/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts b/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts index 9ca961dc772f..ffcad92cdfe5 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts @@ -135,8 +135,6 @@ function useEditComposerToggle({selection, draftComment, composerRef, onFocus, o previousEditingReportActionIDRef.current = editingReportActionID; }, [applyComposerValue, composerRef, draftComment, editingMessage, editingReportActionID, editingState, selection, shouldUseNarrowLayout]); - - return {wasEditingRef}; } export default useEditComposerToggle; From ec639efc3ed863740fe6c6e669f33471504e4219 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 21 Apr 2026 20:36:41 +0100 Subject: [PATCH 187/233] refactor: remove commented out code --- .../refocusComposerAfterPreventFirstResponder.ts | 4 ---- .../inbox/report/ReportActionItemMessageEdit.tsx | 2 -- tests/ui/ReportActionItemMessageEditTest.tsx | 15 --------------- 3 files changed, 21 deletions(-) diff --git a/src/libs/refocusComposerAfterPreventFirstResponder.ts b/src/libs/refocusComposerAfterPreventFirstResponder.ts index 94002b48e73d..e9fe1bdb1eaa 100644 --- a/src/libs/refocusComposerAfterPreventFirstResponder.ts +++ b/src/libs/refocusComposerAfterPreventFirstResponder.ts @@ -12,10 +12,6 @@ function refocusComposerAfterPreventFirstResponder(composerToRefocusOnClose: Com } return focusComposerWithDelay(composerRef)(true); - - // return isWindowReadyToFocus().then(() => { - // composerRef?.focus(); - // }); } export default refocusComposerAfterPreventFirstResponder; diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index bd0203d77f0b..975927a3fb42 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -446,8 +446,6 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy if (isMobileChrome() && reportScrollManager.ref?.current) { reportScrollManager.ref.current.scrollToIndex({index, animated: false}); } - // The last composer that had focus should re-gain focus - // setUpComposeFocusManager(); // Clear active report action when another action gets focused if (!isEmojiPickerActive(action.reportActionID)) { diff --git a/tests/ui/ReportActionItemMessageEditTest.tsx b/tests/ui/ReportActionItemMessageEditTest.tsx index 5b7a9734f16c..bca8a6d81e7a 100644 --- a/tests/ui/ReportActionItemMessageEditTest.tsx +++ b/tests/ui/ReportActionItemMessageEditTest.tsx @@ -95,21 +95,6 @@ const renderReportActionItemMessageEdit = (props?: Partial) => { -// return render( -// -// {/* */} -// -// {/* , */} -// , -// ); -// }; - describe('ReportActionCompose Integration Tests', () => { beforeAll(() => { Onyx.init({ From 88fa1590f0b3e1b1b30ddc5536e4a8f01555ca5f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 21 Apr 2026 23:14:49 +0100 Subject: [PATCH 188/233] refactor: Composer web implementation improvements --- .../Composer/implementation/index.tsx | 40 +++++++++++++------ 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/src/components/Composer/implementation/index.tsx b/src/components/Composer/implementation/index.tsx index 239b1756d15b..4533d0200760 100755 --- a/src/components/Composer/implementation/index.tsx +++ b/src/components/Composer/implementation/index.tsx @@ -96,9 +96,16 @@ function Composer({ /** * Adds the cursor position to the selection change event. */ - const addCursorPositionToSelectionChange = (event: TextInputSelectionChangeEvent) => { - const sel = window.getSelection(); - if (shouldCalculateCaretPosition && isRendered && sel) { + const addCursorPositionToSelectionChange = useCallback( + (event: TextInputSelectionChangeEvent) => { + const sel = window.getSelection(); + const canCalculateCaretPosition = shouldCalculateCaretPosition && isRendered && sel; + if (!canCalculateCaretPosition) { + onSelectionChange(event); + setSelection(event.nativeEvent.selection); + return; + } + const range = sel.getRangeAt(0).cloneRange(); range.collapse(true); const rect = range.getClientRects()[0] || range.startContainer.parentElement?.getClientRects()[0]; @@ -126,11 +133,12 @@ function Composer({ }, }); setSelection(selectionValue); - } else { + onSelectionChange(event); setSelection(event.nativeEvent.selection); - } - }; + }, + [isRendered, onSelectionChange, shouldCalculateCaretPosition], + ); /** * Check the paste event for an attachment, parse the data and call onPasteFile from props with the selected file, @@ -213,16 +221,20 @@ function Composer({ if (!textInputRef.current) { return; } + + const inputRef = textInputRef.current; + const debouncedSetPrevScroll = lodashDebounce(() => { - if (!textInputRef.current) { + if (!inputRef) { return; } - setPrevScroll(textInputRef.current.scrollTop); + setPrevScroll(inputRef.scrollTop); }, 100); - textInputRef.current.addEventListener('scroll', debouncedSetPrevScroll); + inputRef.addEventListener('scroll', debouncedSetPrevScroll); + return () => { - textInputRef.current?.removeEventListener('scroll', debouncedSetPrevScroll); + inputRef?.removeEventListener('scroll', debouncedSetPrevScroll); }; }, []); @@ -235,6 +247,8 @@ function Composer({ }, []); useEffect(() => { + const inputRef = textInputRef.current; + const handleWheel = (e: MouseEvent) => { if (isReportFlatListScrolling.current) { e.preventDefault(); @@ -243,15 +257,15 @@ function Composer({ // When the composer has no scrollable content, the stopPropagation will prevent the inverted wheel event handler on the Chat body // which defaults to the browser wheel behavior. This causes the chat body to scroll in the opposite direction creating jerky behavior. - if (textInputRef.current && textInputRef.current.scrollHeight <= textInputRef.current.clientHeight) { + if (inputRef && inputRef.scrollHeight <= inputRef.clientHeight) { return; } e.stopPropagation(); }; - textInputRef.current?.addEventListener('wheel', handleWheel, {passive: false}); + inputRef?.addEventListener('wheel', handleWheel, {passive: false}); return () => { - textInputRef.current?.removeEventListener('wheel', handleWheel); + inputRef?.removeEventListener('wheel', handleWheel); }; }, []); From 33929df885ea200d035490a195ce8e45c5b6961f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 21 Apr 2026 23:17:13 +0100 Subject: [PATCH 189/233] refactor: `ComposerProvider` improvements --- .../ReportActionCompose/ComposerProvider.tsx | 30 ++++++++----------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx index 0ac8a8894b81..c094b6045428 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx @@ -42,38 +42,33 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { const [draftComment] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); - const [rawReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`, { canEvict: false, }); const reportActionKeys = rawReportActions ? Object.keys(rawReportActions) : []; - const shouldFocusComposerOnScreenFocus = shouldFocusInputOnScreenFocus || !!draftComment; - const initialFocused = shouldFocusComposerOnScreenFocus && !initialModalState?.isVisible && !initialModalState?.willAlertModalBecomeVisible; - const containerRef = useRef(null); - const suggestionsRef = useRef(null); - const composerRef = useRef(null); - const actionButtonRef = useRef(null); - const attachmentFileRef = useRef(null); - - const composerRefShared = useSharedValue>({}); + const includesConcierge = chatIncludesConcierge({participants: report?.participants}); + const userBlockedFromConcierge = isBlockedFromConciergeUserAction(blockedFromConcierge); + const isBlockedFromConcierge = includesConcierge && userBlockedFromConcierge; const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(isComposerFullSize); const [isMenuVisible, setMenuVisibility] = useState(false); - const [text, setText] = useState(() => { return draftComment ?? ''; }); - const includesConcierge = chatIncludesConcierge({participants: report?.participants}); - const userBlockedFromConcierge = isBlockedFromConciergeUserAction(blockedFromConcierge); - const isBlockedFromConcierge = includesConcierge && userBlockedFromConcierge; + const containerRef = useRef(null); + const suggestionsRef = useRef(null); + const composerRef = useRef(null); + const actionButtonRef = useRef(null); + const attachmentFileRef = useRef(null); - const {editingState, editingReportID, editingReportActionID, editingReportAction, editingMessage} = useReportActionActiveEdit(); + const composerRefShared = useSharedValue>({}); + const {editingState, editingReportID, editingReportActionID, editingReportAction, editingMessage} = useReportActionActiveEdit(); const isEditingLastReportAction = editingReportActionID === reportActionKeys.at(-1); const [didResetComposerHeight, setDidResetComposerHeight] = useState(false); @@ -86,9 +81,7 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { }, [didResetComposerHeight, editingState]); const isEditingInComposer = shouldUseNarrowLayout && editingState !== 'off' && !didResetComposerHeight; - const effectiveDraft = isEditingInComposer ? editingMessage : draftComment; - const isEmpty = !isEditingInComposer && (!effectiveDraft || !!text.match(CONST.REGEX.EMPTY_COMMENT)); const {debouncedCommentMaxLengthValidation, exceededMaxLength, isExceedingMaxLength, isTaskTitle} = useDebouncedCommentMaxLengthValidation({ reportID, @@ -106,7 +99,8 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { composerRef, }); - const isSubmittingDraftCommentDisabled = isBlockedFromConcierge || isExceedingMaxLength || isEmpty; + const isDraftCommentEmpty = !text || !!text.match(CONST.REGEX.EMPTY_COMMENT); + const isSubmittingDraftCommentDisabled = isBlockedFromConcierge || isExceedingMaxLength || isDraftCommentEmpty; const isSendDisabled = !isEditingInComposer && isSubmittingDraftCommentDisabled; const {isFocused, onBlur, onFocus, onAddActionPressed, onItemSelected, onTriggerAttachmentPicker, isNextModalWillOpenRef} = useComposerFocus({ From ebfda2a9274dff8420885323ef123c9345563f43 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 21 Apr 2026 23:41:43 +0100 Subject: [PATCH 190/233] fix: duplicate selection update --- src/components/Composer/implementation/index.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/components/Composer/implementation/index.tsx b/src/components/Composer/implementation/index.tsx index 4533d0200760..424b1bd5ec69 100755 --- a/src/components/Composer/implementation/index.tsx +++ b/src/components/Composer/implementation/index.tsx @@ -133,9 +133,6 @@ function Composer({ }, }); setSelection(selectionValue); - - onSelectionChange(event); - setSelection(event.nativeEvent.selection); }, [isRendered, onSelectionChange, shouldCalculateCaretPosition], ); From e5d0a57c6daa61f4feb51a7c56b96fe7e84b7c85 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 22 Apr 2026 00:49:39 +0100 Subject: [PATCH 191/233] fix: inifinite setState loop due to missing memoization in `PureReportActionItem` --- .../inbox/report/PureReportActionItem.tsx | 21 ++++++++++--------- src/pages/inbox/report/ReportActionItem.tsx | 5 +++++ 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/pages/inbox/report/PureReportActionItem.tsx b/src/pages/inbox/report/PureReportActionItem.tsx index 556a000d2a05..29c451347110 100644 --- a/src/pages/inbox/report/PureReportActionItem.tsx +++ b/src/pages/inbox/report/PureReportActionItem.tsx @@ -158,7 +158,6 @@ import MiniReportActionContextMenu from './ContextMenu/MiniReportActionContextMe import type {ContextMenuAnchor} from './ContextMenu/ReportActionContextMenu'; import {hideContextMenu, hideDeleteModal, isActiveReportAction, showContextMenu} from './ContextMenu/ReportActionContextMenu'; import LinkPreviewer from './LinkPreviewer'; -import {useReportActionActiveEdit} from './ReportActionEditMessageContext'; import ReportActionItemBasicMessage from './ReportActionItemBasicMessage'; import ReportActionItemContentCreated from './ReportActionItemContentCreated'; import ReportActionItemDraft from './ReportActionItemDraft'; @@ -228,6 +227,9 @@ type PureReportActionItemProps = { /** Whether context menu should be displayed */ shouldDisplayContextMenu?: boolean; + /** ReportAction draft message */ + draftMessage?: string; + /** The IOU/Expense report we are paying */ iouReport?: OnyxTypes.Report; @@ -375,6 +377,7 @@ function PureReportActionItem({ shouldUseThreadDividerLine = false, shouldDisplayContextMenu = true, parentReportActionForTransactionThread, + draftMessage, iouReport, taskReport, linkedReport, @@ -433,14 +436,10 @@ function PureReportActionItem({ const composerTextInputRef = useRef(null); const popoverAnchorRef = useRef>(null); const downloadedPreviews = useRef([]); + const prevDraftMessage = usePrevious(draftMessage); const isReportActionLinked = linkedReportActionID && action.reportActionID && linkedReportActionID === action.reportActionID; const [isReportActionActive, setIsReportActionActive] = useState(!!isReportActionLinked); const isReportArchived = useReportIsArchived(reportID); - - const {editingMessage, editingReportAction} = useReportActionActiveEdit(); - const draftMessage = editingReportAction && action && editingReportAction.reportActionID === action.reportActionID ? (editingMessage ?? undefined) : undefined; - - const prevDraftMessage = usePrevious(draftMessage); const isEditingInline = !shouldUseNarrowLayout && draftMessage !== undefined; const isHarvestCreatedExpenseReport = isHarvestCreatedExpenseReportUtils(reportNameValuePairsOrigin, reportNameValuePairsOriginalID); @@ -680,15 +679,15 @@ function PureReportActionItem({ }, [ action.reportActionID, + reportID, + toggleContextMenuFromActiveReportAction, + originalReportID, disabledActions, - handleShowContextMenu, isArchivedRoom, isChronosReport, + handleShowContextMenu, isContextMenuDisabled, isThreadReportParentAction, - originalReportID, - reportID, - toggleContextMenuFromActiveReportAction, ], ); @@ -1291,6 +1290,7 @@ function PureReportActionItem({ if (isWhisperActionTargetedToOthers(action)) { return null; } + const whisperedTo = getWhisperedTo(action); const isMultipleParticipant = whisperedTo.length > 1; @@ -1483,6 +1483,7 @@ export default memo(PureReportActionItem, (prevProps, nextProps) => { deepEqual(prevProps.report?.fieldList, nextProps.report?.fieldList) && deepEqual(prevProps.transactionThreadReport, nextProps.transactionThreadReport) && deepEqual(prevParentReportAction, nextParentReportAction) && + prevProps.draftMessage === nextProps.draftMessage && prevProps.iouReport?.reportID === nextProps.iouReport?.reportID && deepEqual(prevProps.emojiReactions, nextProps.emojiReactions) && deepEqual(prevProps.linkedTransactionRouteError, nextProps.linkedTransactionRouteError) && diff --git a/src/pages/inbox/report/ReportActionItem.tsx b/src/pages/inbox/report/ReportActionItem.tsx index 86270ff68dbd..18ceb0619865 100644 --- a/src/pages/inbox/report/ReportActionItem.tsx +++ b/src/pages/inbox/report/ReportActionItem.tsx @@ -23,6 +23,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetailsList, ReportActionReactions, Transaction} from '@src/types/onyx'; import type {PureReportActionItemProps} from './PureReportActionItem'; import PureReportActionItem from './PureReportActionItem'; +import {useReportActionActiveEdit} from './ReportActionEditMessageContext'; type ReportActionItemProps = Omit & { /** Emoji reactions for the report action */ @@ -94,6 +95,9 @@ function ReportActionItem({ const targetReport = isChatThread(report) ? parentReport : report; const missingPaymentMethod = getIndicatedMissingPaymentMethod(userWalletTierName, targetReport?.reportID, action, bankAccountList); + const {editingMessage, editingReportAction} = useReportActionActiveEdit(); + const draftMessage = editingReportAction && action && editingReportAction.reportActionID === action.reportActionID ? (editingMessage ?? undefined) : undefined; + return ( Date: Wed, 22 Apr 2026 11:16:28 +0100 Subject: [PATCH 192/233] fix: add back draft message prop for `DuplicateTransactionItem` --- .../TransactionDuplicate/DuplicateTransactionItem.tsx | 9 +++++++++ src/pages/inbox/report/ReportActionItem.tsx | 7 ++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx b/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx index d21d7dab3892..73521e291054 100644 --- a/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx +++ b/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx @@ -8,6 +8,7 @@ import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getOriginalMessage, getReportAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {getOriginalReportID} from '@libs/ReportUtils'; import ReportActionItem from '@pages/inbox/report/ReportActionItem'; import {ReportActionItemActionsContext, ReportActionItemStateContext} from '@pages/inbox/report/ReportActionItemContext'; import CONST from '@src/CONST'; @@ -40,6 +41,10 @@ function DuplicateTransactionItem({transaction, index, onPreviewPressed}: Duplic return IOUTransactionID === transaction?.transactionID; }); + const originalReportID = getOriginalReportID(report?.reportID, action, reportActions); + + const [draftMessage] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${originalReportID}`); + const [emojiReactions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${action?.reportActionID}`); const [linkedTransactionRouteError] = useOnyx( @@ -56,6 +61,9 @@ function DuplicateTransactionItem({transaction, index, onPreviewPressed}: Duplic return null; } + const reportDraftMessage = draftMessage?.[action.reportActionID]; + const matchingDraftMessage = reportDraftMessage?.message; + return ( @@ -72,6 +80,7 @@ function DuplicateTransactionItem({transaction, index, onPreviewPressed}: Duplic userWalletTierName={userWalletTierName} isUserValidated={isUserValidated} personalDetails={personalDetails} + draftMessage={matchingDraftMessage} emojiReactions={emojiReactions} linkedTransactionRouteError={linkedTransactionRouteError} userBillingFundID={userBillingFundID} diff --git a/src/pages/inbox/report/ReportActionItem.tsx b/src/pages/inbox/report/ReportActionItem.tsx index 18ceb0619865..76966807aa2e 100644 --- a/src/pages/inbox/report/ReportActionItem.tsx +++ b/src/pages/inbox/report/ReportActionItem.tsx @@ -26,6 +26,9 @@ import PureReportActionItem from './PureReportActionItem'; import {useReportActionActiveEdit} from './ReportActionEditMessageContext'; type ReportActionItemProps = Omit & { + /** Draft message for the report action */ + draftMessage?: string; + /** Emoji reactions for the report action */ emojiReactions?: OnyxEntry; @@ -48,6 +51,7 @@ type ReportActionItemProps = Omit Date: Wed, 22 Apr 2026 11:19:38 +0100 Subject: [PATCH 193/233] refactor: update `clearAllReportActionDrafts` function name and JSDoc --- src/libs/actions/Report/index.ts | 6 +++--- src/pages/inbox/ReportScreen.tsx | 6 +++--- src/pages/inbox/report/PureReportActionItem.tsx | 4 ++-- .../inbox/report/ReportActionCompose/useEditMessage.ts | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 5750ea5da5ef..b8fb7b9afa02 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -2947,8 +2947,8 @@ function editReportComment( ); } -/** Clears drafts for all comment report action in a report. */ -function clearReportActionDrafts() { +/** Clears drafts for all comment report action across all reports */ +function clearAllReportActionDrafts() { Onyx.setCollection(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS, {}); } @@ -7380,7 +7380,7 @@ export { extractRHPVariantFromResponse, createNewReport, deleteReport, - clearReportActionDrafts, + clearAllReportActionDrafts, deleteReportComment, deleteReportField, dismissTrackExpenseActionableWhisper, diff --git a/src/pages/inbox/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx index 226bec9950dc..e4376240d003 100644 --- a/src/pages/inbox/ReportScreen.tsx +++ b/src/pages/inbox/ReportScreen.tsx @@ -11,7 +11,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSubmitToDestinationVisible from '@hooks/useSubmitToDestinationVisible'; import useThemeStyles from '@hooks/useThemeStyles'; import useViewportOffsetTop from '@hooks/useViewportOffsetTop'; -import {clearReportActionDrafts} from '@libs/actions/Report'; +import {clearAllReportActionDrafts} from '@libs/actions/Report'; import {flushDeferredWrite, hasDeferredWrite} from '@libs/deferredLayoutWrite'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -56,10 +56,10 @@ function ReportScreen({route, navigation}: ReportScreenProps) { // When the report screen is navigated away from, clear all report action edit drafts useEffect(() => { - clearReportActionDrafts(); + clearAllReportActionDrafts(); return () => { - clearReportActionDrafts(); + clearAllReportActionDrafts(); }; }, []); diff --git a/src/pages/inbox/report/PureReportActionItem.tsx b/src/pages/inbox/report/PureReportActionItem.tsx index 29c451347110..7fa9b60c0cd2 100644 --- a/src/pages/inbox/report/PureReportActionItem.tsx +++ b/src/pages/inbox/report/PureReportActionItem.tsx @@ -131,7 +131,7 @@ import variables from '@styles/variables'; import {openPersonalBankAccountSetupView} from '@userActions/BankAccounts'; import type {IgnoreDirection} from '@userActions/ClearReportActionErrors'; import {hideEmojiPicker, isActive} from '@userActions/EmojiPickerAction'; -import {clearReportActionDrafts, createTransactionThreadReport, expandURLPreview} from '@userActions/Report'; +import {clearAllReportActionDrafts, createTransactionThreadReport, expandURLPreview} from '@userActions/Report'; import {isAnonymousUser, signOutAndRedirectToSignIn} from '@userActions/Session'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -578,7 +578,7 @@ function PureReportActionItem({ if (draftMessage === undefined || !isDeletedAction(action)) { return; } - clearReportActionDrafts(); + clearAllReportActionDrafts(); }, [draftMessage, action, reportID]); // Hide the message if it is being moderated for a higher offense, or is hidden by a moderator diff --git a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts index 7a9c6ff89632..8a26a4ac97d3 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts @@ -7,7 +7,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useOnyx from '@hooks/useOnyx'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportScrollManager from '@hooks/useReportScrollManager'; -import {clearReportActionDrafts, editReportComment} from '@libs/actions/Report'; +import {clearAllReportActionDrafts, editReportComment} from '@libs/actions/Report'; import focusEditAfterCancelDelete from '@libs/focusEditAfterCancelDelete'; import {getOriginalReportID} from '@libs/ReportUtils'; import * as ReportActionContextMenu from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; @@ -49,7 +49,7 @@ function useEditMessage({reportID, originalReportID, reportAction, shouldScrollT stopEditing(); - clearReportActionDrafts(); + clearAllReportActionDrafts(); // Scroll to the last comment after editing to make sure the whole comment is clearly visible in the report. if (shouldScrollToLastMessage) { From 061ae7e02cf56ba5e8ba6062eee4ec5cb1e01800 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 22 Apr 2026 11:28:41 +0100 Subject: [PATCH 194/233] refactor: extract `requestKeyboardForFocusedComposer` from `focusComposerWithDelay` into platform-specific files --- src/libs/focusComposerWithDelay/index.ts | 28 ++----------------- .../index.native.ts | 21 ++++++++++++++ .../index.ts | 7 +++++ .../types.ts | 5 ++++ 4 files changed, 35 insertions(+), 26 deletions(-) create mode 100644 src/libs/focusComposerWithDelay/requestKeyboardForFocusedComposer/index.native.ts create mode 100644 src/libs/focusComposerWithDelay/requestKeyboardForFocusedComposer/index.ts create mode 100644 src/libs/focusComposerWithDelay/requestKeyboardForFocusedComposer/types.ts diff --git a/src/libs/focusComposerWithDelay/index.ts b/src/libs/focusComposerWithDelay/index.ts index eddf2ad9f3aa..29371f921935 100644 --- a/src/libs/focusComposerWithDelay/index.ts +++ b/src/libs/focusComposerWithDelay/index.ts @@ -1,34 +1,10 @@ -import {KeyboardController} from 'react-native-keyboard-controller'; import ComposerFocusManager from '@libs/ComposerFocusManager'; -import getPlatform from '@libs/getPlatform'; import isWindowReadyToFocus from '@libs/isWindowReadyToFocus'; import * as EmojiPickerAction from '@userActions/EmojiPickerAction'; import CONST from '@src/CONST'; +import requestKeyboardForFocusedComposer from './requestKeyboardForFocusedComposer'; import setTextInputSelection from './setTextInputSelection'; -import type {FocusComposerWithDelay, InputType, Selection} from './types'; - -/** - * When the field already has focus, RN's `focus()` often does not show the IME again. - * `KeyboardController.setFocusTo('current')` re-applies focus via native (`requestFocusFromJS` on Android, - * `reloadInputViews` + `focus` on iOS) without blurring first. - * - * @see https://kirillzyusko.github.io/react-native-keyboard-controller/docs/api/keyboard-controller#setfocusto - */ -function requestKeyboardForFocusedComposer(textInput: InputType, forcedSelectionRange?: Selection) { - const platform = getPlatform(); - const isNative = platform === CONST.PLATFORM.IOS || platform === CONST.PLATFORM.ANDROID; - - if (!isNative) { - return; - } - - requestIdleCallback(() => { - KeyboardController.setFocusTo('current'); - if (forcedSelectionRange) { - setTextInputSelection(textInput, forcedSelectionRange); - } - }); -} +import type {FocusComposerWithDelay, InputType} from './types'; /** * Create a function that focuses the composer. diff --git a/src/libs/focusComposerWithDelay/requestKeyboardForFocusedComposer/index.native.ts b/src/libs/focusComposerWithDelay/requestKeyboardForFocusedComposer/index.native.ts new file mode 100644 index 000000000000..723e73257307 --- /dev/null +++ b/src/libs/focusComposerWithDelay/requestKeyboardForFocusedComposer/index.native.ts @@ -0,0 +1,21 @@ +import {KeyboardController} from 'react-native-keyboard-controller'; +import setTextInputSelection from '@libs/focusComposerWithDelay/setTextInputSelection'; +import type RequestKeyboardForFocusedComposer from './types'; + +/** + * When the field already has focus, RN's `focus()` often does not show the IME again. + * `KeyboardController.setFocusTo('current')` re-applies focus via native (`requestFocusFromJS` on Android, + * `reloadInputViews` + `focus` on iOS) without blurring first. + * + * @see https://kirillzyusko.github.io/react-native-keyboard-controller/docs/api/keyboard-controller#setfocusto + */ +const requestKeyboardForFocusedComposer: RequestKeyboardForFocusedComposer = (textInput, forcedSelectionRange) => { + requestIdleCallback(() => { + KeyboardController.setFocusTo('current'); + if (forcedSelectionRange) { + setTextInputSelection(textInput, forcedSelectionRange); + } + }); +}; + +export default requestKeyboardForFocusedComposer; diff --git a/src/libs/focusComposerWithDelay/requestKeyboardForFocusedComposer/index.ts b/src/libs/focusComposerWithDelay/requestKeyboardForFocusedComposer/index.ts new file mode 100644 index 000000000000..8470a7ea6846 --- /dev/null +++ b/src/libs/focusComposerWithDelay/requestKeyboardForFocusedComposer/index.ts @@ -0,0 +1,7 @@ +import type RequestKeyboardForFocusedComposer from './types'; + +const NOOP = () => {}; + +const requestKeyboardForFocusedComposer: RequestKeyboardForFocusedComposer = NOOP; + +export default requestKeyboardForFocusedComposer; diff --git a/src/libs/focusComposerWithDelay/requestKeyboardForFocusedComposer/types.ts b/src/libs/focusComposerWithDelay/requestKeyboardForFocusedComposer/types.ts new file mode 100644 index 000000000000..5464de49d563 --- /dev/null +++ b/src/libs/focusComposerWithDelay/requestKeyboardForFocusedComposer/types.ts @@ -0,0 +1,5 @@ +import type {InputType, Selection} from '@libs/focusComposerWithDelay/types'; + +type RequestKeyboardForFocusedComposer = (textInput: InputType, forcedSelectionRange?: Selection) => void; + +export default RequestKeyboardForFocusedComposer; From 02ffbc3a401cea34c5398b18c25ef05ab56c7abf Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 22 Apr 2026 11:39:55 +0100 Subject: [PATCH 195/233] fix: disable `shouldScrollToLastMessage` for composer editing --- .../inbox/report/ReportActionCompose/ComposerProvider.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx index c094b6045428..b7f2c5cc1d57 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx @@ -42,11 +42,7 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { const [draftComment] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); - const [rawReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`, { - canEvict: false, - }); - const reportActionKeys = rawReportActions ? Object.keys(rawReportActions) : []; const shouldFocusComposerOnScreenFocus = shouldFocusInputOnScreenFocus || !!draftComment; const initialFocused = shouldFocusComposerOnScreenFocus && !initialModalState?.isVisible && !initialModalState?.willAlertModalBecomeVisible; @@ -69,7 +65,6 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { const composerRefShared = useSharedValue>({}); const {editingState, editingReportID, editingReportActionID, editingReportAction, editingMessage} = useReportActionActiveEdit(); - const isEditingLastReportAction = editingReportActionID === reportActionKeys.at(-1); const [didResetComposerHeight, setDidResetComposerHeight] = useState(false); useEffect(() => { @@ -94,7 +89,7 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { reportID: editingReportID ?? undefined, originalReportID, reportAction: editingReportAction, - shouldScrollToLastMessage: isEditingLastReportAction, + shouldScrollToLastMessage: false, debouncedCommentMaxLengthValidation, composerRef, }); From 75eec230255245d05b3be63336bff5f792b2e84e Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 22 Apr 2026 11:49:58 +0100 Subject: [PATCH 196/233] fix: also run `SilentCommentUpdater` on narrow layout --- .../report/ReportActionCompose/ComposerWithSuggestions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 8c1eb7694e83..aff10c6d0e4f 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -1115,7 +1115,7 @@ function ComposerWithSuggestions({ resetKeyboardInput={resetKeyboardInput} /> - {isValidReportIDFromPath(reportID) && !shouldUseNarrowLayout && ( + {isValidReportIDFromPath(reportID) && ( Date: Wed, 22 Apr 2026 11:54:45 +0100 Subject: [PATCH 197/233] fix: only apply selection after focus in `focusComposerWithDelay` --- src/libs/focusComposerWithDelay/index.ts | 27 +++++++++--------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/src/libs/focusComposerWithDelay/index.ts b/src/libs/focusComposerWithDelay/index.ts index 29371f921935..dbbcfae1d965 100644 --- a/src/libs/focusComposerWithDelay/index.ts +++ b/src/libs/focusComposerWithDelay/index.ts @@ -30,18 +30,22 @@ function focusComposerWithDelay(textInput: InputType | null, delay: number = CON return; } - if (!shouldDelay) { + function focusAndUpdateSelection(input: InputType) { if (getIsFocused()) { if (forceKeyboardIfAlreadyFocused) { - requestKeyboardForFocusedComposer(textInput, forcedSelectionRange); + requestKeyboardForFocusedComposer(input, forcedSelectionRange); } return; } - textInput.focus(); + input.focus(); if (forcedSelectionRange) { - setTextInputSelection(textInput, forcedSelectionRange); + setTextInputSelection(input, forcedSelectionRange); } + } + + if (!shouldDelay) { + focusAndUpdateSelection(textInput); return; } @@ -50,21 +54,10 @@ function focusComposerWithDelay(textInput: InputType | null, delay: number = CON if (!textInput) { return; } + // When the closing modal has a focused text input focus() needs a delay to properly work. // Setting 150ms here is a temporary workaround for the Android HybridApp. It should be reverted once we identify the real root cause of this issue: https://github.com/Expensify/App/issues/56311. - setTimeout(() => { - if (getIsFocused()) { - if (forceKeyboardIfAlreadyFocused) { - // Selection is applied synchronously below; only request focus - requestKeyboardForFocusedComposer(textInput); - } - return; - } - textInput.focus(); - }, delay); - if (forcedSelectionRange) { - setTextInputSelection(textInput, forcedSelectionRange); - } + setTimeout(() => focusAndUpdateSelection(textInput), delay); }; } From 99232e6006fdcf7ad9f36065392af512c9f7a10d Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 22 Apr 2026 15:14:07 +0100 Subject: [PATCH 198/233] test: add unit and ui tests for new editing modes --- ...portActionComposeNarrowMessageEditTest.tsx | 219 +++++++++++ tests/unit/hooks/useEditComposerToggleTest.ts | 366 ++++++++++++++++++ 2 files changed, 585 insertions(+) create mode 100644 tests/ui/ReportActionComposeNarrowMessageEditTest.tsx create mode 100644 tests/unit/hooks/useEditComposerToggleTest.ts diff --git a/tests/ui/ReportActionComposeNarrowMessageEditTest.tsx b/tests/ui/ReportActionComposeNarrowMessageEditTest.tsx new file mode 100644 index 000000000000..98b388aee342 --- /dev/null +++ b/tests/ui/ReportActionComposeNarrowMessageEditTest.tsx @@ -0,0 +1,219 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type * as NativeNavigation from '@react-navigation/native'; +import {act, fireEvent, render, screen, waitFor} from '@testing-library/react-native'; +import React from 'react'; +import type {PropsWithChildren} from 'react'; +import Onyx from 'react-native-onyx'; +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import {KeyboardStateProvider} from '@components/withKeyboardState'; +import * as Report from '@libs/actions/Report'; +import type {ReportActionComposeProps} from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; +import ReportActionCompose from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; +import {ReportActionEditMessageContextProvider} from '@pages/inbox/report/ReportActionEditMessageContext'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import * as LHNTestUtils from '../utils/LHNTestUtils'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +// Narrow layout: message edits happen in the main composer (ComposerWithSuggestions + useEditComposerToggle), +// and ReportActionCompose swaps the attachment action row for edit chrome (ComposerEditingButtons). + +jest.mock('@hooks/useResponsiveLayout', () => ({ + __esModule: true, + default: () => ({ + shouldUseNarrowLayout: true, + isSmallScreenWidth: true, + isInNarrowPaneModal: false, + isExtraSmallScreenHeight: false, + isExtraSmallScreenWidth: false, + isMediumScreenWidth: false, + onboardingIsMediumOrLargerScreenWidth: false, + isLargeScreenWidth: false, + isSmallScreen: true, + }), +})); + +jest.mock('@libs/getPlatform', () => ({ + __esModule: true, + default: () => 'web', +})); + +jest.mock('@libs/ComponentUtils', () => ({ + forceClearInput: jest.fn(), +})); + +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: jest.fn((key: string) => key), + numberFormat: jest.fn((num: number) => num.toString()), + })), +); + +jest.mock('@hooks/usePaginatedReportActions', () => jest.fn(() => ({reportActions: [], hasNewerActions: false, hasOlderActions: false}))); +jest.mock('@hooks/useParentReportAction', () => jest.fn(() => null)); +jest.mock('@hooks/useReportTransactionsCollection', () => jest.fn(() => ({}))); +jest.mock('@hooks/useShortMentionsList', () => jest.fn(() => ({availableLoginsList: []}))); +jest.mock('@hooks/useSidePanelState', () => jest.fn(() => ({sessionStartTime: null}))); + +jest.mock('@components/DropZone/DualDropZone', () => { + const RN = jest.requireActual>>('react-native'); + return ({shouldAcceptSingleReceipt}: {shouldAcceptSingleReceipt?: boolean}) => ( + {shouldAcceptSingleReceipt ? 'receipt-editable' : 'receipt-not-editable'} + ); +}); + +const mockRouteReportID = {current: '1'}; + +jest.mock('@react-navigation/native', () => ({ + ...((): typeof NativeNavigation => { + return jest.requireActual('@react-navigation/native'); + })(), + useNavigation: jest.fn(() => ({ + navigate: jest.fn(), + addListener: jest.fn(() => jest.fn()), + })), + useIsFocused: jest.fn(() => true), + useRoute: jest.fn(() => ({key: '', name: '', params: {reportID: mockRouteReportID.current}})), +})); + +TestHelper.setupGlobalFetchMock(); + +const defaultReport = LHNTestUtils.getFakeReport(); +mockRouteReportID.current = defaultReport.reportID; + +const defaultProps: ReportActionComposeProps = { + reportID: defaultReport.reportID, +}; + +const commentAction = { + ...LHNTestUtils.getFakeReportAction(), + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, +}; + +function ReportActionEditMessageContextProviderForReport({children}: PropsWithChildren) { + return {children}; +} + +function ReportScreenProviders({children}: PropsWithChildren) { + return {children}; +} + +const saveReportActionDraftSpy = jest.spyOn(Report, 'saveReportActionDraft'); + +function renderNarrowReportActionCompose() { + return render( + + + , + ); +} + +describe('ReportActionCompose — narrow layout message edit flow', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS], + }); + }); + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(async () => { + jest.useRealTimers(); + await act(async () => { + await Onyx.clear(); + }); + saveReportActionDraftSpy.mockClear(); + }); + + it('shows edit chrome (close) instead of the create control while a report action draft is open', async () => { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${defaultReport.reportID}`, defaultReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${defaultReport.reportID}`, { + [commentAction.reportActionID]: commentAction, + }); + await Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS, { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${defaultReport.reportID}`]: { + [commentAction.reportActionID]: {message: 'Edit me in the composer'}, + }, + }); + }); + await waitForBatchedUpdatesWithAct(); + + renderNarrowReportActionCompose(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByLabelText('common.close')).toBeOnTheScreen(); + expect(screen.queryByLabelText('common.create')).toBeNull(); + }); + + it('loads the draft into the composer and debounces saveReportActionDraft while typing in edit mode', async () => { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${defaultReport.reportID}`, defaultReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${defaultReport.reportID}`, { + [commentAction.reportActionID]: commentAction, + }); + await Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS, { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${defaultReport.reportID}`]: { + [commentAction.reportActionID]: {message: 'Start'}, + }, + }); + }); + await waitForBatchedUpdatesWithAct(); + + renderNarrowReportActionCompose(); + await waitForBatchedUpdatesWithAct(); + + const composer = screen.getByTestId('composer'); + await waitFor(() => { + expect(composer.props.value).toBe('Start'); + }); + + fireEvent.changeText(composer, 'Start, edited'); + + act(() => { + jest.advanceTimersByTime(1100); + }); + await waitForBatchedUpdatesWithAct(); + + expect(saveReportActionDraftSpy).toHaveBeenCalled(); + const lastCall = saveReportActionDraftSpy.mock.calls.at(-1); + expect(lastCall?.[0]).toBe(defaultReport.reportID); + expect(lastCall?.[2]).toBe('Start, edited'); + }); + + it('leaves edit mode and restores default composer actions when the user cancels', async () => { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${defaultReport.reportID}`, defaultReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${defaultReport.reportID}`, { + [commentAction.reportActionID]: commentAction, + }); + await Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS, { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${defaultReport.reportID}`]: { + [commentAction.reportActionID]: {message: 'To cancel'}, + }, + }); + }); + await waitForBatchedUpdatesWithAct(); + + renderNarrowReportActionCompose(); + await waitForBatchedUpdatesWithAct(); + + const cancelButton = screen.getByLabelText('common.close'); + fireEvent.press(cancelButton); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.getByLabelText('common.create')).toBeOnTheScreen(); + }); + expect(screen.queryByLabelText('common.close')).toBeNull(); + }); +}); diff --git a/tests/unit/hooks/useEditComposerToggleTest.ts b/tests/unit/hooks/useEditComposerToggleTest.ts new file mode 100644 index 000000000000..19ef9ab9eeca --- /dev/null +++ b/tests/unit/hooks/useEditComposerToggleTest.ts @@ -0,0 +1,366 @@ +import {act, renderHook} from '@testing-library/react-native'; +import type {ComposerRef, TextSelection} from '@components/Composer/types'; +import type {RefObject} from 'react'; +import ReportActionComposeUtils from '@pages/inbox/report/ReportActionCompose/ReportActionComposeUtils'; +import useEditComposerToggle from '@pages/inbox/report/ReportActionCompose/useEditComposerToggle'; +import type {ReportActionActiveEdit} from '@pages/inbox/report/ReportActionEditMessageContext'; +import * as ReportActionEditMessageContext from '@pages/inbox/report/ReportActionEditMessageContext'; + +jest.mock('@pages/inbox/report/ReportActionEditMessageContext', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@pages/inbox/report/ReportActionEditMessageContext'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + useReportActionActiveEdit: jest.fn(), + }; +}); + +jest.mock('@hooks/useResponsiveLayout', () => ({ + __esModule: true, + default: jest.fn(() => ({ + shouldUseNarrowLayout: true, + isSmallScreenWidth: true, + isInNarrowPaneModal: false, + isExtraSmallScreenHeight: false, + isExtraSmallScreenWidth: false, + isMediumScreenWidth: false, + onboardingIsMediumOrLargerScreenWidth: false, + isLargeScreenWidth: false, + isSmallScreen: true, + })), +})); + +jest.mock('@pages/inbox/report/ReportActionCompose/ReportActionComposeUtils', () => ({ + __esModule: true, + default: { + updateNativeSelectionValue: jest.fn(), + }, +})); + +jest.mock('@libs/getPlatform', () => ({ + __esModule: true, + default: () => 'web', +})); + +const mockUseReportActionActiveEdit = jest.mocked(ReportActionEditMessageContext.useReportActionActiveEdit); +const mockUseResponsiveLayout = jest.requireMock('@hooks/useResponsiveLayout').default as jest.Mock; +const mockUpdateNativeSelectionValue = jest.mocked(ReportActionComposeUtils.updateNativeSelectionValue); + +type ActiveEdit = ReportActionActiveEdit & {editingState: 'off' | 'editing' | 'submitted'}; + +function makeComposerRef(overrides?: Partial): RefObject { + return { + current: { + blur: jest.fn(), + isFocused: jest.fn(() => false), + setNativeProps: jest.fn(), + setSelection: jest.fn(), + focus: jest.fn(), + ...overrides, + } as unknown as ComposerRef, + }; +} + +function defaultActiveEdit(overrides?: Partial): ActiveEdit { + return { + editingReportID: null, + editingReportActionID: null, + editingReportAction: null, + editingMessage: null, + currentEditMessageSelection: null, + editingState: 'off', + ...overrides, + }; +} + +function wideLayoutResult() { + return { + shouldUseNarrowLayout: false, + isSmallScreenWidth: false, + isInNarrowPaneModal: false, + isExtraSmallScreenHeight: false, + isExtraSmallScreenWidth: false, + isMediumScreenWidth: false, + onboardingIsMediumOrLargerScreenWidth: true, + isLargeScreenWidth: true, + isSmallScreen: false, + }; +} + +function narrowLayoutResult() { + return { + shouldUseNarrowLayout: true, + isSmallScreenWidth: true, + isInNarrowPaneModal: false, + isExtraSmallScreenHeight: false, + isExtraSmallScreenWidth: false, + isMediumScreenWidth: false, + onboardingIsMediumOrLargerScreenWidth: false, + isLargeScreenWidth: false, + isSmallScreen: true, + }; +} + +describe('useEditComposerToggle', () => { + const activeEditRef = {current: defaultActiveEdit()}; + + beforeEach(() => { + jest.clearAllMocks(); + activeEditRef.current = defaultActiveEdit(); + mockUseReportActionActiveEdit.mockImplementation(() => activeEditRef.current); + mockUseResponsiveLayout.mockReturnValue(narrowLayoutResult()); + }); + + it('does not run apply logic while editingState is submitted', () => { + const onValueChange = jest.fn(); + const composerRef = makeComposerRef(); + activeEditRef.current = defaultActiveEdit({editingState: 'submitted', editingMessage: 'hello'}); + + renderHook(() => + useEditComposerToggle({ + selection: {start: 0, end: 0}, + draftComment: 'draft', + composerRef, + onValueChange, + }), + ); + + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('on narrow layout, when editing starts, applies editing message, selection at end, and focus', () => { + const onValueChange = jest.fn(); + const onSelectionChange = jest.fn(); + const onFocus = jest.fn(); + const composerRef = makeComposerRef(); + const priorSelection: TextSelection = {start: 0, end: 0}; + + const {rerender} = renderHook( + (props: {selection: TextSelection; draft: string}) => + useEditComposerToggle({ + selection: props.selection, + draftComment: props.draft, + composerRef, + onValueChange, + onSelectionChange, + onFocus, + }), + {initialProps: {selection: priorSelection, draft: 'keep my draft'}}, + ); + + act(() => { + activeEditRef.current = defaultActiveEdit({ + editingState: 'editing', + editingMessage: 'edited body', + editingReportActionID: '100', + currentEditMessageSelection: {start: 1, end: 2}, + }); + rerender({selection: priorSelection, draft: 'keep my draft'}); + }); + + const expectedEnd = 'edited body'.length; + expect(onValueChange).toHaveBeenCalledWith('edited body'); + expect(onSelectionChange).toHaveBeenCalledWith({start: expectedEnd, end: expectedEnd}); + expect(onFocus).toHaveBeenCalled(); + }); + + it('on wide layout, when editing starts, leaves composer value unchanged (inline editor handles edit)', () => { + mockUseResponsiveLayout.mockReturnValue(wideLayoutResult()); + + const onValueChange = jest.fn(); + const composerRef = makeComposerRef(); + + const {rerender} = renderHook(() => + useEditComposerToggle({ + selection: {start: 0, end: 0}, + draftComment: 'draft in composer', + composerRef, + onValueChange, + }), + ); + + act(() => { + activeEditRef.current = defaultActiveEdit({ + editingState: 'editing', + editingMessage: 'from thread', + }); + rerender(); + }); + + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('on narrow, when edit ends, restores prior draft and selection; blurs if composer was not focused before', () => { + const onValueChange = jest.fn(); + const onSelectionChange = jest.fn(); + const composerRef = makeComposerRef(); + const priorSelection: TextSelection = {start: 2, end: 5}; + + // Start with edit off so wasEditingRef is false; then turn editing on to capture previousDraftSelectionRef. + const {rerender} = renderHook( + (props: {selection: TextSelection; draft: string; editing: boolean}) => { + activeEditRef.current = defaultActiveEdit( + props.editing + ? {editingState: 'editing', editingMessage: 'e', editingReportActionID: '1'} + : {editingState: 'off'}, + ); + return useEditComposerToggle({ + selection: props.selection, + draftComment: props.draft, + composerRef, + onValueChange, + onSelectionChange, + }); + }, + {initialProps: {selection: priorSelection, draft: 'restored', editing: false}}, + ); + + act(() => { + rerender({selection: priorSelection, draft: 'restored', editing: true}); + }); + + onValueChange.mockClear(); + onSelectionChange.mockClear(); + + act(() => { + rerender({selection: priorSelection, draft: 'restored', editing: false}); + }); + + expect(onValueChange).toHaveBeenCalledWith('restored'); + expect(onSelectionChange).toHaveBeenCalledWith(priorSelection); + expect(composerRef.current?.blur).toHaveBeenCalled(); + }); + + it('on narrow, when switching the message being edited, applies the new message', () => { + const onValueChange = jest.fn(); + const onFocus = jest.fn(); + const composerRef = makeComposerRef(); + + activeEditRef.current = defaultActiveEdit({ + editingState: 'editing', + editingMessage: 'first', + editingReportActionID: 'a', + }); + + const {rerender} = renderHook( + (id: string) => { + activeEditRef.current = defaultActiveEdit({ + editingState: 'editing', + editingMessage: id === 'a' ? 'first' : 'second', + editingReportActionID: id, + }); + return useEditComposerToggle({ + selection: {start: 0, end: 0}, + draftComment: 'x', + composerRef, + onValueChange, + onFocus, + }); + }, + {initialProps: 'a'}, + ); + + onValueChange.mockClear(); + onFocus.mockClear(); + + act(() => { + rerender('b'); + }); + + expect(onValueChange).toHaveBeenCalledWith('second'); + expect(onFocus).toHaveBeenCalled(); + }); + + it('when layout goes from wide to narrow while editing, loads editing message into the composer', () => { + mockUseResponsiveLayout.mockReturnValue(wideLayoutResult()); + + const onValueChange = jest.fn(); + const onFocus = jest.fn(); + const composerRef = makeComposerRef(); + + activeEditRef.current = defaultActiveEdit({ + editingState: 'editing', + editingMessage: 'wide first', + }); + + const {rerender} = renderHook(() => + useEditComposerToggle({ + selection: {start: 0, end: 0}, + draftComment: 'composer draft', + composerRef, + onValueChange, + onFocus, + }), + ); + + onValueChange.mockClear(); + + act(() => { + mockUseResponsiveLayout.mockReturnValue(narrowLayoutResult()); + rerender(); + }); + + expect(onValueChange).toHaveBeenCalledWith('wide first'); + expect(onFocus).toHaveBeenCalled(); + }); + + it('when layout goes from narrow to wide while editing, restores the normal draft in the composer', () => { + const onValueChange = jest.fn(); + const composerRef = makeComposerRef(); + + activeEditRef.current = defaultActiveEdit({ + editingState: 'editing', + editingMessage: 'editing in narrow', + }); + + const {rerender} = renderHook( + (narrow: boolean) => { + mockUseResponsiveLayout.mockReturnValue(narrow ? narrowLayoutResult() : wideLayoutResult()); + return useEditComposerToggle({ + selection: {start: 0, end: 0}, + draftComment: 'plain draft for wide', + composerRef, + onValueChange, + }); + }, + {initialProps: true}, + ); + + onValueChange.mockClear(); + + act(() => { + rerender(false); + }); + + expect(onValueChange).toHaveBeenCalledWith('plain draft for wide'); + }); + + it('passes selection through to ReportActionComposeUtils when toggling (non-iOS / web mock)', () => { + const onValueChange = jest.fn(); + const onSelectionChange = jest.fn(); + const composerRef = makeComposerRef(); + + const {rerender} = renderHook( + (editing: boolean) => { + activeEditRef.current = defaultActiveEdit( + editing ? {editingState: 'editing', editingMessage: 'hi', editingReportActionID: '1'} : {editingState: 'off'}, + ); + return useEditComposerToggle({ + selection: {start: 0, end: 0}, + draftComment: 'd', + composerRef, + onValueChange, + onSelectionChange, + }); + }, + {initialProps: false}, + ); + + act(() => { + rerender(true); + }); + + expect(mockUpdateNativeSelectionValue).toHaveBeenCalled(); + }); +}); From e919d28a749bf974762b4d4f0e388801a5ec268a Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 22 Apr 2026 15:34:23 +0100 Subject: [PATCH 199/233] chore: add test ids to editing components --- src/CONST/index.ts | 12 ++++++++++++ .../AttachmentPickerWithMenuItems.tsx | 8 +++++++- .../ReportActionCompose/ComposerActionMenu.tsx | 2 ++ .../ReportActionCompose/ComposerEditingButtons.tsx | 11 +++++++++-- .../ReportActionCompose/MessageEditCancelButton.tsx | 1 + .../ReportActionCompose/ReportActionCompose.tsx | 5 ++++- .../inbox/report/ReportActionItemMessageEdit.tsx | 2 ++ 7 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 0f882fbc2f12..cac19437aaad 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1833,6 +1833,18 @@ const CONST = { MAX_LINES_FULL: -1, // The minimum height needed to enable the full screen composer FULL_COMPOSER_MIN_HEIGHT: 60, + /** + * TestIDs for the main report composer vs inline message editor (E2E / integration tests only). + * See tests/ui/ReportActionMessageEditLayoutTest.tsx + */ + TEST_ID: { + REPORT_ACTION_COMPOSE: 'reportActionCompose', + DRAFT_MESSAGE_ACTION_ROW: 'reportActionCompose_draftMessageActionRow', + EDITING_MESSAGE_ACTION_ROW: 'reportActionCompose_editingMessageActionRow', + REPORT_ACTION_ITEM_MESSAGE_EDIT: 'reportActionItemMessageEdit', + MESSAGE_EDIT_CANCEL_MAIN_COMPOSER: 'messageEditCancel_mainComposer', + MESSAGE_EDIT_CANCEL_INLINE: 'messageEditCancel_inlineMessageEdit', + }, }, MODAL: { MODAL_TYPE: { diff --git a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx index b65ef3c7d34f..51e6bbf3ad9c 100644 --- a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx +++ b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx @@ -110,6 +110,8 @@ type AttachmentPickerWithMenuItemsProps = { reportParticipantIDs?: number[]; shouldDisableAttachmentItem?: boolean; + + testID?: string; }; /** @@ -135,6 +137,7 @@ function AttachmentPickerWithMenuItems({ actionButtonRef, raiseIsScrollLikelyLayoutTriggered, shouldDisableAttachmentItem, + testID, }: AttachmentPickerWithMenuItemsProps) { const icons = useMemoizedLazyExpensifyIcons([ 'Cash', @@ -446,7 +449,10 @@ function AttachmentPickerWithMenuItems({ ]; return ( <> - + diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerActionMenu.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerActionMenu.tsx index 004e12a733ca..b5a9db6d205a 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerActionMenu.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerActionMenu.tsx @@ -5,6 +5,7 @@ import useOnyx from '@hooks/useOnyx'; import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import {chatIncludesConcierge} from '@libs/ReportUtils'; import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {FileObject} from '@src/types/utils/Attachment'; import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; @@ -39,6 +40,7 @@ function ComposerActionMenu({reportID, onAttachmentPicked}: ComposerActionMenuPr return ( + - + ); } diff --git a/src/pages/inbox/report/ReportActionCompose/MessageEditCancelButton.tsx b/src/pages/inbox/report/ReportActionCompose/MessageEditCancelButton.tsx index 92f6ed61f1fe..356e1eea4590 100644 --- a/src/pages/inbox/report/ReportActionCompose/MessageEditCancelButton.tsx +++ b/src/pages/inbox/report/ReportActionCompose/MessageEditCancelButton.tsx @@ -12,6 +12,7 @@ import CONST from '@src/CONST'; type MessageEditCancelButtonProps = ViewProps & { onCancel: () => void; + testID?: string; }; function MessageEditCancelButton({onCancel, ...restProps}: MessageEditCancelButtonProps) { diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index e2b2ea61fea0..e4e1730469cd 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -165,7 +165,10 @@ function ReportActionComposeInner({reportID}: ReportActionComposeProps) { } return ( - + diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index 975927a3fb42..3fd9f0a6866d 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -396,6 +396,7 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy return ( <> @@ -409,6 +410,7 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy ]} > From 62ffcc3079a990d49692dcc04c9af133e69ba3a2 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 22 Apr 2026 15:35:35 +0100 Subject: [PATCH 200/233] test: remove redundant `act()` and fix ESLint errors --- tests/unit/hooks/useEditComposerToggleTest.ts | 69 +++++++------------ 1 file changed, 24 insertions(+), 45 deletions(-) diff --git a/tests/unit/hooks/useEditComposerToggleTest.ts b/tests/unit/hooks/useEditComposerToggleTest.ts index 19ef9ab9eeca..170552fe8ff7 100644 --- a/tests/unit/hooks/useEditComposerToggleTest.ts +++ b/tests/unit/hooks/useEditComposerToggleTest.ts @@ -1,9 +1,10 @@ -import {act, renderHook} from '@testing-library/react-native'; -import type {ComposerRef, TextSelection} from '@components/Composer/types'; +/* eslint-disable @typescript-eslint/naming-convention */ +import {renderHook} from '@testing-library/react-native'; import type {RefObject} from 'react'; +import type {ComposerRef, TextSelection} from '@components/Composer/types'; import ReportActionComposeUtils from '@pages/inbox/report/ReportActionCompose/ReportActionComposeUtils'; import useEditComposerToggle from '@pages/inbox/report/ReportActionCompose/useEditComposerToggle'; -import type {ReportActionActiveEdit} from '@pages/inbox/report/ReportActionEditMessageContext'; +import type {ReportActionEditMessageContextValue} from '@pages/inbox/report/ReportActionEditMessageContext'; import * as ReportActionEditMessageContext from '@pages/inbox/report/ReportActionEditMessageContext'; jest.mock('@pages/inbox/report/ReportActionEditMessageContext', () => { @@ -47,7 +48,7 @@ const mockUseReportActionActiveEdit = jest.mocked(ReportActionEditMessageContext const mockUseResponsiveLayout = jest.requireMock('@hooks/useResponsiveLayout').default as jest.Mock; const mockUpdateNativeSelectionValue = jest.mocked(ReportActionComposeUtils.updateNativeSelectionValue); -type ActiveEdit = ReportActionActiveEdit & {editingState: 'off' | 'editing' | 'submitted'}; +type ActiveEdit = ReportActionEditMessageContextValue & {editingState: 'off' | 'editing' | 'submitted'}; function makeComposerRef(overrides?: Partial): RefObject { return { @@ -149,15 +150,13 @@ describe('useEditComposerToggle', () => { {initialProps: {selection: priorSelection, draft: 'keep my draft'}}, ); - act(() => { - activeEditRef.current = defaultActiveEdit({ - editingState: 'editing', - editingMessage: 'edited body', - editingReportActionID: '100', - currentEditMessageSelection: {start: 1, end: 2}, - }); - rerender({selection: priorSelection, draft: 'keep my draft'}); + activeEditRef.current = defaultActiveEdit({ + editingState: 'editing', + editingMessage: 'edited body', + editingReportActionID: '100', + currentEditMessageSelection: {start: 1, end: 2}, }); + rerender({selection: priorSelection, draft: 'keep my draft'}); const expectedEnd = 'edited body'.length; expect(onValueChange).toHaveBeenCalledWith('edited body'); @@ -180,13 +179,11 @@ describe('useEditComposerToggle', () => { }), ); - act(() => { - activeEditRef.current = defaultActiveEdit({ - editingState: 'editing', - editingMessage: 'from thread', - }); - rerender(); + activeEditRef.current = defaultActiveEdit({ + editingState: 'editing', + editingMessage: 'from thread', }); + rerender({}); expect(onValueChange).not.toHaveBeenCalled(); }); @@ -200,11 +197,7 @@ describe('useEditComposerToggle', () => { // Start with edit off so wasEditingRef is false; then turn editing on to capture previousDraftSelectionRef. const {rerender} = renderHook( (props: {selection: TextSelection; draft: string; editing: boolean}) => { - activeEditRef.current = defaultActiveEdit( - props.editing - ? {editingState: 'editing', editingMessage: 'e', editingReportActionID: '1'} - : {editingState: 'off'}, - ); + activeEditRef.current = defaultActiveEdit(props.editing ? {editingState: 'editing', editingMessage: 'e', editingReportActionID: '1'} : {editingState: 'off'}); return useEditComposerToggle({ selection: props.selection, draftComment: props.draft, @@ -216,16 +209,12 @@ describe('useEditComposerToggle', () => { {initialProps: {selection: priorSelection, draft: 'restored', editing: false}}, ); - act(() => { - rerender({selection: priorSelection, draft: 'restored', editing: true}); - }); + rerender({selection: priorSelection, draft: 'restored', editing: true}); onValueChange.mockClear(); onSelectionChange.mockClear(); - act(() => { - rerender({selection: priorSelection, draft: 'restored', editing: false}); - }); + rerender({selection: priorSelection, draft: 'restored', editing: false}); expect(onValueChange).toHaveBeenCalledWith('restored'); expect(onSelectionChange).toHaveBeenCalledWith(priorSelection); @@ -264,9 +253,7 @@ describe('useEditComposerToggle', () => { onValueChange.mockClear(); onFocus.mockClear(); - act(() => { - rerender('b'); - }); + rerender('b'); expect(onValueChange).toHaveBeenCalledWith('second'); expect(onFocus).toHaveBeenCalled(); @@ -296,10 +283,8 @@ describe('useEditComposerToggle', () => { onValueChange.mockClear(); - act(() => { - mockUseResponsiveLayout.mockReturnValue(narrowLayoutResult()); - rerender(); - }); + mockUseResponsiveLayout.mockReturnValue(narrowLayoutResult()); + rerender({}); expect(onValueChange).toHaveBeenCalledWith('wide first'); expect(onFocus).toHaveBeenCalled(); @@ -329,9 +314,7 @@ describe('useEditComposerToggle', () => { onValueChange.mockClear(); - act(() => { - rerender(false); - }); + rerender(false); expect(onValueChange).toHaveBeenCalledWith('plain draft for wide'); }); @@ -343,9 +326,7 @@ describe('useEditComposerToggle', () => { const {rerender} = renderHook( (editing: boolean) => { - activeEditRef.current = defaultActiveEdit( - editing ? {editingState: 'editing', editingMessage: 'hi', editingReportActionID: '1'} : {editingState: 'off'}, - ); + activeEditRef.current = defaultActiveEdit(editing ? {editingState: 'editing', editingMessage: 'hi', editingReportActionID: '1'} : {editingState: 'off'}); return useEditComposerToggle({ selection: {start: 0, end: 0}, draftComment: 'd', @@ -357,9 +338,7 @@ describe('useEditComposerToggle', () => { {initialProps: false}, ); - act(() => { - rerender(true); - }); + rerender(true); expect(mockUpdateNativeSelectionValue).toHaveBeenCalled(); }); From 90f57156d067f8a93e63fe3b0f4469850c1f1e60 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 22 Apr 2026 15:35:48 +0100 Subject: [PATCH 201/233] test: improve `ReportActionMessageEditLayoutTest` suite and handle more cases --- ...portActionComposeNarrowMessageEditTest.tsx | 219 ---------- .../ui/ReportActionMessageEditLayoutTest.tsx | 401 ++++++++++++++++++ 2 files changed, 401 insertions(+), 219 deletions(-) delete mode 100644 tests/ui/ReportActionComposeNarrowMessageEditTest.tsx create mode 100644 tests/ui/ReportActionMessageEditLayoutTest.tsx diff --git a/tests/ui/ReportActionComposeNarrowMessageEditTest.tsx b/tests/ui/ReportActionComposeNarrowMessageEditTest.tsx deleted file mode 100644 index 98b388aee342..000000000000 --- a/tests/ui/ReportActionComposeNarrowMessageEditTest.tsx +++ /dev/null @@ -1,219 +0,0 @@ -/* eslint-disable @typescript-eslint/naming-convention */ -import type * as NativeNavigation from '@react-navigation/native'; -import {act, fireEvent, render, screen, waitFor} from '@testing-library/react-native'; -import React from 'react'; -import type {PropsWithChildren} from 'react'; -import Onyx from 'react-native-onyx'; -import ComposeProviders from '@components/ComposeProviders'; -import {LocaleContextProvider} from '@components/LocaleContextProvider'; -import OnyxListItemProvider from '@components/OnyxListItemProvider'; -import {KeyboardStateProvider} from '@components/withKeyboardState'; -import * as Report from '@libs/actions/Report'; -import type {ReportActionComposeProps} from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; -import ReportActionCompose from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; -import {ReportActionEditMessageContextProvider} from '@pages/inbox/report/ReportActionEditMessageContext'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import * as LHNTestUtils from '../utils/LHNTestUtils'; -import * as TestHelper from '../utils/TestHelper'; -import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; - -// Narrow layout: message edits happen in the main composer (ComposerWithSuggestions + useEditComposerToggle), -// and ReportActionCompose swaps the attachment action row for edit chrome (ComposerEditingButtons). - -jest.mock('@hooks/useResponsiveLayout', () => ({ - __esModule: true, - default: () => ({ - shouldUseNarrowLayout: true, - isSmallScreenWidth: true, - isInNarrowPaneModal: false, - isExtraSmallScreenHeight: false, - isExtraSmallScreenWidth: false, - isMediumScreenWidth: false, - onboardingIsMediumOrLargerScreenWidth: false, - isLargeScreenWidth: false, - isSmallScreen: true, - }), -})); - -jest.mock('@libs/getPlatform', () => ({ - __esModule: true, - default: () => 'web', -})); - -jest.mock('@libs/ComponentUtils', () => ({ - forceClearInput: jest.fn(), -})); - -jest.mock('@hooks/useLocalize', () => - jest.fn(() => ({ - translate: jest.fn((key: string) => key), - numberFormat: jest.fn((num: number) => num.toString()), - })), -); - -jest.mock('@hooks/usePaginatedReportActions', () => jest.fn(() => ({reportActions: [], hasNewerActions: false, hasOlderActions: false}))); -jest.mock('@hooks/useParentReportAction', () => jest.fn(() => null)); -jest.mock('@hooks/useReportTransactionsCollection', () => jest.fn(() => ({}))); -jest.mock('@hooks/useShortMentionsList', () => jest.fn(() => ({availableLoginsList: []}))); -jest.mock('@hooks/useSidePanelState', () => jest.fn(() => ({sessionStartTime: null}))); - -jest.mock('@components/DropZone/DualDropZone', () => { - const RN = jest.requireActual>>('react-native'); - return ({shouldAcceptSingleReceipt}: {shouldAcceptSingleReceipt?: boolean}) => ( - {shouldAcceptSingleReceipt ? 'receipt-editable' : 'receipt-not-editable'} - ); -}); - -const mockRouteReportID = {current: '1'}; - -jest.mock('@react-navigation/native', () => ({ - ...((): typeof NativeNavigation => { - return jest.requireActual('@react-navigation/native'); - })(), - useNavigation: jest.fn(() => ({ - navigate: jest.fn(), - addListener: jest.fn(() => jest.fn()), - })), - useIsFocused: jest.fn(() => true), - useRoute: jest.fn(() => ({key: '', name: '', params: {reportID: mockRouteReportID.current}})), -})); - -TestHelper.setupGlobalFetchMock(); - -const defaultReport = LHNTestUtils.getFakeReport(); -mockRouteReportID.current = defaultReport.reportID; - -const defaultProps: ReportActionComposeProps = { - reportID: defaultReport.reportID, -}; - -const commentAction = { - ...LHNTestUtils.getFakeReportAction(), - actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, -}; - -function ReportActionEditMessageContextProviderForReport({children}: PropsWithChildren) { - return {children}; -} - -function ReportScreenProviders({children}: PropsWithChildren) { - return {children}; -} - -const saveReportActionDraftSpy = jest.spyOn(Report, 'saveReportActionDraft'); - -function renderNarrowReportActionCompose() { - return render( - - - , - ); -} - -describe('ReportActionCompose — narrow layout message edit flow', () => { - beforeAll(() => { - Onyx.init({ - keys: ONYXKEYS, - evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS], - }); - }); - - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(async () => { - jest.useRealTimers(); - await act(async () => { - await Onyx.clear(); - }); - saveReportActionDraftSpy.mockClear(); - }); - - it('shows edit chrome (close) instead of the create control while a report action draft is open', async () => { - await act(async () => { - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${defaultReport.reportID}`, defaultReport); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${defaultReport.reportID}`, { - [commentAction.reportActionID]: commentAction, - }); - await Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS, { - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${defaultReport.reportID}`]: { - [commentAction.reportActionID]: {message: 'Edit me in the composer'}, - }, - }); - }); - await waitForBatchedUpdatesWithAct(); - - renderNarrowReportActionCompose(); - await waitForBatchedUpdatesWithAct(); - - expect(screen.getByLabelText('common.close')).toBeOnTheScreen(); - expect(screen.queryByLabelText('common.create')).toBeNull(); - }); - - it('loads the draft into the composer and debounces saveReportActionDraft while typing in edit mode', async () => { - await act(async () => { - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${defaultReport.reportID}`, defaultReport); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${defaultReport.reportID}`, { - [commentAction.reportActionID]: commentAction, - }); - await Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS, { - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${defaultReport.reportID}`]: { - [commentAction.reportActionID]: {message: 'Start'}, - }, - }); - }); - await waitForBatchedUpdatesWithAct(); - - renderNarrowReportActionCompose(); - await waitForBatchedUpdatesWithAct(); - - const composer = screen.getByTestId('composer'); - await waitFor(() => { - expect(composer.props.value).toBe('Start'); - }); - - fireEvent.changeText(composer, 'Start, edited'); - - act(() => { - jest.advanceTimersByTime(1100); - }); - await waitForBatchedUpdatesWithAct(); - - expect(saveReportActionDraftSpy).toHaveBeenCalled(); - const lastCall = saveReportActionDraftSpy.mock.calls.at(-1); - expect(lastCall?.[0]).toBe(defaultReport.reportID); - expect(lastCall?.[2]).toBe('Start, edited'); - }); - - it('leaves edit mode and restores default composer actions when the user cancels', async () => { - await act(async () => { - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${defaultReport.reportID}`, defaultReport); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${defaultReport.reportID}`, { - [commentAction.reportActionID]: commentAction, - }); - await Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS, { - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${defaultReport.reportID}`]: { - [commentAction.reportActionID]: {message: 'To cancel'}, - }, - }); - }); - await waitForBatchedUpdatesWithAct(); - - renderNarrowReportActionCompose(); - await waitForBatchedUpdatesWithAct(); - - const cancelButton = screen.getByLabelText('common.close'); - fireEvent.press(cancelButton); - await waitForBatchedUpdatesWithAct(); - - await waitFor(() => { - expect(screen.getByLabelText('common.create')).toBeOnTheScreen(); - }); - expect(screen.queryByLabelText('common.close')).toBeNull(); - }); -}); diff --git a/tests/ui/ReportActionMessageEditLayoutTest.tsx b/tests/ui/ReportActionMessageEditLayoutTest.tsx new file mode 100644 index 000000000000..6466fde55607 --- /dev/null +++ b/tests/ui/ReportActionMessageEditLayoutTest.tsx @@ -0,0 +1,401 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type * as NativeNavigation from '@react-navigation/native'; +import {act, fireEvent, render, screen, waitFor, within} from '@testing-library/react-native'; +import React from 'react'; +import type {PropsWithChildren} from 'react'; +import Onyx from 'react-native-onyx'; +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import {KeyboardStateProvider} from '@components/withKeyboardState'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import type {ReportActionComposeProps} from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; +import ReportActionCompose from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; +import {ReportActionEditMessageContextProvider} from '@pages/inbox/report/ReportActionEditMessageContext'; +import type {ReportActionItemMessageEditProps} from '@pages/inbox/report/ReportActionItemMessageEdit'; +import ReportActionItemMessageEdit from '@pages/inbox/report/ReportActionItemMessageEdit'; +import {draftMessageVideoAttributeCache} from '@pages/inbox/report/useDraftMessageVideoAttributeCache'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import * as LHNTestUtils from '../utils/LHNTestUtils'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +/** + * Exercises where message edit appears on narrow (main @ReportActionCompose) vs wide (@ReportActionItemMessageEdit), + * draft set/unset, and layout switching. TestIDs: CONST.COMPOSER.TEST_ID.* + */ + +jest.mock('@hooks/useResponsiveLayout', () => ({ + __esModule: true, + default: jest.fn(), +})); + +const narrowLayout: ReturnType = { + shouldUseNarrowLayout: true, + isSmallScreenWidth: true, + isInNarrowPaneModal: false, + isExtraSmallScreenHeight: false, + isExtraSmallScreenWidth: false, + isMediumScreenWidth: false, + onboardingIsMediumOrLargerScreenWidth: false, + isLargeScreenWidth: false, + isSmallScreen: true, +} as ReturnType; + +const wideLayout: ReturnType = { + shouldUseNarrowLayout: false, + isSmallScreenWidth: false, + isInNarrowPaneModal: false, + isExtraSmallScreenHeight: false, + isExtraSmallScreenWidth: false, + isMediumScreenWidth: false, + onboardingIsMediumOrLargerScreenWidth: true, + isLargeScreenWidth: true, + isSmallScreen: false, +} as ReturnType; + +jest.mock('@libs/getPlatform', () => ({ + __esModule: true, + default: () => 'web', +})); + +jest.mock('@libs/ComponentUtils', () => ({ + forceClearInput: jest.fn(), +})); + +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: jest.fn((key: string) => key), + numberFormat: jest.fn((num: number) => num.toString()), + })), +); + +jest.mock('@hooks/usePaginatedReportActions', () => jest.fn(() => ({reportActions: [], hasNewerActions: false, hasOlderActions: false}))); +jest.mock('@hooks/useParentReportAction', () => jest.fn(() => null)); +jest.mock('@hooks/useReportTransactionsCollection', () => jest.fn(() => ({}))); +jest.mock('@hooks/useShortMentionsList', () => jest.fn(() => ({availableLoginsList: []}))); +jest.mock('@hooks/useSidePanelState', () => jest.fn(() => ({sessionStartTime: null}))); +jest.mock('@hooks/useCardFeedsForDisplay', () => jest.fn(() => ({defaultCardFeed: null, cardFeedsByPolicy: {}}))); + +jest.mock('@libs/actions/Report', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@libs/actions/Report'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + editReportComment: jest.fn(), + }; +}); + +jest.mock('@pages/inbox/report/ContextMenu/ReportActionContextMenu', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@pages/inbox/report/ContextMenu/ReportActionContextMenu'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + showDeleteModal: jest.fn(), + }; +}); + +jest.mock('@components/DropZone/DualDropZone', () => { + const RN = jest.requireActual>>('react-native'); + return ({shouldAcceptSingleReceipt}: {shouldAcceptSingleReceipt?: boolean}) => ( + {shouldAcceptSingleReceipt ? 'receipt-editable' : 'receipt-not-editable'} + ); +}); + +const mockRouteReportID = {current: '1'}; + +jest.mock('@react-navigation/native', () => ({ + ...((): typeof NativeNavigation => { + return jest.requireActual('@react-navigation/native'); + })(), + useNavigation: jest.fn(() => ({ + navigate: jest.fn(), + addListener: jest.fn(() => jest.fn()), + })), + useIsFocused: jest.fn(() => true), + useRoute: jest.fn(() => ({key: '', name: '', params: {reportID: mockRouteReportID.current}})), +})); + +TestHelper.setupGlobalFetchMock(); + +const mockUseResponsiveLayout = jest.mocked(useResponsiveLayout); + +const defaultReport = LHNTestUtils.getFakeReport(); +mockRouteReportID.current = defaultReport.reportID; + +const defaultProps: ReportActionComposeProps = { + reportID: defaultReport.reportID, +}; + +const commentAction: ReportActionItemMessageEditProps['action'] = { + ...LHNTestUtils.getFakeReportAction(), + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, +}; + +const testIds = CONST.COMPOSER.TEST_ID; + +function ReportActionEditMessageContextProviderForReport({children}: PropsWithChildren) { + return {children}; +} + +function ReportScreenProviders({children}: PropsWithChildren) { + return {children}; +} + +/** + * Simulates the product split: on wide, inline @ReportActionItemMessageEdit is mounted (isEditingInline in PureReportActionItem); + * on narrow it is not and edit happens in the main composer. + */ +type LayoutMode = 'narrow' | 'wide'; +function MessageEditLayoutHost({layout}: {layout: LayoutMode}) { + const isWide = layout === 'wide'; + return ( + + + {isWide && ( + + )} + + ); +} + +async function seedReportAndActions() { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${defaultReport.reportID}`, defaultReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${defaultReport.reportID}`, { + [commentAction.reportActionID]: commentAction, + }); + }); +} + +async function setReportActionDraftWithMessage(message: string) { + await act(async () => { + await Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS, { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${defaultReport.reportID}`]: { + [commentAction.reportActionID]: {message}, + }, + }); + }); +} + +async function clearReportActionDraftsForReport() { + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${defaultReport.reportID}`, {}); + }); +} + +function renderNarrowMessageCompose() { + mockUseResponsiveLayout.mockReturnValue(narrowLayout); + return render( + + + , + ); +} + +describe('ReportActionMessageEdit layout and draft (narrow vs wide)', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS], + }); + }); + + beforeEach(() => { + mockUseResponsiveLayout.mockReturnValue(narrowLayout); + jest.useFakeTimers(); + }); + + afterEach(async () => { + jest.useRealTimers(); + await act(async () => { + await Onyx.clear(); + }); + draftMessageVideoAttributeCache.clear(); + }); + + it('with no report-action draft, main composer is in normal draft message mode (not message-edit action row)', async () => { + await seedReportAndActions(); + await waitForBatchedUpdatesWithAct(); + + renderNarrowMessageCompose(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByTestId(testIds.DRAFT_MESSAGE_ACTION_ROW)).toBeOnTheScreen(); + expect(screen.queryByTestId(testIds.EDITING_MESSAGE_ACTION_ROW)).toBeNull(); + }); + + it('when a report-action draft is set on narrow, main composer enters message edit mode and edit-mode test IDs are used', async () => { + await seedReportAndActions(); + await setReportActionDraftWithMessage('Narrow body'); + await waitForBatchedUpdatesWithAct(); + + renderNarrowMessageCompose(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByTestId(testIds.EDITING_MESSAGE_ACTION_ROW)).toBeOnTheScreen(); + expect(screen.queryByTestId(testIds.DRAFT_MESSAGE_ACTION_ROW)).toBeNull(); + expect(screen.getByTestId(testIds.MESSAGE_EDIT_CANCEL_MAIN_COMPOSER)).toBeOnTheScreen(); + const mainCompose = screen.getByTestId(testIds.REPORT_ACTION_COMPOSE); + expect(within(mainCompose).getByTestId(CONST.COMPOSER.NATIVE_ID).props.value).toBe('Narrow body'); + }); + + it('when the draft is cleared, message edit mode ends and normal draft action row returns', async () => { + await seedReportAndActions(); + await setReportActionDraftWithMessage('Then remove'); + await waitForBatchedUpdatesWithAct(); + + const {unmount} = renderNarrowMessageCompose(); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByTestId(testIds.EDITING_MESSAGE_ACTION_ROW)).toBeOnTheScreen(); + + await clearReportActionDraftsForReport(); + await waitForBatchedUpdatesWithAct(); + + unmount(); + renderNarrowMessageCompose(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByTestId(testIds.DRAFT_MESSAGE_ACTION_ROW)).toBeOnTheScreen(); + expect(screen.queryByTestId(testIds.EDITING_MESSAGE_ACTION_ROW)).toBeNull(); + }); + + it('on wide, main composer stays in normal action row while the inline @ReportActionItemMessageEdit is used', async () => { + await seedReportAndActions(); + await setReportActionDraftWithMessage('Wide inline'); + await waitForBatchedUpdatesWithAct(); + + mockUseResponsiveLayout.mockReturnValue(wideLayout); + render(); + await waitForBatchedUpdatesWithAct(); + + const mainRoot = screen.getByTestId(testIds.REPORT_ACTION_COMPOSE); + expect(within(mainRoot).getByTestId(testIds.DRAFT_MESSAGE_ACTION_ROW)).toBeOnTheScreen(); + expect(within(mainRoot).queryByTestId(testIds.EDITING_MESSAGE_ACTION_ROW)).toBeNull(); + + expect(screen.getByTestId(testIds.REPORT_ACTION_ITEM_MESSAGE_EDIT)).toBeOnTheScreen(); + expect(screen.getByTestId(testIds.MESSAGE_EDIT_CANCEL_INLINE)).toBeOnTheScreen(); + expect(screen.queryByTestId(testIds.MESSAGE_EDIT_CANCEL_MAIN_COMPOSER)).toBeNull(); + }); + + it('switches the editing surface from inline (wide) to main composer (narrow) when layout becomes narrow', async () => { + await seedReportAndActions(); + await setReportActionDraftWithMessage('Shared draft'); + await waitForBatchedUpdatesWithAct(); + + mockUseResponsiveLayout.mockReturnValue(wideLayout); + const {unmount} = render( + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByTestId(testIds.REPORT_ACTION_ITEM_MESSAGE_EDIT)).toBeOnTheScreen(); + expect(screen.getByTestId(testIds.MESSAGE_EDIT_CANCEL_INLINE)).toBeOnTheScreen(); + const mainWide = screen.getByTestId(testIds.REPORT_ACTION_COMPOSE); + expect(within(mainWide).getByTestId(testIds.DRAFT_MESSAGE_ACTION_ROW)).toBeOnTheScreen(); + + unmount(); + mockUseResponsiveLayout.mockReturnValue(narrowLayout); + render( + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(screen.queryByTestId(testIds.REPORT_ACTION_ITEM_MESSAGE_EDIT)).toBeNull(); + expect(screen.queryByTestId(testIds.MESSAGE_EDIT_CANCEL_INLINE)).toBeNull(); + expect(screen.getByTestId(testIds.EDITING_MESSAGE_ACTION_ROW)).toBeOnTheScreen(); + expect(screen.getByTestId(testIds.MESSAGE_EDIT_CANCEL_MAIN_COMPOSER)).toBeOnTheScreen(); + }); + + it('switches the editing surface from main composer (narrow) to inline (wide) when layout becomes wide', async () => { + await seedReportAndActions(); + await setReportActionDraftWithMessage('Back to wide'); + await waitForBatchedUpdatesWithAct(); + + mockUseResponsiveLayout.mockReturnValue(narrowLayout); + const {unmount} = render( + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByTestId(testIds.EDITING_MESSAGE_ACTION_ROW)).toBeOnTheScreen(); + expect(screen.getByTestId(testIds.MESSAGE_EDIT_CANCEL_MAIN_COMPOSER)).toBeOnTheScreen(); + expect(screen.queryByTestId(testIds.REPORT_ACTION_ITEM_MESSAGE_EDIT)).toBeNull(); + + unmount(); + mockUseResponsiveLayout.mockReturnValue(wideLayout); + render( + , + ); + await waitForBatchedUpdatesWithAct(); + + const main = screen.getByTestId(testIds.REPORT_ACTION_COMPOSE); + expect(within(main).getByTestId(testIds.DRAFT_MESSAGE_ACTION_ROW)).toBeOnTheScreen(); + expect(within(main).queryByTestId(testIds.EDITING_MESSAGE_ACTION_ROW)).toBeNull(); + expect(screen.getByTestId(testIds.REPORT_ACTION_ITEM_MESSAGE_EDIT)).toBeOnTheScreen(); + expect(screen.getByTestId(testIds.MESSAGE_EDIT_CANCEL_INLINE)).toBeOnTheScreen(); + }); + + it('in narrow message-edit-in-composer mode, updateComment keeps the main composer value in sync (editingState + shouldUseNarrowLayout branch in ComposerWithSuggestions)', async () => { + await seedReportAndActions(); + await setReportActionDraftWithMessage('Start'); + await waitForBatchedUpdatesWithAct(); + + mockUseResponsiveLayout.mockReturnValue(narrowLayout); + renderNarrowMessageCompose(); + await waitForBatchedUpdatesWithAct(); + + const mainRoot = screen.getByTestId(testIds.REPORT_ACTION_COMPOSE); + const composer = within(mainRoot).getByTestId(CONST.COMPOSER.NATIVE_ID); + expect(screen.getByTestId(testIds.EDITING_MESSAGE_ACTION_ROW)).toBeOnTheScreen(); + + fireEvent.changeText(composer, 'Start, edited'); + await waitFor(() => { + expect(within(mainRoot).getByTestId(CONST.COMPOSER.NATIVE_ID).props.value).toBe('Start, edited'); + }); + }); + + it('cancel in narrow main composer returns to normal draft action row', async () => { + await seedReportAndActions(); + await setReportActionDraftWithMessage('Cancel me'); + await waitForBatchedUpdatesWithAct(); + + renderNarrowMessageCompose(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByTestId(testIds.MESSAGE_EDIT_CANCEL_MAIN_COMPOSER)); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.getByTestId(testIds.DRAFT_MESSAGE_ACTION_ROW)).toBeOnTheScreen(); + }); + expect(screen.queryByTestId(testIds.EDITING_MESSAGE_ACTION_ROW)).toBeNull(); + }); +}); From 108f842747aeb58c3ac2f4ac4a5df387b0d49d9e Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 22 Apr 2026 15:38:36 +0100 Subject: [PATCH 202/233] fix: move testID to pressable --- .../report/ReportActionCompose/MessageEditCancelButton.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionCompose/MessageEditCancelButton.tsx b/src/pages/inbox/report/ReportActionCompose/MessageEditCancelButton.tsx index 356e1eea4590..7a8758897afc 100644 --- a/src/pages/inbox/report/ReportActionCompose/MessageEditCancelButton.tsx +++ b/src/pages/inbox/report/ReportActionCompose/MessageEditCancelButton.tsx @@ -15,7 +15,7 @@ type MessageEditCancelButtonProps = ViewProps & { testID?: string; }; -function MessageEditCancelButton({onCancel, ...restProps}: MessageEditCancelButtonProps) { +function MessageEditCancelButton({onCancel, testID, ...restProps}: MessageEditCancelButtonProps) { const styles = useThemeStyles(); const theme = useTheme(); const {translate} = useLocalize(); @@ -28,6 +28,7 @@ function MessageEditCancelButton({onCancel, ...restProps}: MessageEditCancelButt Date: Wed, 22 Apr 2026 19:53:23 +0100 Subject: [PATCH 203/233] fix: test mock type errors --- tests/unit/hooks/useEditComposerToggleTest.ts | 27 +++++++------------ 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/tests/unit/hooks/useEditComposerToggleTest.ts b/tests/unit/hooks/useEditComposerToggleTest.ts index 170552fe8ff7..f67d3ace4aaa 100644 --- a/tests/unit/hooks/useEditComposerToggleTest.ts +++ b/tests/unit/hooks/useEditComposerToggleTest.ts @@ -2,6 +2,8 @@ import {renderHook} from '@testing-library/react-native'; import type {RefObject} from 'react'; import type {ComposerRef, TextSelection} from '@components/Composer/types'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types'; import ReportActionComposeUtils from '@pages/inbox/report/ReportActionCompose/ReportActionComposeUtils'; import useEditComposerToggle from '@pages/inbox/report/ReportActionCompose/useEditComposerToggle'; import type {ReportActionEditMessageContextValue} from '@pages/inbox/report/ReportActionEditMessageContext'; @@ -17,20 +19,7 @@ jest.mock('@pages/inbox/report/ReportActionEditMessageContext', () => { }; }); -jest.mock('@hooks/useResponsiveLayout', () => ({ - __esModule: true, - default: jest.fn(() => ({ - shouldUseNarrowLayout: true, - isSmallScreenWidth: true, - isInNarrowPaneModal: false, - isExtraSmallScreenHeight: false, - isExtraSmallScreenWidth: false, - isMediumScreenWidth: false, - onboardingIsMediumOrLargerScreenWidth: false, - isLargeScreenWidth: false, - isSmallScreen: true, - })), -})); +jest.mock('@hooks/useResponsiveLayout', () => jest.fn()); jest.mock('@pages/inbox/report/ReportActionCompose/ReportActionComposeUtils', () => ({ __esModule: true, @@ -45,7 +34,7 @@ jest.mock('@libs/getPlatform', () => ({ })); const mockUseReportActionActiveEdit = jest.mocked(ReportActionEditMessageContext.useReportActionActiveEdit); -const mockUseResponsiveLayout = jest.requireMock('@hooks/useResponsiveLayout').default as jest.Mock; +const mockUseResponsiveLayout = useResponsiveLayout as jest.MockedFunction; const mockUpdateNativeSelectionValue = jest.mocked(ReportActionComposeUtils.updateNativeSelectionValue); type ActiveEdit = ReportActionEditMessageContextValue & {editingState: 'off' | 'editing' | 'submitted'}; @@ -75,7 +64,7 @@ function defaultActiveEdit(overrides?: Partial): ActiveEdit { }; } -function wideLayoutResult() { +function wideLayoutResult(): ResponsiveLayoutResult { return { shouldUseNarrowLayout: false, isSmallScreenWidth: false, @@ -85,11 +74,13 @@ function wideLayoutResult() { isMediumScreenWidth: false, onboardingIsMediumOrLargerScreenWidth: true, isLargeScreenWidth: true, + isExtraLargeScreenWidth: false, isSmallScreen: false, + isInLandscapeMode: false, }; } -function narrowLayoutResult() { +function narrowLayoutResult(): ResponsiveLayoutResult { return { shouldUseNarrowLayout: true, isSmallScreenWidth: true, @@ -99,7 +90,9 @@ function narrowLayoutResult() { isMediumScreenWidth: false, onboardingIsMediumOrLargerScreenWidth: false, isLargeScreenWidth: false, + isExtraLargeScreenWidth: false, isSmallScreen: true, + isInLandscapeMode: false, }; } From 3fd68a798b94e875189d24311d4aefbca233abf2 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 23 Apr 2026 11:42:22 +0100 Subject: [PATCH 204/233] fix: `clearComposer` callback invalid use of `scheduleOnUI` --- .../inbox/report/ReportActionCompose/ComposerProvider.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx index b7f2c5cc1d57..6c670c0102ef 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx @@ -1,6 +1,5 @@ import React, {useEffect, useRef, useState} from 'react'; import type {View} from 'react-native'; -import {useSharedValue} from 'react-native-reanimated'; import {scheduleOnUI} from 'react-native-worklets'; import useOnyx from '@hooks/useOnyx'; import useOriginalReportID from '@hooks/useOriginalReportID'; @@ -62,8 +61,6 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { const actionButtonRef = useRef(null); const attachmentFileRef = useRef(null); - const composerRefShared = useSharedValue>({}); - const {editingState, editingReportID, editingReportActionID, editingReportAction, editingMessage} = useReportActionActiveEdit(); const [didResetComposerHeight, setDidResetComposerHeight] = useState(false); @@ -106,7 +103,7 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { }); const clearComposer = () => { - const clearWorklet = composerRefShared.get().clearWorklet; + const clearWorklet = composerRef.current?.clearWorklet; if (!clearWorklet) { throw new Error('The composerRef.clearWorklet function is not set yet. This should never happen, and indicates a developer error.'); } @@ -115,9 +112,6 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { const setComposerRef = (ref: ComposerWithSuggestionsRef | null) => { composerRef.current = ref; - composerRefShared.set({ - clearWorklet: ref?.clearWorklet, - }); }; const composerState = { From 28f9b824804398ad1b8e2dcd50b4f1864d3e5812 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 24 Apr 2026 19:03:36 +0100 Subject: [PATCH 205/233] fix: forward expand/collapse button style --- .../report/ReportActionCompose/ComposerEditingButtons.tsx | 7 ++++++- .../ReportActionCompose/ComposerExpandCollapseButton.tsx | 7 +++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerEditingButtons.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerEditingButtons.tsx index b5060f393c42..7d627e50653d 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerEditingButtons.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerEditingButtons.tsx @@ -22,12 +22,17 @@ function ComposerEditingButtons({reportID}: ComposerEditingButtonsProps) { styles.justifyContentCenter, {paddingVertical: styles.composerSizeButton.marginHorizontal}, ]; + const expandCollapseComposerButtonStyles = [styles.flexGrow1, styles.flexShrink0, {marginRight: styles.composerSizeButton.marginHorizontal}]; + return ( - + ); } From aa394b76c919c253c103133a7f4425dc7246668c Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 28 Apr 2026 09:34:28 +0100 Subject: [PATCH 206/233] fix: remove `canEvict` `useOnyx` flag --- src/pages/inbox/report/ReportActionEditMessageContext.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/pages/inbox/report/ReportActionEditMessageContext.tsx b/src/pages/inbox/report/ReportActionEditMessageContext.tsx index 5f745ff1e458..fdb842952c74 100644 --- a/src/pages/inbox/report/ReportActionEditMessageContext.tsx +++ b/src/pages/inbox/report/ReportActionEditMessageContext.tsx @@ -56,9 +56,7 @@ type ReportActionEditMessageContextProviderProps = { function ReportActionEditMessageContextProvider({reportID, children}: ReportActionEditMessageContextProviderProps) { const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { - canEvict: false, - }); + const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`); const ancestors = useAncestors(report, shouldExcludeAncestorReportAction); From 9117a243f185727871b0c0079fb10030b876448f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 29 Apr 2026 11:31:19 +0100 Subject: [PATCH 207/233] refactor: use `useComposerEditState` instead of `useReportActionActiveEdit` --- .../ReportActionCompose/ComposerContext.ts | 14 +++++++++++--- .../ReportActionCompose/ComposerProvider.tsx | 3 ++- .../ComposerWithSuggestions.tsx | 5 +++-- .../useEditComposerToggle.ts | 15 ++++++++++++--- .../report/ReportActionEditMessageContext.tsx | 2 +- tests/unit/hooks/useEditComposerToggleTest.ts | 17 +++++++++-------- 6 files changed, 38 insertions(+), 18 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts index 225e25c736ab..d6bebf43a981 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts +++ b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts @@ -2,7 +2,9 @@ import type {RefObject} from 'react'; import {createContext, useContext} from 'react'; import type {BlurEvent, TextInputSelectionChangeEvent, View} from 'react-native'; import type {Emoji} from '@assets/emojis/types'; +import type {TextSelection} from '@components/Composer/types'; import type {Mention} from '@components/MentionSuggestions'; +import type {ReportAction} from '@src/types/onyx'; import type {FileObject} from '@src/types/utils/Attachment'; import type {ComposerWithSuggestionsRef} from './ComposerWithSuggestions'; import type useDebouncedCommentMaxLengthValidation from './useDebouncedCommentMaxLengthValidation'; @@ -29,12 +31,15 @@ type ComposerState = { }; type ComposerEditState = { + editingState: 'off' | 'editing' | 'submitted'; isEditingInComposer: boolean; + editingReportID: string | null; editingReportActionID: string | null; + editingReportAction: ReportAction | null; editingMessage: string | null; - editingState: 'off' | 'editing' | 'submitted'; draftComment: string | null | undefined; effectiveDraft: string | null | undefined; + currentEditMessageSelection: TextSelection | null; }; // Warm — changes based on content + policy @@ -99,12 +104,15 @@ const defaultSendState: ComposerSendState = { }; const defaultEditState: ComposerEditState = { + editingState: 'off', isEditingInComposer: false, + editingReportID: null, editingReportActionID: null, + editingReportAction: null, editingMessage: null, - editingState: 'off', draftComment: undefined, effectiveDraft: undefined, + currentEditMessageSelection: null, }; const ComposerEditStateContext = createContext(defaultEditState); @@ -181,4 +189,4 @@ export { useComposerEditActions, useComposerMeta, }; -export type {SuggestionsRef, ComposerText, ComposerState, ComposerSendState, ComposerActions, ComposerMeta}; +export type {SuggestionsRef, ComposerText, ComposerState, ComposerEditState, ComposerSendState, ComposerActions, ComposerMeta}; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx index 6c670c0102ef..6d41cff7335c 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx @@ -61,7 +61,7 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { const actionButtonRef = useRef(null); const attachmentFileRef = useRef(null); - const {editingState, editingReportID, editingReportActionID, editingReportAction, editingMessage} = useReportActionActiveEdit(); + const {editingState, editingReportID, editingReportActionID, editingReportAction, editingMessage, currentEditMessageSelection} = useReportActionActiveEdit(); const [didResetComposerHeight, setDidResetComposerHeight] = useState(false); useEffect(() => { @@ -122,6 +122,7 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { }; const composerEditState = { + editingState, isEditingInComposer, editingReportActionID, editingMessage, diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 4bd63c834c09..f13506e1076e 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -48,7 +48,7 @@ import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManag import {isValidReportIDFromPath, shouldAutoFocusOnKeyPress} from '@libs/ReportUtils'; import updateMultilineInputRange from '@libs/updateMultilineInputRange'; import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; -import {useReportActionActiveEdit, useReportActionActiveEditActions} from '@pages/inbox/report/ReportActionEditMessageContext'; +import {useReportActionActiveEditActions} from '@pages/inbox/report/ReportActionEditMessageContext'; import useDraftMessageVideoAttributeCache from '@pages/inbox/report/useDraftMessageVideoAttributeCache'; import {isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; import type {OnEmojiSelected} from '@userActions/EmojiPickerAction'; @@ -63,6 +63,7 @@ import type {FileObject} from '@src/types/utils/Attachment'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; // eslint-disable-next-line no-restricted-imports import findNodeHandle from '@src/utils/findNodeHandle'; +import {useComposerEditState} from './ComposerContext'; import getCursorPosition from './getCursorPosition'; import getScrollPosition from './getScrollPosition'; import type {SuggestionsRef} from './ReportActionCompose'; @@ -260,7 +261,7 @@ function ComposerWithSuggestions({ const composerRef = useRef(null); - const {editingState, editingReportActionID, editingReportAction, editingMessage, currentEditMessageSelection} = useReportActionActiveEdit(); + const {editingState, editingReportActionID, editingReportAction, editingMessage, currentEditMessageSelection} = useComposerEditState(); const {setEditingMessage, setCurrentEditMessageSelection} = useReportActionActiveEditActions(); const isEditing = editingState !== 'off'; diff --git a/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts b/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts index ffcad92cdfe5..d233c3285f46 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts @@ -4,8 +4,8 @@ import type {RefObject} from 'react'; import type {ComposerRef, TextSelection} from '@components/Composer/types'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import getPlatform from '@libs/getPlatform'; -import {useReportActionActiveEdit} from '@pages/inbox/report/ReportActionEditMessageContext'; import CONST from '@src/CONST'; +import {useComposerEditState} from './ComposerContext'; import ReportActionComposeUtils from './ReportActionComposeUtils'; const isIOSNative = getPlatform() === CONST.PLATFORM.IOS; @@ -29,7 +29,7 @@ type UseEditComposerToggleProps = { function useEditComposerToggle({selection, draftComment, composerRef, onFocus, onValueChange, onSelectionChange}: UseEditComposerToggleProps) { const {shouldUseNarrowLayout} = useResponsiveLayout(); - const {editingState, editingReportActionID, editingMessage, currentEditMessageSelection} = useReportActionActiveEdit(); + const {editingState, editingReportActionID, editingMessage, currentEditMessageSelection} = useComposerEditState(); const isEditing = editingState !== 'off'; const wasEditingRef = useRef(isEditing); @@ -134,7 +134,16 @@ function useEditComposerToggle({selection, draftComment, composerRef, onFocus, o } previousEditingReportActionIDRef.current = editingReportActionID; - }, [applyComposerValue, composerRef, draftComment, editingMessage, editingReportActionID, editingState, selection, shouldUseNarrowLayout]); + }, [ + applyComposerValue, + composerRef, + draftComment, + editingMessage, + editingReportActionID, + editingState, + selection, + shouldUseNarrowLayout, + ]); } export default useEditComposerToggle; diff --git a/src/pages/inbox/report/ReportActionEditMessageContext.tsx b/src/pages/inbox/report/ReportActionEditMessageContext.tsx index fdb842952c74..2493731e4eb2 100644 --- a/src/pages/inbox/report/ReportActionEditMessageContext.tsx +++ b/src/pages/inbox/report/ReportActionEditMessageContext.tsx @@ -34,12 +34,12 @@ type ReportActionEditMessageContextActions = { }; const ReportActionEditMessageContext = createContext({ + editingState: 'off', editingReportID: null, editingReportActionID: null, editingReportAction: null, editingMessage: null, currentEditMessageSelection: null, - editingState: 'off', }); const ReportActionEditMessageActionsContext = createContext({ diff --git a/tests/unit/hooks/useEditComposerToggleTest.ts b/tests/unit/hooks/useEditComposerToggleTest.ts index f67d3ace4aaa..8da04140c803 100644 --- a/tests/unit/hooks/useEditComposerToggleTest.ts +++ b/tests/unit/hooks/useEditComposerToggleTest.ts @@ -4,10 +4,10 @@ import type {RefObject} from 'react'; import type {ComposerRef, TextSelection} from '@components/Composer/types'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types'; +import * as ComposerContext from '@pages/inbox/report/ReportActionCompose/ComposerContext'; +import type {ComposerEditState} from '@pages/inbox/report/ReportActionCompose/ComposerContext'; import ReportActionComposeUtils from '@pages/inbox/report/ReportActionCompose/ReportActionComposeUtils'; import useEditComposerToggle from '@pages/inbox/report/ReportActionCompose/useEditComposerToggle'; -import type {ReportActionEditMessageContextValue} from '@pages/inbox/report/ReportActionEditMessageContext'; -import * as ReportActionEditMessageContext from '@pages/inbox/report/ReportActionEditMessageContext'; jest.mock('@pages/inbox/report/ReportActionEditMessageContext', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -33,12 +33,10 @@ jest.mock('@libs/getPlatform', () => ({ default: () => 'web', })); -const mockUseReportActionActiveEdit = jest.mocked(ReportActionEditMessageContext.useReportActionActiveEdit); +const mockUseComposerEditState = jest.mocked(ComposerContext.useComposerEditState); const mockUseResponsiveLayout = useResponsiveLayout as jest.MockedFunction; const mockUpdateNativeSelectionValue = jest.mocked(ReportActionComposeUtils.updateNativeSelectionValue); -type ActiveEdit = ReportActionEditMessageContextValue & {editingState: 'off' | 'editing' | 'submitted'}; - function makeComposerRef(overrides?: Partial): RefObject { return { current: { @@ -52,14 +50,17 @@ function makeComposerRef(overrides?: Partial): RefObject): ActiveEdit { +function defaultActiveEdit(overrides?: Partial): ComposerEditState { return { + editingState: 'off', + isEditingInComposer: false, editingReportID: null, editingReportActionID: null, editingReportAction: null, editingMessage: null, currentEditMessageSelection: null, - editingState: 'off', + draftComment: undefined, + effectiveDraft: undefined, ...overrides, }; } @@ -102,7 +103,7 @@ describe('useEditComposerToggle', () => { beforeEach(() => { jest.clearAllMocks(); activeEditRef.current = defaultActiveEdit(); - mockUseReportActionActiveEdit.mockImplementation(() => activeEditRef.current); + mockUseComposerEditState.mockImplementation(() => activeEditRef.current); mockUseResponsiveLayout.mockReturnValue(narrowLayoutResult()); }); From 83c613d8803a677db6b6db69a353d18862301e4a Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 29 Apr 2026 12:23:36 +0100 Subject: [PATCH 208/233] refactor: move `didResetComposerHeight` reset call to `useEditComposerToggle` --- .../report/ReportActionCompose/ComposerProvider.tsx | 13 ++++--------- .../ReportActionCompose/useEditComposerToggle.ts | 11 +++++++++-- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx index 6d41cff7335c..bea30aaddee9 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useRef, useState} from 'react'; +import React, {useRef, useState} from 'react'; import type {View} from 'react-native'; import {scheduleOnUI} from 'react-native-worklets'; import useOnyx from '@hooks/useOnyx'; @@ -64,13 +64,6 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { const {editingState, editingReportID, editingReportActionID, editingReportAction, editingMessage, currentEditMessageSelection} = useReportActionActiveEdit(); const [didResetComposerHeight, setDidResetComposerHeight] = useState(false); - useEffect(() => { - if (editingState !== 'off' || !didResetComposerHeight) { - return; - } - - setDidResetComposerHeight(false); - }, [didResetComposerHeight, editingState]); const isEditingInComposer = shouldUseNarrowLayout && editingState !== 'off' && !didResetComposerHeight; const effectiveDraft = isEditingInComposer ? editingMessage : draftComment; @@ -124,11 +117,13 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { const composerEditState = { editingState, isEditingInComposer, + editingReportID, editingReportActionID, + editingReportAction, editingMessage, - editingState, draftComment, effectiveDraft, + currentEditMessageSelection, }; const composerSendState = { diff --git a/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts b/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts index d233c3285f46..3f3d9dac3bc7 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts @@ -5,7 +5,7 @@ import type {ComposerRef, TextSelection} from '@components/Composer/types'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import getPlatform from '@libs/getPlatform'; import CONST from '@src/CONST'; -import {useComposerEditState} from './ComposerContext'; +import {useComposerActions, useComposerEditState} from './ComposerContext'; import ReportActionComposeUtils from './ReportActionComposeUtils'; const isIOSNative = getPlatform() === CONST.PLATFORM.IOS; @@ -29,7 +29,8 @@ type UseEditComposerToggleProps = { function useEditComposerToggle({selection, draftComment, composerRef, onFocus, onValueChange, onSelectionChange}: UseEditComposerToggleProps) { const {shouldUseNarrowLayout} = useResponsiveLayout(); - const {editingState, editingReportActionID, editingMessage, currentEditMessageSelection} = useComposerEditState(); + const {isEditingInComposer, editingState, editingReportActionID, editingMessage, currentEditMessageSelection} = useComposerEditState(); + const {setDidResetComposerHeight} = useComposerActions(); const isEditing = editingState !== 'off'; const wasEditingRef = useRef(isEditing); @@ -83,6 +84,10 @@ function useEditComposerToggle({selection, draftComment, composerRef, onFocus, o // Editing just ended in the composer – restore the draft comment and its previous selection. applyComposerValue(draftComment ?? '', {selection: previousDraftSelectionRef.current, shouldForceNativeValueUpdate: true}); + if (isEditingInComposer) { + setDidResetComposerHeight(true); + } + if (!wasComposerFocusedBeforeEditingRef.current) { composerRef.current?.blur(); } @@ -141,7 +146,9 @@ function useEditComposerToggle({selection, draftComment, composerRef, onFocus, o editingMessage, editingReportActionID, editingState, + isEditingInComposer, selection, + setDidResetComposerHeight, shouldUseNarrowLayout, ]); } From 3421d13b50153109e35fd8f1a7801a6d22c8aab7 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 29 Apr 2026 13:05:02 +0100 Subject: [PATCH 209/233] refactor: remove `InteractionManager.runAfterInteractions` --- .../report/ReportActionCompose/ReportActionComposeUtils.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionComposeUtils.ts b/src/pages/inbox/report/ReportActionCompose/ReportActionComposeUtils.ts index a397e4bd94bd..dc89ef29d741 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionComposeUtils.ts +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionComposeUtils.ts @@ -1,4 +1,3 @@ -import {InteractionManager} from 'react-native'; import type {ComposerRef} from '@components/Composer/types'; import getPlatform from '@libs/getPlatform'; import CONST from '@src/CONST'; @@ -11,8 +10,7 @@ const updateNativeSelectionValue = (composerRef: React.RefObject { + requestIdleCallback(() => { // note: this implementation is only available on non-web RN, thus the wrapping // 'if' block contains a redundant (since the ref is only used on iOS) platform check composerRef.current?.setSelection(start, end); From 30f26fe469fa414384744397487d4c55f6a4c621 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 29 Apr 2026 13:05:26 +0100 Subject: [PATCH 210/233] refactor: remove manual memoization from `useDebounce` --- src/hooks/useDebounce.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts index a5438785295a..639f917d7abe 100644 --- a/src/hooks/useDebounce.ts +++ b/src/hooks/useDebounce.ts @@ -1,7 +1,7 @@ // eslint-disable-next-line lodash/import-scope import type {DebouncedFunc, DebounceSettings} from 'lodash'; import lodashDebounce from 'lodash/debounce'; -import {useCallback, useEffect, useRef} from 'react'; +import {useEffect, useRef} from 'react'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type GenericFunction = (...args: any[]) => void; @@ -34,13 +34,13 @@ export default function useDebounce(func: T, wait: nu }; }, [func, wait, leading, maxWait, trailing]); - const debounceCallback = useCallback((...args: Parameters) => { + const debounceCallback = (...args: Parameters) => { const debouncedFn = debouncedFnRef.current; if (debouncedFn) { debouncedFn(...args); } - }, []); + }; return debounceCallback as T; } From 6dede483bda17b3dde38b4e113bc89d0a13e40fb Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 29 Apr 2026 13:07:40 +0100 Subject: [PATCH 211/233] fix: callback memoization not able to get preserved (RC) --- .../report/ReportActionCompose/ComposerWithSuggestions.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index f13506e1076e..3476af206c73 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -485,7 +485,7 @@ function ComposerWithSuggestions({ // previous text before change const prevText = lastTextRef.current; // snapshot selection (should be the selection that was active just before the paste/change) - const prevSelectionStart = selection?.start ?? 0; + const prevSelectionStart = selection.start ?? 0; const prevSelectionEnd = selection?.end ?? 0; // detect newly added text (existing helper) @@ -578,7 +578,7 @@ function ComposerWithSuggestions({ suggestionsRef, raiseIsScrollLikelyLayoutTriggered, selection.end, - selection?.start, + selection.start, setCurrentEditMessageSelection, setEditingMessage, shouldUseNarrowLayout, From 1eb90fd5b3115910438d9e509212c55b6dfefd1f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 29 Apr 2026 13:13:50 +0100 Subject: [PATCH 212/233] refactor: extract debounced save functions to separate hook --- .../ComposerWithSuggestions.tsx | 64 +++++-------------- .../report/ReportActionItemMessageEdit.tsx | 37 +++-------- .../inbox/report/useDebouncedSaveDraft.ts | 38 +++++++++++ 3 files changed, 63 insertions(+), 76 deletions(-) create mode 100644 src/pages/inbox/report/useDebouncedSaveDraft.ts diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 3476af206c73..d3a3ba9c14b7 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -1,7 +1,7 @@ import {useIsFocused, useNavigation, useRoute} from '@react-navigation/native'; import lodashDebounce from 'lodash/debounce'; import type {Ref, RefObject} from 'react'; -import React, {memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import React, {memo, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; import type { BlurEvent, LayoutChangeEvent, @@ -49,6 +49,7 @@ import {isValidReportIDFromPath, shouldAutoFocusOnKeyPress} from '@libs/ReportUt import updateMultilineInputRange from '@libs/updateMultilineInputRange'; import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; import {useReportActionActiveEditActions} from '@pages/inbox/report/ReportActionEditMessageContext'; +import useDebouncedSaveDraft from '@pages/inbox/report/useDebouncedSaveDraft'; import useDraftMessageVideoAttributeCache from '@pages/inbox/report/useDraftMessageVideoAttributeCache'; import {isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; import type {OnEmojiSelected} from '@userActions/EmojiPickerAction'; @@ -275,15 +276,19 @@ function ComposerWithSuggestions({ return initialValue; }); - // The ref to check whether the comment saving is in progress - const isDraftPendingSaved = useRef(false); + // Save the draft of the report action. This debounced so that we're not ceaselessly saving your edit. + const {saveDraft: debouncedSaveReportActionDraft, isSavePending: isDraftSavePending} = useDebouncedSaveDraft(saveReportActionDraft); + + // Save the draft of the report comment. This debounced so that we're not ceaselessly saving your edit. Saving the draft + // allows one to navigate somewhere else and come back to the comment and still have it in edit mode. + const {saveDraft: debouncedSaveComment, isSavePending: isCommentSavePending} = useDebouncedSaveDraft(saveReportDraftComment); useDraftMessageVideoAttributeCache({ draftMessage: value, isEditing, editingReportAction, updateDraftMessage: setValue, - isEditInProgressRef: isDraftPendingSaved, + isEditInProgressRef: isDraftSavePending, }); const [selection, setSelection] = useState(() => currentEditMessageSelection ?? {start: value.length, end: value.length}); @@ -383,40 +388,6 @@ function ComposerWithSuggestions({ RNTextInputReset.resetKeyboardInput(CONST.COMPOSER.NATIVE_ID); }, []); - /** - * Save the draft of the comment. This debounced so that we're not ceaselessly saving your edit. Saving the draft - * allows one to navigate somewhere else and come back to the comment and still have it in edit mode. - * @param {String} newDraft - */ - const debouncedSaveDraft = useMemo( - () => - lodashDebounce((newDraft: string) => { - saveReportActionDraft(reportID, editingReportAction, newDraft); - isDraftPendingSaved.current = false; - }, 1000), - [reportID, editingReportAction], - ); - - useEffect( - () => () => { - debouncedSaveDraft.cancel(); - isDraftPendingSaved.current = false; - }, - [debouncedSaveDraft], - ); - - // The ref to check whether the comment saving is in progress - const isCommentPendingSaved = useRef(false); - - const debouncedSaveReportComment = useMemo( - () => - lodashDebounce((selectedReportID: string, newComment: string | null) => { - saveReportDraftComment(selectedReportID, newComment); - isCommentPendingSaved.current = false; - }, 1000), - [], - ); - useEffect(() => { const switchToCurrentReport = DeviceEventEmitter.addListener(`switchToPreExistingReport_${reportID}`, ({reportToCopyDraftTo, callback}: SwitchToCurrentReportProps) => { if (!commentRef.current) { @@ -545,8 +516,7 @@ function ComposerWithSuggestions({ if (editingState === 'editing' && shouldUseNarrowLayout) { setEditingMessage(newCommentConverted); if (shouldDebounceSaveComment) { - isDraftPendingSaved.current = true; - debouncedSaveDraft(newCommentConverted); + debouncedSaveReportActionDraft(reportID, editingReportAction, newCommentConverted); return; } @@ -555,8 +525,7 @@ function ComposerWithSuggestions({ } if (shouldDebounceSaveComment) { - isCommentPendingSaved.current = true; - debouncedSaveReportComment(reportID, newCommentConverted); + debouncedSaveComment(reportID, newCommentConverted); } else { saveReportDraftComment(reportID, newCommentConverted); } @@ -567,21 +536,22 @@ function ComposerWithSuggestions({ }, [ currentUserAccountID, - debouncedSaveDraft, - debouncedSaveReportComment, + editingReportAction, editingReportActionID, editingState, findNewlyAddedChars, preferredLocale, preferredSkinTone, - reportID, - suggestionsRef, raiseIsScrollLikelyLayoutTriggered, + reportID, selection.end, selection.start, setCurrentEditMessageSelection, setEditingMessage, shouldUseNarrowLayout, + suggestionsRef, + debouncedSaveReportActionDraft, + debouncedSaveComment, ], ); @@ -1123,7 +1093,7 @@ function ComposerWithSuggestions({ value={value} updateComment={updateComment} commentRef={commentRef} - isCommentPendingSaved={isCommentPendingSaved} + isCommentPendingSaved={isCommentSavePending} isTransitioningToPreExistingReport={isTransitioningToPreExistingReport} onTransitionToPreExistingReportComplete={handleTransitionToPreExistingReportComplete} /> diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index 8399a5342c68..ba5597a893d3 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -1,4 +1,3 @@ -import lodashDebounce from 'lodash/debounce'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; // eslint-disable-next-line no-restricted-imports import {InteractionManager, View} from 'react-native'; @@ -47,6 +46,7 @@ import useDebouncedCommentMaxLengthValidation from './ReportActionCompose/useDeb import useEditMessage from './ReportActionCompose/useEditMessage'; import {useReportActionActiveEdit, useReportActionActiveEditActions} from './ReportActionEditMessageContext'; import shouldUseEmojiPickerSelection from './shouldUseEmojiPickerSelection'; +import useDebouncedSaveDraft from './useDebouncedSaveDraft'; import useDraftMessageVideoAttributeCache from './useDraftMessageVideoAttributeCache'; type ReportActionItemMessageEditProps = { @@ -137,15 +137,17 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy const composerRef = useRef(null); const draftRef = useRef(draft); const emojiPickerSelectionRef = useRef(undefined); - // The ref to check whether the comment saving is in progress - const isCommentPendingSaved = useRef(false); + + // Save the draft of the comment. This debounced so that we're not ceaselessly saving your edit. Saving the draft + // allows one to navigate somewhere else and come back to the comment and still have it in edit mode. + const {saveDraft, isSavePending: isDraftSavePending} = useDebouncedSaveDraft(saveReportActionDraft); useDraftMessageVideoAttributeCache({ draftMessage: editingMessage ?? '', isEditing: true, editingReportAction: action, updateDraftMessage: setDraft, - isEditInProgressRef: isCommentPendingSaved, + isEditInProgressRef: isDraftSavePending, }); useEffect(() => { @@ -184,28 +186,6 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy }, true); }, [focus]); - /** - * Save the draft of the comment. This debounced so that we're not ceaselessly saving your edit. Saving the draft - * allows one to navigate somewhere else and come back to the comment and still have it in edit mode. - * @param {String} newDraft - */ - const debouncedSaveDraft = useMemo( - () => - lodashDebounce((newDraft: string) => { - saveReportActionDraft(reportID, action, newDraft); - isCommentPendingSaved.current = false; - }, 1000), - [reportID, action], - ); - - useEffect( - () => () => { - debouncedSaveDraft.cancel(); - isCommentPendingSaved.current = false; - }, - [debouncedSaveDraft], - ); - /** * Update the value of the draft in Onyx * @@ -238,10 +218,9 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy setEditingMessage(newDraft); // We want to escape the draft message to differentiate the HTML from the report action and the HTML the user drafted. - debouncedSaveDraft(newDraft); - isCommentPendingSaved.current = true; + saveDraft(reportID, action, newDraft); }, - [debouncedSaveDraft, preferredLocale, preferredSkinTone, raiseIsScrollLayoutTriggered, selection?.end, setEditingMessage, setSelection], + [action, preferredLocale, preferredSkinTone, raiseIsScrollLayoutTriggered, reportID, selection.end, setEditingMessage, setSelection, saveDraft], ); useEffect(() => { diff --git a/src/pages/inbox/report/useDebouncedSaveDraft.ts b/src/pages/inbox/report/useDebouncedSaveDraft.ts new file mode 100644 index 000000000000..f3c663743dcf --- /dev/null +++ b/src/pages/inbox/report/useDebouncedSaveDraft.ts @@ -0,0 +1,38 @@ +import {useEffect, useRef} from 'react'; +import useDebounce from '@hooks/useDebounce'; + +const DEFAULT_DEBOUNCE_DELAY = 1000; + +/** + * Debounces a function to save a draft for a report comment or report action draft. + * @param saveDraft - The function to save the draft. It will be called with the arguments passed to the triggerSaveDraft function. + * @returns An object containing the debounced save draft function, the trigger save draft function, and the is save pending ref. + * @property {Function} debouncedSaveDraft - The debounced save draft function. + * @property {Function} triggerSaveDraft - The trigger save draft function. + * @property {Ref} isSavePending - The ref to check whether the save is pending. + */ +function useDebouncedSaveDraft(saveDraftFn: (...args: SaveDraftArgs) => void, wait = DEFAULT_DEBOUNCE_DELAY) { + const isSavePending = useRef(false); + + const debouncedSaveDraft = useDebounce(saveDraftFn, wait); + + const saveDraft = (...args: SaveDraftArgs) => { + isSavePending.current = true; + debouncedSaveDraft(...args); + }; + + // Cancel the debounced save draft on unmount + useEffect( + () => () => { + isSavePending.current = false; + }, + [debouncedSaveDraft], + ); + + return { + saveDraft, + isSavePending, + }; +} + +export default useDebouncedSaveDraft; From 0e13386b77cfadf8a79899660543528bc6495f80 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 29 Apr 2026 13:31:07 +0100 Subject: [PATCH 213/233] fix: keep `editingReportActionID` as a dependency of debounced saveDraft function --- .../ComposerWithSuggestions.tsx | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index d3a3ba9c14b7..fb7eef64946b 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -277,11 +277,25 @@ function ComposerWithSuggestions({ }); // Save the draft of the report action. This debounced so that we're not ceaselessly saving your edit. - const {saveDraft: debouncedSaveReportActionDraft, isSavePending: isDraftSavePending} = useDebouncedSaveDraft(saveReportActionDraft); + const {saveDraft: debouncedSaveReportActionDraft, isSavePending: isDraftSavePending} = useDebouncedSaveDraft( + useCallback( + (comment: string) => { + saveReportActionDraft(reportID, editingReportAction, comment); + }, + [reportID, editingReportAction], + ), + ); // Save the draft of the report comment. This debounced so that we're not ceaselessly saving your edit. Saving the draft // allows one to navigate somewhere else and come back to the comment and still have it in edit mode. - const {saveDraft: debouncedSaveComment, isSavePending: isCommentSavePending} = useDebouncedSaveDraft(saveReportDraftComment); + const {saveDraft: debouncedSaveComment, isSavePending: isCommentSavePending} = useDebouncedSaveDraft( + useCallback( + (comment: string) => { + saveReportDraftComment(reportID, comment); + }, + [reportID], + ), + ); useDraftMessageVideoAttributeCache({ draftMessage: value, @@ -516,7 +530,7 @@ function ComposerWithSuggestions({ if (editingState === 'editing' && shouldUseNarrowLayout) { setEditingMessage(newCommentConverted); if (shouldDebounceSaveComment) { - debouncedSaveReportActionDraft(reportID, editingReportAction, newCommentConverted); + debouncedSaveReportActionDraft(newCommentConverted); return; } @@ -525,7 +539,7 @@ function ComposerWithSuggestions({ } if (shouldDebounceSaveComment) { - debouncedSaveComment(reportID, newCommentConverted); + debouncedSaveComment(newCommentConverted); } else { saveReportDraftComment(reportID, newCommentConverted); } @@ -536,7 +550,6 @@ function ComposerWithSuggestions({ }, [ currentUserAccountID, - editingReportAction, editingReportActionID, editingState, findNewlyAddedChars, From 5536df02a17790a2939dee2c92fe4124b67ebc44 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 29 Apr 2026 17:31:08 +0100 Subject: [PATCH 214/233] test: fix invalid mock --- tests/unit/hooks/useEditComposerToggleTest.ts | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/tests/unit/hooks/useEditComposerToggleTest.ts b/tests/unit/hooks/useEditComposerToggleTest.ts index 8da04140c803..f530eea7e11c 100644 --- a/tests/unit/hooks/useEditComposerToggleTest.ts +++ b/tests/unit/hooks/useEditComposerToggleTest.ts @@ -9,13 +9,13 @@ import type {ComposerEditState} from '@pages/inbox/report/ReportActionCompose/Co import ReportActionComposeUtils from '@pages/inbox/report/ReportActionCompose/ReportActionComposeUtils'; import useEditComposerToggle from '@pages/inbox/report/ReportActionCompose/useEditComposerToggle'; -jest.mock('@pages/inbox/report/ReportActionEditMessageContext', () => { +jest.mock('@pages/inbox/report/ReportActionCompose/ComposerContext', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const actual = jest.requireActual('@pages/inbox/report/ReportActionEditMessageContext'); + const actual = jest.requireActual('@pages/inbox/report/ReportActionCompose/ComposerContext'); // eslint-disable-next-line @typescript-eslint/no-unsafe-return return { ...actual, - useReportActionActiveEdit: jest.fn(), + useComposerEditState: jest.fn(), }; }); @@ -50,7 +50,7 @@ function makeComposerRef(overrides?: Partial): RefObject): ComposerEditState { +function defaultComposerEditState(overrides?: Partial): ComposerEditState { return { editingState: 'off', isEditingInComposer: false, @@ -98,19 +98,19 @@ function narrowLayoutResult(): ResponsiveLayoutResult { } describe('useEditComposerToggle', () => { - const activeEditRef = {current: defaultActiveEdit()}; + const composerEditStateRef = {current: defaultComposerEditState()}; beforeEach(() => { jest.clearAllMocks(); - activeEditRef.current = defaultActiveEdit(); - mockUseComposerEditState.mockImplementation(() => activeEditRef.current); + composerEditStateRef.current = defaultComposerEditState(); + mockUseComposerEditState.mockImplementation(() => composerEditStateRef.current); mockUseResponsiveLayout.mockReturnValue(narrowLayoutResult()); }); it('does not run apply logic while editingState is submitted', () => { const onValueChange = jest.fn(); const composerRef = makeComposerRef(); - activeEditRef.current = defaultActiveEdit({editingState: 'submitted', editingMessage: 'hello'}); + composerEditStateRef.current = defaultComposerEditState({editingState: 'submitted', editingMessage: 'hello'}); renderHook(() => useEditComposerToggle({ @@ -144,7 +144,7 @@ describe('useEditComposerToggle', () => { {initialProps: {selection: priorSelection, draft: 'keep my draft'}}, ); - activeEditRef.current = defaultActiveEdit({ + composerEditStateRef.current = defaultComposerEditState({ editingState: 'editing', editingMessage: 'edited body', editingReportActionID: '100', @@ -173,7 +173,7 @@ describe('useEditComposerToggle', () => { }), ); - activeEditRef.current = defaultActiveEdit({ + composerEditStateRef.current = defaultComposerEditState({ editingState: 'editing', editingMessage: 'from thread', }); @@ -191,7 +191,7 @@ describe('useEditComposerToggle', () => { // Start with edit off so wasEditingRef is false; then turn editing on to capture previousDraftSelectionRef. const {rerender} = renderHook( (props: {selection: TextSelection; draft: string; editing: boolean}) => { - activeEditRef.current = defaultActiveEdit(props.editing ? {editingState: 'editing', editingMessage: 'e', editingReportActionID: '1'} : {editingState: 'off'}); + composerEditStateRef.current = defaultComposerEditState(props.editing ? {editingState: 'editing', editingMessage: 'e', editingReportActionID: '1'} : {editingState: 'off'}); return useEditComposerToggle({ selection: props.selection, draftComment: props.draft, @@ -220,7 +220,7 @@ describe('useEditComposerToggle', () => { const onFocus = jest.fn(); const composerRef = makeComposerRef(); - activeEditRef.current = defaultActiveEdit({ + composerEditStateRef.current = defaultComposerEditState({ editingState: 'editing', editingMessage: 'first', editingReportActionID: 'a', @@ -228,7 +228,7 @@ describe('useEditComposerToggle', () => { const {rerender} = renderHook( (id: string) => { - activeEditRef.current = defaultActiveEdit({ + composerEditStateRef.current = defaultComposerEditState({ editingState: 'editing', editingMessage: id === 'a' ? 'first' : 'second', editingReportActionID: id, @@ -260,7 +260,7 @@ describe('useEditComposerToggle', () => { const onFocus = jest.fn(); const composerRef = makeComposerRef(); - activeEditRef.current = defaultActiveEdit({ + composerEditStateRef.current = defaultComposerEditState({ editingState: 'editing', editingMessage: 'wide first', }); @@ -288,7 +288,7 @@ describe('useEditComposerToggle', () => { const onValueChange = jest.fn(); const composerRef = makeComposerRef(); - activeEditRef.current = defaultActiveEdit({ + composerEditStateRef.current = defaultComposerEditState({ editingState: 'editing', editingMessage: 'editing in narrow', }); @@ -320,7 +320,7 @@ describe('useEditComposerToggle', () => { const {rerender} = renderHook( (editing: boolean) => { - activeEditRef.current = defaultActiveEdit(editing ? {editingState: 'editing', editingMessage: 'hi', editingReportActionID: '1'} : {editingState: 'off'}); + composerEditStateRef.current = defaultComposerEditState(editing ? {editingState: 'editing', editingMessage: 'hi', editingReportActionID: '1'} : {editingState: 'off'}); return useEditComposerToggle({ selection: {start: 0, end: 0}, draftComment: 'd', From a2475f338cf235e3da0a16efb3a0b1b63a22015d Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 29 Apr 2026 17:49:12 +0100 Subject: [PATCH 215/233] refactor: draftMessage usage --- .../report/ReportActionCompose/ComposerActionMenu.tsx | 3 +-- .../inbox/report/ReportActionCompose/ComposerContext.ts | 4 ++-- .../report/ReportActionCompose/ComposerProvider.tsx | 2 +- .../ReportActionCompose/ComposerWithSuggestions.tsx | 9 ++++----- src/pages/inbox/report/ReportActionItemMessageEdit.tsx | 2 ++ 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerActionMenu.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerActionMenu.tsx index 08af19646a21..1fe0bd566459 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerActionMenu.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerActionMenu.tsx @@ -17,14 +17,13 @@ type ComposerActionMenuProps = { function ComposerActionMenu({reportID}: ComposerActionMenuProps) { const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const {isMenuVisible, isFullComposerAvailable} = useComposerState(); + const {isMenuVisible, isFullComposerAvailable, draftComment} = useComposerState(); const {exceededMaxLength} = useComposerSendState(); const {setMenuVisibility, onAddActionPressed, onItemSelected, onTriggerAttachmentPicker} = useComposerActions(); const {actionButtonRef, composerRef} = useComposerMeta(); const {pickAttachments, PDFValidationComponent, ErrorModal} = useAttachmentPicker(reportID); const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); - const [draftComment] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`); const {raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts index d6bebf43a981..7768e03f7572 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts +++ b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts @@ -28,6 +28,7 @@ type ComposerState = { isMenuVisible: boolean; isFullComposerAvailable: boolean; didResetComposerHeight: boolean; + draftComment: string | undefined; }; type ComposerEditState = { @@ -37,7 +38,6 @@ type ComposerEditState = { editingReportActionID: string | null; editingReportAction: ReportAction | null; editingMessage: string | null; - draftComment: string | null | undefined; effectiveDraft: string | null | undefined; currentEditMessageSelection: TextSelection | null; }; @@ -91,6 +91,7 @@ const defaultState: ComposerState = { isMenuVisible: false, isFullComposerAvailable: false, didResetComposerHeight: false, + draftComment: undefined, }; const ComposerStateContext = createContext(defaultState); @@ -110,7 +111,6 @@ const defaultEditState: ComposerEditState = { editingReportActionID: null, editingReportAction: null, editingMessage: null, - draftComment: undefined, effectiveDraft: undefined, currentEditMessageSelection: null, }; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx index bea30aaddee9..23590d162d0c 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx @@ -112,6 +112,7 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { isMenuVisible, isFullComposerAvailable, didResetComposerHeight, + draftComment, }; const composerEditState = { @@ -121,7 +122,6 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { editingReportActionID, editingReportAction, editingMessage, - draftComment, effectiveDraft, currentEditMessageSelection, }; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index fb7eef64946b..389c8019db0c 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -64,7 +64,7 @@ import type {FileObject} from '@src/types/utils/Attachment'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; // eslint-disable-next-line no-restricted-imports import findNodeHandle from '@src/utils/findNodeHandle'; -import {useComposerEditState} from './ComposerContext'; +import {useComposerEditState, useComposerState} from './ComposerContext'; import getCursorPosition from './getCursorPosition'; import getScrollPosition from './getScrollPosition'; import type {SuggestionsRef} from './ReportActionCompose'; @@ -257,18 +257,17 @@ function ComposerWithSuggestions({ const cursorPositionValue = useSharedValue({x: 0, y: 0}); const tag = useSharedValue(-1); const isInSidePanel = useIsInSidePanel(); - const [draftComment = ''] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`); const {shouldUseNarrowLayout} = useResponsiveLayout(); const composerRef = useRef(null); - const {editingState, editingReportActionID, editingReportAction, editingMessage, currentEditMessageSelection} = useComposerEditState(); + const {draftComment = ''} = useComposerState(); + const {editingState, editingReportActionID, editingReportAction, effectiveDraft, currentEditMessageSelection} = useComposerEditState(); const {setEditingMessage, setCurrentEditMessageSelection} = useReportActionActiveEditActions(); const isEditing = editingState !== 'off'; - const isEditingInComposer = shouldUseNarrowLayout && isEditing; const [value, setValue] = useState(() => { - const initialValue = isEditingInComposer ? (editingMessage ?? draftComment) : draftComment; + const initialValue = effectiveDraft ?? draftComment; if (initialValue) { emojisPresentBefore.current = extractEmojis(initialValue); diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index ba5597a893d3..9fcbc935ed88 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -118,6 +118,8 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy ); useEffect(() => { + // When the current edit message selection changes, we need to update the selection state + // eslint-disable-next-line react-hooks/set-state-in-effect setSelectionState(currentEditMessageSelection ?? defaultSelection); }, [currentEditMessageSelection, defaultSelection, draft.length, setSelection]); From a82b009be13cc6a005035dffc54be6a48c926c37 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 29 Apr 2026 17:49:25 +0100 Subject: [PATCH 216/233] chore: fix `eslint-seatbelt` complaining about moved file --- config/eslint/eslint.seatbelt.tsv | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index bbf0c7e8c27a..89c68d0969d4 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -379,8 +379,8 @@ "../../src/pages/inbox/report/PureReportActionItem.tsx" "react-hooks/refs" 2 "../../src/pages/inbox/report/PureReportActionItem.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/inbox/report/ReactionList/HeaderReactionList.tsx" "no-restricted-syntax" 1 -"../../src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx" "react-hooks/preserve-manual-memoization" 2 -"../../src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx" "react-hooks/refs" 8 +"../../src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx" "react-hooks/preserve-manual-memoization" 2 +"../../src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx" "react-hooks/refs" 8 "../../src/pages/inbox/report/ReportActionCompose/SuggestionEmoji.tsx" "react-hooks/refs" 1 "../../src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx" "react-hooks/refs" 3 "../../src/pages/inbox/report/ReportActionItemMessageEdit.tsx" "no-restricted-syntax" 1 From 3a5e5fe87c0fc5e163992f57a8c76b504fde2415 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 29 Apr 2026 22:17:53 +0100 Subject: [PATCH 217/233] fix: update draftComment composer hook --- .../inbox/report/ReportActionCompose/useComposerSubmit.ts | 4 ++-- tests/unit/hooks/useEditComposerToggleTest.ts | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts index cc55a5c9a5f1..c08bb3d191da 100644 --- a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts +++ b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts @@ -45,10 +45,10 @@ function useComposerSubmit(reportID: string): ComposerSubmitFunctions { const delegateAccountID = useDelegateAccountID(); const {composerRef, attachmentFileRef} = useComposerMeta(); - const {didResetComposerHeight} = useComposerState(); + const {didResetComposerHeight, draftComment} = useComposerState(); const {setDidResetComposerHeight, clearComposer} = useComposerActions(); const {isSendDisabled, debouncedCommentMaxLengthValidation} = useComposerSendState(); - const {isEditingInComposer, editingMessage, draftComment, effectiveDraft} = useComposerEditState(); + const {isEditingInComposer, editingMessage, effectiveDraft} = useComposerEditState(); const {publishDraft} = useComposerEditActions(); const {scrollOffsetRef} = useContext(ActionListContext); diff --git a/tests/unit/hooks/useEditComposerToggleTest.ts b/tests/unit/hooks/useEditComposerToggleTest.ts index f530eea7e11c..76c808c2d158 100644 --- a/tests/unit/hooks/useEditComposerToggleTest.ts +++ b/tests/unit/hooks/useEditComposerToggleTest.ts @@ -59,7 +59,6 @@ function defaultComposerEditState(overrides?: Partial): Compo editingReportAction: null, editingMessage: null, currentEditMessageSelection: null, - draftComment: undefined, effectiveDraft: undefined, ...overrides, }; From 2b422eb8351eba806710ebcff07c7ed30ba899d0 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 29 Apr 2026 22:18:46 +0100 Subject: [PATCH 218/233] fix: remove unused `useOnyx` call --- src/pages/inbox/report/ReportActionsList.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index a25bd3507e33..38d3aba883aa 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -191,7 +191,6 @@ function ReportActionsList({ const isFocused = useIsFocused(); const isReportArchived = useReportIsArchived(report?.reportID); - const [] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`); const [userBillingFundID] = useOnyx(ONYXKEYS.NVP_BILLING_FUND_ID); const [tryNewDot] = useOnyx(ONYXKEYS.NVP_TRY_NEW_DOT); const isTryNewDotNVPDismissed = !!tryNewDot?.classicRedirect?.dismissed; From 8c8bfa1a2b983f6114c53cca5d841f866180c8d2 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 5 May 2026 10:51:54 +0100 Subject: [PATCH 219/233] fix: lint errors --- src/pages/inbox/report/PureReportActionItem.tsx | 1 - src/pages/inbox/report/ReportActionsList.tsx | 3 --- tests/ui/ReportActionComposeTest.tsx | 1 - tests/ui/ReportActionMessageEditLayoutTest.tsx | 1 - tests/unit/hooks/useEditComposerToggleTest.ts | 1 - tests/unit/hooks/useEditMessage.test.ts | 4 ---- 6 files changed, 11 deletions(-) diff --git a/src/pages/inbox/report/PureReportActionItem.tsx b/src/pages/inbox/report/PureReportActionItem.tsx index 311394089072..44326c4dc91d 100644 --- a/src/pages/inbox/report/PureReportActionItem.tsx +++ b/src/pages/inbox/report/PureReportActionItem.tsx @@ -9,7 +9,6 @@ import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import * as ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; import type {ComposerRef} from '@components/Composer/types'; -import DisplayNames from '@components/DisplayNames'; import Hoverable from '@components/Hoverable'; import InlineSystemMessage from '@components/InlineSystemMessage'; import {ModalActions} from '@components/Modal/Global/ModalContext'; diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 574f1b90cdc8..57d1adf540db 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -50,7 +50,6 @@ import { canShowReportRecipientLocalTime, canUserPerformWriteAction, chatIncludesChronosWithID, - getOriginalReportID, getReportLastVisibleActionCreated, isArchivedNonExpenseReport, isCanceledTaskReport, @@ -791,7 +790,6 @@ function ReportActionsList({ report, showHiddenHistory, hasPreviousMessages, - translate, onShowPreviousMessages, parentReportAction, parentReportActionForTransactionThread, @@ -810,7 +808,6 @@ function ReportActionsList({ isTryNewDotNVPDismissed, reportNameValuePairs?.origin, reportNameValuePairs?.originalID, - styles, showHiddenHistory, hasPreviousMessages, onShowPreviousMessages, diff --git a/tests/ui/ReportActionComposeTest.tsx b/tests/ui/ReportActionComposeTest.tsx index b4d1aa3cacd0..0863baf49d2b 100644 --- a/tests/ui/ReportActionComposeTest.tsx +++ b/tests/ui/ReportActionComposeTest.tsx @@ -68,7 +68,6 @@ function ReportScreenProviders({children}: PropsWithChildren) { } const renderReportActionCompose = (props?: Partial) => { - // eslint-disable-next-line react/jsx-props-no-spreading return render( { }); jest.mock('@hooks/useAncestors', () => ({ - // eslint-disable-next-line @typescript-eslint/naming-convention __esModule: true, default: () => [], })); jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ - // eslint-disable-next-line @typescript-eslint/naming-convention __esModule: true, default: () => ({email: 'user@test.com'}), })); jest.mock('@hooks/useReportIsArchived', () => ({ - // eslint-disable-next-line @typescript-eslint/naming-convention __esModule: true, default: () => false, })); jest.mock('@hooks/useReportScrollManager', () => ({ - // eslint-disable-next-line @typescript-eslint/naming-convention __esModule: true, default: () => ({scrollToIndex: jest.fn()}), })); From b3f9ed4f73a1d9466fceb12276b8f884c530ad46 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 7 May 2026 12:50:28 +0100 Subject: [PATCH 220/233] fix: ESLint errors --- src/pages/inbox/report/ReportActionsList.tsx | 29 +++++++++----------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 82fc470e0ef2..578896e966eb 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -782,30 +782,27 @@ function ReportActionsList({ ); }, [ - report, - showHiddenHistory, + actionIndexMap, + firstVisibleReportActionID, hasPreviousMessages, + isOffline, + isReportArchived, + isTryNewDotNVPDismissed, + linkedReportActionID, onShowPreviousMessages, parentReportAction, parentReportActionForTransactionThread, - isOffline, - transactionThreadReport, - linkedReportActionID, - actionIndexMap, - renderedVisibleReportActions, - shouldHideThreadDividerLine, - unreadMarkerReportActionID, - firstVisibleReportActionID, - shouldUseThreadDividerLine, personalDetailsList, - isReportArchived, - userBillingFundID, - isTryNewDotNVPDismissed, + renderedVisibleReportActions, + report, reportNameValuePairs?.origin, reportNameValuePairs?.originalID, + shouldHideThreadDividerLine, + shouldUseThreadDividerLine, showHiddenHistory, - hasPreviousMessages, - onShowPreviousMessages, + transactionThreadReport, + unreadMarkerReportActionID, + userBillingFundID, ], ); From 3d7dead03a0e4f784febdc656b356c37bf1fc25f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 7 May 2026 13:38:24 +0100 Subject: [PATCH 221/233] refactor: `React.RefObject` type import --- .../report/ReportActionCompose/ReportActionComposeUtils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionComposeUtils.ts b/src/pages/inbox/report/ReportActionCompose/ReportActionComposeUtils.ts index dc89ef29d741..10faf40d535f 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionComposeUtils.ts +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionComposeUtils.ts @@ -1,10 +1,11 @@ +import type {RefObject} from 'react'; import type {ComposerRef} from '@components/Composer/types'; import getPlatform from '@libs/getPlatform'; import CONST from '@src/CONST'; const isIOSNative = getPlatform() === CONST.PLATFORM.IOS; -const updateNativeSelectionValue = (composerRef: React.RefObject, start: number, end: number) => { +const updateNativeSelectionValue = (composerRef: RefObject, start: number, end: number) => { if (!isIOSNative) { return; } From 3899406910b351d184a354a298e958b5ea46fed3 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 7 May 2026 13:38:39 +0100 Subject: [PATCH 222/233] fix: reset debounced safe draft ref --- src/pages/inbox/report/useDebouncedSaveDraft.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pages/inbox/report/useDebouncedSaveDraft.ts b/src/pages/inbox/report/useDebouncedSaveDraft.ts index f3c663743dcf..12870247cf15 100644 --- a/src/pages/inbox/report/useDebouncedSaveDraft.ts +++ b/src/pages/inbox/report/useDebouncedSaveDraft.ts @@ -14,7 +14,10 @@ const DEFAULT_DEBOUNCE_DELAY = 1000; function useDebouncedSaveDraft(saveDraftFn: (...args: SaveDraftArgs) => void, wait = DEFAULT_DEBOUNCE_DELAY) { const isSavePending = useRef(false); - const debouncedSaveDraft = useDebounce(saveDraftFn, wait); + const debouncedSaveDraft = useDebounce((...args: SaveDraftArgs) => { + saveDraftFn(...args); + isSavePending.current = false; + }, wait); const saveDraft = (...args: SaveDraftArgs) => { isSavePending.current = true; From ec7671feebd8a49c4633df5ace079eb4d1e2d181 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 7 May 2026 13:38:55 +0100 Subject: [PATCH 223/233] fix: also clear report action drafts when report changes --- src/pages/inbox/ReportScreen.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/inbox/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx index 5b3b3a6c40cc..d21a791820f2 100644 --- a/src/pages/inbox/ReportScreen.tsx +++ b/src/pages/inbox/ReportScreen.tsx @@ -57,14 +57,14 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const isTopMostReportId = currentReportIDValue === reportIDFromRoute; const screenWrapperStyle: ViewStyle[] = [styles.appContent, styles.flex1, {marginTop: viewportOffsetTop}]; - // When the report screen is navigated away from, clear all report action edit drafts + // When the report screen is navigated away from or the report changes, clear all report action edit drafts useEffect(() => { clearAllReportActionDrafts(); return () => { clearAllReportActionDrafts(); }; - }, []); + }, [reportIDFromRoute]); const shouldDeferNonEssentials = useDeferNonEssentials(reportIDFromRoute); From 76b3dc552e3219b3cf1af44737422c8db62338a0 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 7 May 2026 13:39:26 +0100 Subject: [PATCH 224/233] fix: remove unused eslint-disable directive --- .../inbox/report/ReportActionCompose/useEditComposerToggle.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts b/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts index 3f3d9dac3bc7..4b07fdff7abe 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts @@ -1,4 +1,3 @@ -/* eslint-disable no-param-reassign */ import {useCallback, useEffect, useRef} from 'react'; import type {RefObject} from 'react'; import type {ComposerRef, TextSelection} from '@components/Composer/types'; From c32aa0fb4cd122e738a1479574837195df526dd5 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 7 May 2026 13:56:03 +0100 Subject: [PATCH 225/233] refactor: split `SendOrSaveButton` into compositional component usages --- .../ComposerSendButton.tsx | 13 ++++++-- ...OrSaveButton.tsx => SubmitDraftButton.tsx} | 31 ++++++++----------- .../report/ReportActionItemMessageEdit.tsx | 12 ++++--- 3 files changed, 31 insertions(+), 25 deletions(-) rename src/pages/inbox/report/ReportActionCompose/{SendOrSaveButton.tsx => SubmitDraftButton.tsx} (53%) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerSendButton.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerSendButton.tsx index adf38f7b1868..ed16804decac 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerSendButton.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerSendButton.tsx @@ -1,17 +1,19 @@ import React from 'react'; import {View} from 'react-native'; import {Gesture, GestureDetector} from 'react-native-gesture-handler'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import {useComposerEditState, useComposerSendState} from './ComposerContext'; -import SendOrSaveButton from './SendOrSaveButton'; +import SubmitDraftButton from './SubmitDraftButton'; import useComposerSubmit from './useComposerSubmit'; function ComposerSendButton({reportID}: {reportID: string}) { const styles = useThemeStyles(); const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Send']); const {isEditingInComposer} = useComposerEditState(); const {isSendDisabled} = useComposerSendState(); @@ -48,9 +50,14 @@ function ComposerSendButton({reportID}: {reportID: string}) { accessibilityLabel={accessibilityLabel} collapsable={false} > - diff --git a/src/pages/inbox/report/ReportActionCompose/SendOrSaveButton.tsx b/src/pages/inbox/report/ReportActionCompose/SubmitDraftButton.tsx similarity index 53% rename from src/pages/inbox/report/ReportActionCompose/SendOrSaveButton.tsx rename to src/pages/inbox/report/ReportActionCompose/SubmitDraftButton.tsx index 4b5311bccbaa..4d4ceebe248c 100644 --- a/src/pages/inbox/report/ReportActionCompose/SendOrSaveButton.tsx +++ b/src/pages/inbox/report/ReportActionCompose/SubmitDraftButton.tsx @@ -3,30 +3,27 @@ import Icon from '@components/Icon'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import type {PressableWithFeedbackProps} from '@components/Pressable/PressableWithFeedback'; import Tooltip from '@components/Tooltip'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import CONST from '@src/CONST'; +import type IconAsset from '@src/types/utils/IconAsset'; + +type SubmitDraftButtonProps = PressableWithFeedbackProps & { + /** The label to display on the button */ + label: string; + + /** The icon to display on the button */ + icon: IconAsset; -type SendOrSaveButtonProps = PressableWithFeedbackProps & { /** Whether the button is disabled */ isDisabled: boolean; - /** Whether the button is in editing mode */ - isEditing?: boolean; - /** Handle clicking on send button */ - onSendOrSave?: () => void; + onPress?: () => void; }; -function SendOrSaveButton({isDisabled: isDisabledProp = false, isEditing = false, onSendOrSave, ...restProps}: SendOrSaveButtonProps) { +function SubmitDraftButton({isDisabled: isDisabledProp = false, icon, label, sentryLabel, onPress, ...restProps}: SubmitDraftButtonProps) { const theme = useTheme(); const styles = useThemeStyles(); - const {translate} = useLocalize(); - const icons = useMemoizedLazyExpensifyIcons(['Send', 'Checkmark']); - const label = translate(isEditing ? 'common.saveChanges' : 'common.send'); - const sentryLabel = isEditing ? CONST.SENTRY_LABEL.REPORT.REPORT_ACTION_ITEM_MESSAGE_EDIT_SAVE_BUTTON : CONST.SENTRY_LABEL.REPORT.SEND_BUTTON; return ( @@ -36,9 +33,7 @@ function SendOrSaveButton({isDisabled: isDisabledProp = false, isEditing = false isDisabledProp || pressed || isDisabled ? undefined : styles.buttonSuccess, isDisabledProp ? styles.cursorDisabled : undefined, ]} - // Since the parent View has accessible, we need to set accessible to false here to avoid duplicate accessibility elements. - // On Android when TalkBack is enabled, only the parent element should be accessible, otherwise the button will not work. - onPress={onSendOrSave} + onPress={onPress} sentryLabel={sentryLabel} disabled={isDisabledProp} // eslint-disable-next-line react/jsx-props-no-spreading @@ -46,7 +41,7 @@ function SendOrSaveButton({isDisabled: isDisabledProp = false, isEditing = false > {({pressed}) => ( )} @@ -55,4 +50,4 @@ function SendOrSaveButton({isDisabled: isDisabledProp = false, isEditing = false ); } -export default SendOrSaveButton; +export default SubmitDraftButton; diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index 6d0ca33675ad..3a5ba34826b3 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -12,6 +12,7 @@ import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; import ExceededCommentLength from '@components/ExceededCommentLength'; import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTriggered'; import useKeyboardState from '@hooks/useKeyboardState'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useReportScrollManager from '@hooks/useReportScrollManager'; @@ -40,7 +41,7 @@ import getCursorPosition from './ReportActionCompose/getCursorPosition'; import getScrollPosition from './ReportActionCompose/getScrollPosition'; import MessageEditCancelButton from './ReportActionCompose/MessageEditCancelButton'; import type {SuggestionsRef} from './ReportActionCompose/ReportActionCompose'; -import SendOrSaveButton from './ReportActionCompose/SendOrSaveButton'; +import SubmitDraftButton from './ReportActionCompose/SubmitDraftButton'; import Suggestions from './ReportActionCompose/Suggestions'; import useDebouncedCommentMaxLengthValidation from './ReportActionCompose/useDebouncedCommentMaxLengthValidation'; import useEditMessage from './ReportActionCompose/useEditMessage'; @@ -96,6 +97,7 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy const cursorPositionValue = useSharedValue({x: 0, y: 0}); const tag = useSharedValue(-1); const emojisPresentBefore = useRef([]); + const icons = useMemoizedLazyExpensifyIcons(['Checkmark']); const {currentEditMessageSelection, editingMessage} = useReportActionActiveEdit(); const {setEditingMessage, setCurrentEditMessageSelection} = useReportActionActiveEditActions(); @@ -483,10 +485,12 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy - publishDraft(draft)} + icon={icons.Checkmark} + label={translate('common.saveChanges')} + sentryLabel={CONST.SENTRY_LABEL.REPORT.REPORT_ACTION_ITEM_MESSAGE_EDIT_SAVE_BUTTON} + onPress={() => publishDraft(draft)} accessibilityLabel={translate('common.saveChanges')} role={CONST.ROLE.BUTTON} hoverDimmingValue={1} From 42a865ba601b3dc574b9f58eb0d41e9d19934a0c Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 7 May 2026 14:08:08 +0100 Subject: [PATCH 226/233] refactor: move platform-specific logic into platform files --- .../ComposerWithSuggestions.tsx | 8 +++----- .../getUpdatedSyncSelection/index.ios.ts | 12 ++++++++++++ .../getUpdatedSyncSelection/index.ts | 4 ++++ .../getUpdatedSyncSelection/types.ts | 12 ++++++++++++ .../updateNativeTextInputValue/index.ios.ts | 14 ++++++++++++++ .../updateNativeTextInputValue/index.ts | 4 ++++ .../updateNativeTextInputValue/types.ts | 15 +++++++++++++++ .../ReportActionCompose/useEditComposerToggle.ts | 11 ++--------- 8 files changed, 66 insertions(+), 14 deletions(-) create mode 100644 src/pages/inbox/report/ReportActionCompose/getUpdatedSyncSelection/index.ios.ts create mode 100644 src/pages/inbox/report/ReportActionCompose/getUpdatedSyncSelection/index.ts create mode 100644 src/pages/inbox/report/ReportActionCompose/getUpdatedSyncSelection/types.ts create mode 100644 src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/index.ios.ts create mode 100644 src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/index.ts create mode 100644 src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/types.ts diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 0fdbafe70fbe..ade58d411771 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -40,7 +40,6 @@ import {containsOnlyEmojis, extractEmojis, getAddedEmojis, getTextVSCursorOffset import focusComposerWithDelay from '@libs/focusComposerWithDelay'; import type {Selection} from '@libs/focusComposerWithDelay/types'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; -import getPlatform from '@libs/getPlatform'; import {addKeyDownPressListener, removeKeyDownPressListener} from '@libs/KeyboardShortcut/KeyDownPressListener'; import {detectAndRewritePaste} from '@libs/MarkdownLinkHelpers'; import Parser from '@libs/Parser'; @@ -177,8 +176,6 @@ type SwitchToCurrentReportProps = { }; const {RNTextInputReset} = NativeModules; -const isIOSNative = getPlatform() === CONST.PLATFORM.IOS; - /** * Broadcast that the user is typing. Debounced to limit how often we publish client events. */ @@ -512,8 +509,9 @@ function ComposerWithSuggestions({ const adjustedCursorPosition = cursorPosition !== undefined && cursorPosition !== null ? cursorPosition + textVSOffset : undefined; const position = Math.max((selection.end ?? 0) + (newComment.length - commentRef.current.length), adjustedCursorPosition ?? 0); - if (commentWithSpaceInserted !== newComment && isIOSNative) { - syncSelectionWithOnChangeTextRef.current = {position, value: newComment}; + const updatedSyncSelection = getUpdatedSyncSelection({commentWithSpaceInserted, newComment, position}); + if (updatedSyncSelection) { + syncSelectionWithOnChangeTextRef.current = updatedSyncSelection; } // Keep selection in sync after emoji conversion / insertion while editing (e.g. emoji picker on web); diff --git a/src/pages/inbox/report/ReportActionCompose/getUpdatedSyncSelection/index.ios.ts b/src/pages/inbox/report/ReportActionCompose/getUpdatedSyncSelection/index.ios.ts new file mode 100644 index 000000000000..40c9e12db43e --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/getUpdatedSyncSelection/index.ios.ts @@ -0,0 +1,12 @@ +import type GetUpdatedSyncSelection from './types'; + +// We only need to update the sync selection on iOS platforms +const getUpdatedSyncSelection: GetUpdatedSyncSelection = ({commentWithSpaceInserted, newComment, position}) => { + if (commentWithSpaceInserted === newComment) { + return; + } + + return {position, value: newComment}; +}; + +export default getUpdatedSyncSelection; diff --git a/src/pages/inbox/report/ReportActionCompose/getUpdatedSyncSelection/index.ts b/src/pages/inbox/report/ReportActionCompose/getUpdatedSyncSelection/index.ts new file mode 100644 index 000000000000..f0f516e4250c --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/getUpdatedSyncSelection/index.ts @@ -0,0 +1,4 @@ +// This is a no-op function for non-iOS platforms +const noop = () => undefined; + +export default noop; diff --git a/src/pages/inbox/report/ReportActionCompose/getUpdatedSyncSelection/types.ts b/src/pages/inbox/report/ReportActionCompose/getUpdatedSyncSelection/types.ts new file mode 100644 index 000000000000..e2f750764272 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/getUpdatedSyncSelection/types.ts @@ -0,0 +1,12 @@ +type GetUpdatedSyncSelectionProps = { + /** The comment with space inserted */ + commentWithSpaceInserted: string; + /** The new comment */ + newComment: string; + /** The position of the comment */ + position: number; +}; + +type GetUpdatedSyncSelection = (props: GetUpdatedSyncSelectionProps) => {position: number; value: string} | undefined; + +export default GetUpdatedSyncSelection; diff --git a/src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/index.ios.ts b/src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/index.ios.ts new file mode 100644 index 000000000000..99a8f2f23e44 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/index.ios.ts @@ -0,0 +1,14 @@ +import type UpdateNativeTextInputValue from './types'; + +// We need to manually update the native text prop on iOS platforms, +// in order to force a re-calculation of the composer height and layout, +// when the composer changes in or out of edit mode. +const updateNativeTextInputValue: UpdateNativeTextInputValue = ({text, shouldForceNativeValueUpdate, composerRef}) => { + if (!shouldForceNativeValueUpdate) { + return; + } + + composerRef.current?.setNativeProps({text}); +}; + +export default updateNativeTextInputValue; diff --git a/src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/index.ts b/src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/index.ts new file mode 100644 index 000000000000..75a1e96cb44e --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/index.ts @@ -0,0 +1,4 @@ +// We don't need to manually update the native text prop on non-iOS platforms +const noop = () => undefined; + +export default noop; diff --git a/src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/types.ts b/src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/types.ts new file mode 100644 index 000000000000..234919473076 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/types.ts @@ -0,0 +1,15 @@ +import type {RefObject} from 'react'; +import type {ComposerRef} from '@components/Composer/types'; + +type UpdateNativeTextInputValueProps = { + /** The text to update */ + text: string; + /** Whether to force a native value update */ + shouldForceNativeValueUpdate: boolean; + /** The ref to the composer */ + composerRef: RefObject; +}; + +type UpdateNativeTextInputValue = (props: UpdateNativeTextInputValueProps) => void; + +export default UpdateNativeTextInputValue; diff --git a/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts b/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts index 4b07fdff7abe..0f25b5f1c934 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts @@ -2,12 +2,10 @@ import {useCallback, useEffect, useRef} from 'react'; import type {RefObject} from 'react'; import type {ComposerRef, TextSelection} from '@components/Composer/types'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import getPlatform from '@libs/getPlatform'; -import CONST from '@src/CONST'; import {useComposerActions, useComposerEditState} from './ComposerContext'; import ReportActionComposeUtils from './ReportActionComposeUtils'; +import updateNativeTextInputValue from './updateNativeTextInputValue/index.ios'; -const isIOSNative = getPlatform() === CONST.PLATFORM.IOS; type UseEditComposerToggleProps = { selection: TextSelection; draftComment: string; @@ -55,12 +53,7 @@ function useEditComposerToggle({selection, draftComment, composerRef, onFocus, o const selectionToApply = explicitSelection ?? (shouldUseEditingSelection && !shouldForceSelectionToEnd ? (currentEditMessageSelection ?? defaultSelection) : defaultSelection); onValueChange?.(nextValue); - // We need to manually update the native text prop, - // in order to force a re-calculation of the composer height and layout, - // when the composer changes in or out of edit mode. - if (isIOSNative && options?.shouldForceNativeValueUpdate) { - composerRef.current?.setNativeProps({text: nextValue}); - } + updateNativeTextInputValue({text: nextValue, shouldForceNativeValueUpdate: options?.shouldForceNativeValueUpdate ?? false, composerRef}); onSelectionChange?.(selectionToApply); ReportActionComposeUtils.updateNativeSelectionValue(composerRef, selectionToApply.start, selectionToApply.end ?? selectionToApply.start); From c23849e7e66511287897fca5f85283e0accc8308 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 7 May 2026 14:13:49 +0100 Subject: [PATCH 227/233] docs: add JSDoc to all new type properties --- .../ReportActionCompose/ComposerEditingButtons.tsx | 1 + .../ComposerExpandCollapseButton.tsx | 1 + .../ReportActionCompose/ComposerWithSuggestions.tsx | 1 + .../ReportActionCompose/MessageEditCancelButton.tsx | 3 +++ .../report/ReportActionCompose/useComposerSubmit.ts | 7 +------ .../useDebouncedCommentMaxLengthValidation.ts | 3 +++ .../ReportActionCompose/useEditComposerToggle.ts | 11 +++++++++++ .../report/ReportActionCompose/useEditMessage.ts | 6 ++++++ .../inbox/report/ReportActionEditMessageContext.tsx | 13 +++++++++++++ 9 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerEditingButtons.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerEditingButtons.tsx index 7d627e50653d..77f8f58dfae0 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerEditingButtons.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerEditingButtons.tsx @@ -7,6 +7,7 @@ import ComposerExpandCollapseButton from './ComposerExpandCollapseButton'; import MessageEditCancelButton from './MessageEditCancelButton'; type ComposerEditingButtonsProps = { + /** The report ID */ reportID: string; }; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerExpandCollapseButton.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerExpandCollapseButton.tsx index 7d55275f0100..8d3a6cfb62b7 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerExpandCollapseButton.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerExpandCollapseButton.tsx @@ -8,6 +8,7 @@ import {useComposerSendState, useComposerState} from './ComposerContext'; import ExpandCollapseButton from './ExpandCollapseButton'; type ComposerExpandCollapseButtonProps = ViewProps & { + /** The report ID */ reportID: string; }; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index ade58d411771..9150bcd76932 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -66,6 +66,7 @@ import findNodeHandle from '@src/utils/findNodeHandle'; import {useComposerEditState, useComposerState} from './ComposerContext'; import getCursorPosition from './getCursorPosition'; import getScrollPosition from './getScrollPosition'; +import getUpdatedSyncSelection from './getUpdatedSyncSelection/index.ios'; import type {SuggestionsRef} from './ReportActionCompose'; import ReportActionComposeUtils from './ReportActionComposeUtils'; import SilentCommentUpdater from './SilentCommentUpdater'; diff --git a/src/pages/inbox/report/ReportActionCompose/MessageEditCancelButton.tsx b/src/pages/inbox/report/ReportActionCompose/MessageEditCancelButton.tsx index 7a8758897afc..644e61ef1fea 100644 --- a/src/pages/inbox/report/ReportActionCompose/MessageEditCancelButton.tsx +++ b/src/pages/inbox/report/ReportActionCompose/MessageEditCancelButton.tsx @@ -11,7 +11,10 @@ import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; type MessageEditCancelButtonProps = ViewProps & { + /** Handle clicking on cancel button */ onCancel: () => void; + + /** The test ID to use for the button */ testID?: string; }; diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts index 482c7545e414..9e3d99b12d13 100644 --- a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts +++ b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts @@ -28,12 +28,7 @@ import type * as OnyxTypes from '@src/types/onyx'; import {useComposerActions, useComposerEditActions, useComposerEditState, useComposerMeta, useComposerSendState, useComposerState} from './ComposerContext'; import useSidePanelContext from './useSidePanelContext'; -type ComposerSubmitFunctions = { - validateAndSubmitDraft: (draftMessage: string) => void; - submitDraftAndClearComposer: () => void; -}; - -function useComposerSubmit(reportID: string): ComposerSubmitFunctions { +function useComposerSubmit(reportID: string) { const {isOffline} = useNetwork(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const personalDetails = usePersonalDetails(); diff --git a/src/pages/inbox/report/ReportActionCompose/useDebouncedCommentMaxLengthValidation.ts b/src/pages/inbox/report/ReportActionCompose/useDebouncedCommentMaxLengthValidation.ts index 15baf8e62669..ce9495104e78 100644 --- a/src/pages/inbox/report/ReportActionCompose/useDebouncedCommentMaxLengthValidation.ts +++ b/src/pages/inbox/report/ReportActionCompose/useDebouncedCommentMaxLengthValidation.ts @@ -4,7 +4,10 @@ import {getCommentLength} from '@libs/ReportUtils'; import CONST from '@src/CONST'; type UseDebouncedCommentValidationProps = { + /** The report ID */ reportID: string | undefined; + + /** Whether the composer is in edit mode */ isEditing?: boolean; }; diff --git a/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts b/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts index 0f25b5f1c934..d07151df306c 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts @@ -7,11 +7,22 @@ import ReportActionComposeUtils from './ReportActionComposeUtils'; import updateNativeTextInputValue from './updateNativeTextInputValue/index.ios'; type UseEditComposerToggleProps = { + /** The selection of the composer */ selection: TextSelection; + + /** The draft comment of the composer */ draftComment: string; + + /** The ref to the composer */ composerRef: RefObject; + + /** Handle changing the selection of the composer */ onSelectionChange?: (selection: TextSelection) => void; + + /** Handle focusing the composer */ onFocus?: () => void; + + /** Handle changing the value of the composer */ onValueChange?: (value: string) => void; }; diff --git a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts index 8a26a4ac97d3..e39bf1abf21d 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts @@ -17,11 +17,17 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; type UseEditMessageProps = { + /** The report ID */ reportID: string | undefined; + /** The original report ID */ originalReportID: string | undefined; + /** The report action */ reportAction: OnyxTypes.ReportAction | null | undefined; + /** Whether to scroll to the last message */ shouldScrollToLastMessage?: boolean; + /** The debounced comment max length validation */ debouncedCommentMaxLengthValidation: DebouncedFuncLeading<(value: string) => boolean>; + /** The ref to the composer */ composerRef: React.RefObject; }; diff --git a/src/pages/inbox/report/ReportActionEditMessageContext.tsx b/src/pages/inbox/report/ReportActionEditMessageContext.tsx index 2493731e4eb2..a470d2aac4d7 100644 --- a/src/pages/inbox/report/ReportActionEditMessageContext.tsx +++ b/src/pages/inbox/report/ReportActionEditMessageContext.tsx @@ -12,24 +12,35 @@ function NOOP() { return null; } +/** Whether the report is currently being edited, is already submitted or is not editing any m */ type EditingState = 'off' | 'editing' | 'submitted'; type ReportActionActiveEdit = { + /** The report ID */ editingReportID: string | null; + /** The report action ID */ editingReportActionID: string | null; + /** The report action */ editingReportAction: OnyxTypes.ReportAction | null; + /** The editing message */ editingMessage: string | null; }; type ReportActionEditMessageContextValue = ReportActionActiveEdit & { + /** The current edit message selection */ currentEditMessageSelection: TextSelection | null; + /** The editing state */ editingState: EditingState; }; type ReportActionEditMessageContextActions = { + /** Set the editing message */ setEditingMessage: Dispatch>; + /** Set the current edit message selection */ setCurrentEditMessageSelection: Dispatch>; + /** Submit the edit */ submitEdit: () => void; + /** Stop the editing */ stopEditing: () => void; }; @@ -50,7 +61,9 @@ const ReportActionEditMessageActionsContext = createContext Date: Thu, 7 May 2026 14:25:26 +0100 Subject: [PATCH 228/233] fix: manual composer height state is never reset --- .../inbox/report/ReportActionCompose/useEditComposerToggle.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts b/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts index d07151df306c..e9848e697cc2 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts @@ -87,8 +87,9 @@ function useEditComposerToggle({selection, draftComment, composerRef, onFocus, o // Editing just ended in the composer – restore the draft comment and its previous selection. applyComposerValue(draftComment ?? '', {selection: previousDraftSelectionRef.current, shouldForceNativeValueUpdate: true}); + // Once the composer is no longer in edit mode, we can reset the manual composer height. if (isEditingInComposer) { - setDidResetComposerHeight(true); + setDidResetComposerHeight(false); } if (!wasComposerFocusedBeforeEditingRef.current) { From da9d5e34000b721f5fba5362c982c7727e2cc212 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 11 May 2026 09:43:17 +0100 Subject: [PATCH 229/233] fix: replace `index.ios` module imports --- .../report/ReportActionCompose/ComposerWithSuggestions.tsx | 2 +- .../inbox/report/ReportActionCompose/useEditComposerToggle.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 9bf348874a90..c3c9e8128e34 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -66,7 +66,7 @@ import findNodeHandle from '@src/utils/findNodeHandle'; import {useComposerEditState, useComposerState} from './ComposerContext'; import getCursorPosition from './getCursorPosition'; import getScrollPosition from './getScrollPosition'; -import getUpdatedSyncSelection from './getUpdatedSyncSelection/index.ios'; +import getUpdatedSyncSelection from './getUpdatedSyncSelection'; import type {SuggestionsRef} from './ReportActionCompose'; import ReportActionComposeUtils from './ReportActionComposeUtils'; import SilentCommentUpdater from './SilentCommentUpdater'; diff --git a/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts b/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts index e9848e697cc2..1640d4af4554 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts @@ -4,7 +4,7 @@ import type {ComposerRef, TextSelection} from '@components/Composer/types'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import {useComposerActions, useComposerEditState} from './ComposerContext'; import ReportActionComposeUtils from './ReportActionComposeUtils'; -import updateNativeTextInputValue from './updateNativeTextInputValue/index.ios'; +import updateNativeTextInputValue from './updateNativeTextInputValue'; type UseEditComposerToggleProps = { /** The selection of the composer */ From bb6401c40ec5ba4b7611b5d215aee16c690d4978 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 11 May 2026 09:48:12 +0100 Subject: [PATCH 230/233] fix: types of `updateNativeTextInputValue` --- .../updateNativeTextInputValue/index.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/index.ts b/src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/index.ts index 75a1e96cb44e..5b97a110d5e5 100644 --- a/src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/index.ts +++ b/src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/index.ts @@ -1,4 +1,8 @@ -// We don't need to manually update the native text prop on non-iOS platforms +import type UpdateNativeTextInputValue from './types'; + const noop = () => undefined; -export default noop; +// We don't need to manually update the native text prop on non-iOS platforms +const updateNativeTextInputValue: UpdateNativeTextInputValue = noop; + +export default updateNativeTextInputValue; From 9883f391b8f6544ecfaa4533a4e1b156232e4681 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 11 May 2026 09:48:28 +0100 Subject: [PATCH 231/233] refactor: remove manual `useCallback` from `useEditComposerToggle` --- .../useEditComposerToggle.ts | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts b/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts index 1640d4af4554..caee59f76d9f 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts @@ -1,4 +1,4 @@ -import {useCallback, useEffect, useRef} from 'react'; +import {useEffect, useRef} from 'react'; import type {RefObject} from 'react'; import type {ComposerRef, TextSelection} from '@components/Composer/types'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -54,27 +54,24 @@ function useEditComposerToggle({selection, draftComment, composerRef, onFocus, o shouldForceNativeValueUpdate?: boolean; }; - const applyComposerValue = useCallback( - (nextValue: string, options?: ApplyComposerValueOptions) => { - const defaultSelection: TextSelection = {start: nextValue.length, end: nextValue.length}; - const shouldUseEditingSelection = options?.isEditingInComposer ?? false; - const shouldForceSelectionToEnd = options?.shouldMoveSelectionToEnd ?? false; - const explicitSelection = options?.selection ?? null; + const applyComposerValue = (nextValue: string, options?: ApplyComposerValueOptions) => { + const defaultSelection: TextSelection = {start: nextValue.length, end: nextValue.length}; + const shouldUseEditingSelection = options?.isEditingInComposer ?? false; + const shouldForceSelectionToEnd = options?.shouldMoveSelectionToEnd ?? false; + const explicitSelection = options?.selection ?? null; - const selectionToApply = explicitSelection ?? (shouldUseEditingSelection && !shouldForceSelectionToEnd ? (currentEditMessageSelection ?? defaultSelection) : defaultSelection); + const selectionToApply = explicitSelection ?? (shouldUseEditingSelection && !shouldForceSelectionToEnd ? (currentEditMessageSelection ?? defaultSelection) : defaultSelection); - onValueChange?.(nextValue); - updateNativeTextInputValue({text: nextValue, shouldForceNativeValueUpdate: options?.shouldForceNativeValueUpdate ?? false, composerRef}); + onValueChange?.(nextValue); + updateNativeTextInputValue({text: nextValue, shouldForceNativeValueUpdate: options?.shouldForceNativeValueUpdate ?? false, composerRef}); - onSelectionChange?.(selectionToApply); - ReportActionComposeUtils.updateNativeSelectionValue(composerRef, selectionToApply.start, selectionToApply.end ?? selectionToApply.start); + onSelectionChange?.(selectionToApply); + ReportActionComposeUtils.updateNativeSelectionValue(composerRef, selectionToApply.start, selectionToApply.end ?? selectionToApply.start); - if (options?.isEditingInComposer) { - onFocus?.(); - } - }, - [composerRef, currentEditMessageSelection, onFocus, onSelectionChange, onValueChange], - ); + if (options?.isEditingInComposer) { + onFocus?.(); + } + }; useEffect(() => { // If the draft message is already being submitted, do nothing. From 03174f0e8d0c177139106f27e0907e0ad237d9c4 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 11 May 2026 09:50:17 +0100 Subject: [PATCH 232/233] refactor: remove manual memos --- .../report/ReportActionEditMessageContext.tsx | 68 +++++++++---------- 1 file changed, 31 insertions(+), 37 deletions(-) diff --git a/src/pages/inbox/report/ReportActionEditMessageContext.tsx b/src/pages/inbox/report/ReportActionEditMessageContext.tsx index a470d2aac4d7..8da94bc60c03 100644 --- a/src/pages/inbox/report/ReportActionEditMessageContext.tsx +++ b/src/pages/inbox/report/ReportActionEditMessageContext.tsx @@ -1,4 +1,4 @@ -import React, {createContext, useCallback, useContext, useState} from 'react'; +import React, {createContext, useContext, useState} from 'react'; import type {Dispatch, SetStateAction} from 'react'; import type {OnyxCollection} from 'react-native-onyx'; import type {TextSelection} from '@components/Composer/types'; @@ -73,46 +73,40 @@ function ReportActionEditMessageContextProvider({reportID, children}: ReportActi const ancestors = useAncestors(report, shouldExcludeAncestorReportAction); - const ancestorReportActionsSelector = useCallback( - (allReportActions: OnyxCollection) => { - if (!allReportActions) { - return {}; - } - const result: OnyxCollection = {}; - for (const ancestor of ancestors) { - const key = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${ancestor.report.reportID}`; - result[key] = allReportActions[key]; - } - return result; - }, - [ancestors], - ); + const ancestorReportActionsSelector = (allReportActions: OnyxCollection) => { + if (!allReportActions) { + return {}; + } + const result: OnyxCollection = {}; + for (const ancestor of ancestors) { + const key = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${ancestor.report.reportID}`; + result[key] = allReportActions[key]; + } + return result; + }; const [ancestorsReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, {selector: ancestorReportActionsSelector}, [ancestors]); - const ancestorDraftSelector = useCallback( - (allDrafts: OnyxCollection) => { - if (!allDrafts) { - return {}; - } - const result: OnyxCollection = {}; - if (reportID) { - const currentDraftKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`; - result[currentDraftKey] = allDrafts[currentDraftKey]; - } - for (const ancestor of ancestors) { - const reportActionsForAncestor = ancestorsReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${ancestor.report.reportID}`]; - const origID = getOriginalReportID(ancestor.report.reportID, ancestor.reportAction, reportActionsForAncestor); - if (!origID) { - continue; - } - const draftKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${origID}`; - result[draftKey] = allDrafts[draftKey]; + const ancestorDraftSelector = (allDrafts: OnyxCollection) => { + if (!allDrafts) { + return {}; + } + const result: OnyxCollection = {}; + if (reportID) { + const currentDraftKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`; + result[currentDraftKey] = allDrafts[currentDraftKey]; + } + for (const ancestor of ancestors) { + const reportActionsForAncestor = ancestorsReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${ancestor.report.reportID}`]; + const origID = getOriginalReportID(ancestor.report.reportID, ancestor.reportAction, reportActionsForAncestor); + if (!origID) { + continue; } - return result; - }, - [ancestors, ancestorsReportActions, reportID], - ); + const draftKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${origID}`; + result[draftKey] = allDrafts[draftKey]; + } + return result; + }; const [reportActionDrafts] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS, {selector: ancestorDraftSelector}, [ancestors, ancestorsReportActions, reportID]); From ec111b2dcd80597691f929aebd2956069a88de37 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 11 May 2026 10:14:44 +0100 Subject: [PATCH 233/233] fix: types for `getUpdatedSyncSelection` --- .../ReportActionCompose/getUpdatedSyncSelection/index.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/getUpdatedSyncSelection/index.ts b/src/pages/inbox/report/ReportActionCompose/getUpdatedSyncSelection/index.ts index f0f516e4250c..40bb0fb3f939 100644 --- a/src/pages/inbox/report/ReportActionCompose/getUpdatedSyncSelection/index.ts +++ b/src/pages/inbox/report/ReportActionCompose/getUpdatedSyncSelection/index.ts @@ -1,4 +1,8 @@ -// This is a no-op function for non-iOS platforms +import type GetUpdatedSyncSelection from './types'; + const noop = () => undefined; -export default noop; +// This is a no-op function for non-iOS platforms +const getUpdatedSyncSelection: GetUpdatedSyncSelection = noop; + +export default getUpdatedSyncSelection;