From bd2ca7425016dd2313ae4253f500e68778dafd7d Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:56:55 +0530 Subject: [PATCH 01/57] Implement system message for card freeze and unfreeze actions --- src/CONST/index.ts | 1 + src/languages/de.ts | 2 ++ src/languages/en.ts | 2 ++ src/languages/es.ts | 2 ++ src/languages/fr.ts | 2 ++ src/languages/it.ts | 2 ++ src/languages/ja.ts | 2 ++ src/languages/nl.ts | 2 ++ src/languages/pl.ts | 2 ++ src/languages/pt-BR.ts | 2 ++ src/languages/zh-hans.ts | 2 ++ src/libs/ReportActionsUtils.ts | 25 ++++++++++++++++- src/types/onyx/OriginalMessage.ts | 18 +++++++++++- tests/unit/ReportActionsUtilsTest.ts | 42 ++++++++++++++++++++++++++++ 14 files changed, 104 insertions(+), 2 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index de2b333f87dab..58a28e55a79a0 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1287,6 +1287,7 @@ const CONST = { CARD_REPLACED_VIRTUAL: 'CARDREPLACEDVIRTUAL', CARD_REPLACED: 'CARDREPLACED', CARD_ASSIGNED: 'CARDASSIGNED', + CARD_FREEZE: 'CARDFREEZE', PERSONAL_CARD_CONNECTION_BROKEN: 'PERSONALCARDCONNECTIONBROKEN', CHANGE_FIELD: 'CHANGEFIELD', // OldDot Action CHANGE_POLICY: 'CHANGEPOLICY', diff --git a/src/languages/de.ts b/src/languages/de.ts index 542b40a8eef42..b7fae10b61348 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -5162,6 +5162,8 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU addedShippingDetails: (assignee: string) => `${assignee} hat Versanddetails hinzugefügt. Die Expensify Karte wird in 2–3 Werktagen ankommen.`, replacedCard: (assignee: string) => `${assignee} hat ihre Expensify Karte ersetzt. Die neue Karte wird in 2–3 Werktagen ankommen.`, replacedVirtualCard: (assignee: string, link: string) => `${assignee} hat ihre virtuelle Expensify Karte ersetzt! Der ${link} kann sofort verwendet werden.`, + frozeCard: (assignee: string) => `hat die Expensify Karte von ${assignee} gesperrt.`, + unfrozeCard: (assignee: string) => `hat die Expensify Karte von ${assignee} entsperrt.`, card: 'Karte', replacementCard: 'Ersatzkarte', verifyingHeader: 'Wird überprüft', diff --git a/src/languages/en.ts b/src/languages/en.ts index 0b033038a8f46..15efac31c6f3a 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -5130,6 +5130,8 @@ const translations = { addedShippingDetails: (assignee: string) => `${assignee} added shipping details. Expensify Card will arrive in 2-3 business days.`, replacedCard: (assignee: string) => `${assignee} replaced their Expensify Card. The new card will arrive in 2-3 business days.`, replacedVirtualCard: (assignee: string, link: string) => `${assignee} replaced their virtual Expensify Card! The ${link} can be used right away.`, + frozeCard: (assignee: string) => `froze ${assignee}'s Expensify Card.`, + unfrozeCard: (assignee: string) => `unfroze ${assignee}'s Expensify Card.`, card: 'card', replacementCard: 'replacement card', verifyingHeader: 'Verifying', diff --git a/src/languages/es.ts b/src/languages/es.ts index ceca7d565f167..450a8d85d6038 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4982,6 +4982,8 @@ ${amount} para ${merchant} - ${date}`, addedShippingDetails: (assignee) => `${assignee} agregó los detalles de envío. La Tarjeta Expensify llegará en 2-3 días hábiles.`, replacedCard: (assignee) => `${assignee} reemplazó su Tarjeta Expensify. La nueva tarjeta llegará en 2-3 días hábiles.`, replacedVirtualCard: (assignee, link) => `${assignee} reemplazó su Tarjeta Expensify virtual! La ${link} puede utilizarse inmediatamente.`, + frozeCard: (assignee) => `congeló la Tarjeta Expensify de ${assignee}.`, + unfrozeCard: (assignee) => `descongeló la Tarjeta Expensify de ${assignee}.`, card: 'tarjeta', replacementCard: 'tarjeta de reemplazo', verifyingHeader: 'Verificando', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index bac2579dfa9dc..660bdbe1af6b6 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -5171,6 +5171,8 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. addedShippingDetails: (assignee: string) => `${assignee} a ajouté les informations de livraison. La Carte Expensify arrivera sous 2 à 3 jours ouvrés.`, replacedCard: (assignee: string) => `${assignee} a remplacé sa Carte Expensify. La nouvelle carte arrivera sous 2 à 3 jours ouvrés.`, replacedVirtualCard: (assignee: string, link: string) => `${assignee} a remplacé sa Carte Expensify virtuelle ! Le ${link} peut être utilisé immédiatement.`, + frozeCard: (assignee: string) => `a gelé la Carte Expensify de ${assignee}.`, + unfrozeCard: (assignee: string) => `a dégelé la Carte Expensify de ${assignee}.`, card: 'carte', replacementCard: 'carte de remplacement', verifyingHeader: 'Vérification', diff --git a/src/languages/it.ts b/src/languages/it.ts index c29a275fb8f2e..bc42891189631 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -5142,6 +5142,8 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. addedShippingDetails: (assignee: string) => `${assignee} ha aggiunto i dettagli di spedizione. La Carta Expensify arriverà in 2-3 giorni lavorativi.`, replacedCard: (assignee: string) => `${assignee} ha sostituito la propria Carta Expensify. La nuova carta arriverà tra 2-3 giorni lavorativi.`, replacedVirtualCard: (assignee: string, link: string) => `${assignee} ha sostituito la sua Carta Expensify virtuale! Il ${link} può essere usato subito.`, + frozeCard: (assignee: string) => `ha bloccato la Carta Expensify di ${assignee}.`, + unfrozeCard: (assignee: string) => `ha sbloccato la Carta Expensify di ${assignee}.`, card: 'carta', replacementCard: 'carta sostitutiva', verifyingHeader: 'Verifica in corso', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 1b91fe9524a71..48043ff1f4d21 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -5100,6 +5100,8 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO addedShippingDetails: (assignee: string) => `${assignee} さんが配送先情報を追加しました。Expensify カードは営業日2~3日で届きます。`, replacedCard: (assignee: string) => `${assignee} さんが Expensify カードを再発行しました。新しいカードは 2〜3 営業日以内に到着します。`, replacedVirtualCard: (assignee: string, link: string) => `${assignee} はバーチャル Expensify カードを再発行しました!${link} はすぐにご利用いただけます。`, + frozeCard: (assignee: string) => `${assignee} の Expensify カードを一時停止しました。`, + unfrozeCard: (assignee: string) => `${assignee} の Expensify カードの一時停止を解除しました。`, card: 'カード', replacementCard: '再発行カード', verifyingHeader: '確認中', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index a96ebff19b6e6..c385cc7514b5f 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -5133,6 +5133,8 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ addedShippingDetails: (assignee: string) => `${assignee} heeft verzendgegevens toegevoegd. Expensify Kaart komt over 2-3 werkdagen aan.`, replacedCard: (assignee: string) => `${assignee} heeft hun Expensify Kaart vervangen. De nieuwe kaart arriveert binnen 2-3 werkdagen.`, replacedVirtualCard: (assignee: string, link: string) => `${assignee} heeft zijn/haar virtuele Expensify Kaart vervangen! De ${link} kan meteen worden gebruikt.`, + frozeCard: (assignee: string) => `heeft de Expensify Kaart van ${assignee} geblokkeerd.`, + unfrozeCard: (assignee: string) => `heeft de Expensify Kaart van ${assignee} gedeblokkeerd.`, card: 'kaart', replacementCard: 'vervangende kaart', verifyingHeader: 'Bezig met verifiëren', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 411c7f56b2b7a..ff58ea85f8245 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -5124,6 +5124,8 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy addedShippingDetails: (assignee: string) => `${assignee} dodał dane wysyłki. Karta Expensify dotrze w ciągu 2–3 dni roboczych.`, replacedCard: (assignee: string) => `${assignee} wymienił(a) swoją Kartę Expensify. Nowa karta dotrze w ciągu 2–3 dni roboczych.`, replacedVirtualCard: (assignee: string, link: string) => `${assignee} wymienił(-a) swoją wirtualną Kartę Expensify! ${link} można używać od razu.`, + frozeCard: (assignee: string) => `zamroził(-a) Kartę Expensify użytkownika ${assignee}.`, + unfrozeCard: (assignee: string) => `odmroził(-a) Kartę Expensify użytkownika ${assignee}.`, card: 'karta', replacementCard: 'karta zastępcza', verifyingHeader: 'Weryfikowanie', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index fcd6645a66387..1060b841cd7ed 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -5125,6 +5125,8 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS addedShippingDetails: (assignee: string) => `${assignee} adicionou os detalhes de envio. O Cartão Expensify chegará em 2-3 dias úteis.`, replacedCard: (assignee: string) => `${assignee} substituiu o Cartão Expensify. O novo cartão chegará em 2-3 dias úteis.`, replacedVirtualCard: (assignee: string, link: string) => `${assignee} substituiu o cartão virtual Cartão Expensify! O ${link} já pode ser usado.`, + frozeCard: (assignee: string) => `bloqueou o Cartão Expensify de ${assignee}.`, + unfrozeCard: (assignee: string) => `desbloqueou o Cartão Expensify de ${assignee}.`, card: 'cartão', replacementCard: 'cartão de substituição', verifyingHeader: 'Verificando', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 687da7fdc2283..fae627d0b2f1a 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -5024,6 +5024,8 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM addedShippingDetails: (assignee: string) => `${assignee} 已添加运输详情。Expensify 卡将在 2-3 个工作日内送达。`, replacedCard: (assignee: string) => `${assignee} 已更换了他们的 Expensify 卡。新卡将在 2–3 个工作日内送达。`, replacedVirtualCard: (assignee: string, link: string) => `${assignee} 已更换其虚拟 Expensify 卡!现在就可以使用 ${link}。`, + frozeCard: (assignee: string) => `已冻结 ${assignee} 的 Expensify 卡。`, + unfrozeCard: (assignee: string) => `已解冻 ${assignee} 的 Expensify 卡。`, card: '卡片', replacementCard: '替换卡', verifyingHeader: '正在验证', diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index ddadc85058544..34df095c56c91 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -4153,6 +4153,7 @@ function isCardIssuedAction( | typeof CONST.REPORT.ACTIONS.TYPE.CARD_ASSIGNED | typeof CONST.REPORT.ACTIONS.TYPE.CARD_REPLACED_VIRTUAL | typeof CONST.REPORT.ACTIONS.TYPE.CARD_REPLACED + | typeof CONST.REPORT.ACTIONS.TYPE.CARD_FREEZE > { return ( isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.CARD_ISSUED) || @@ -4160,7 +4161,8 @@ function isCardIssuedAction( isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.CARD_MISSING_ADDRESS) || isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.CARD_ASSIGNED) || isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.CARD_REPLACED_VIRTUAL) || - isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.CARD_REPLACED) + isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.CARD_REPLACED) || + isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.CARD_FREEZE) ); } @@ -4187,6 +4189,25 @@ function isCardActive(card?: Card): boolean { return !closedStates.has(card.state); } +function isFreezeActionFrozen(reportAction: OnyxEntry, expensifyCard?: Card): boolean { + if (!isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.CARD_FREEZE)) { + return false; + } + + const originalMessage = getOriginalMessage(reportAction); + if (typeof originalMessage?.frozen === 'boolean') { + return originalMessage.frozen; + } + if (typeof originalMessage?.isFrozen === 'boolean') { + return originalMessage.isFrozen; + } + if (originalMessage?.state !== undefined) { + return String(originalMessage.state) === String(CONST.EXPENSIFY_CARD.STATE.STATE_SUSPENDED); + } + + return expensifyCard?.state === CONST.EXPENSIFY_CARD.STATE.STATE_SUSPENDED; +} + function getCardIssuedMessage({ reportAction, shouldRenderHTML = false, @@ -4235,6 +4256,8 @@ function getCardIssuedMessage({ return translate('workspace.expensifyCard.replacedVirtualCard', assignee, expensifyCardLink(translate('workspace.expensifyCard.replacementCard'))); case CONST.REPORT.ACTIONS.TYPE.CARD_REPLACED: return translate('workspace.expensifyCard.replacedCard', assignee); + case CONST.REPORT.ACTIONS.TYPE.CARD_FREEZE: + return isFreezeActionFrozen(reportAction, expensifyCard) ? translate('workspace.expensifyCard.frozeCard', assignee) : translate('workspace.expensifyCard.unfrozeCard', assignee); default: return ''; } diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index 0e7d0513ab079..bf89a247bd253 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -1265,6 +1265,20 @@ type OriginalMessageCard = { hadMissingAddress?: boolean; }; +/** + * Model of CARDFREEZE action + */ +type OriginalMessageCardFreeze = OriginalMessageCard & { + /** Whether the action froze or unfroze the card */ + frozen?: boolean; + + /** Fallback boolean the backend may use to indicate the new frozen state */ + isFrozen?: boolean; + + /** Fallback state value the backend may use to indicate the new card state */ + state?: number | string; +}; + /** * Model of PERSONAL_CARD_CONNECTION_BROKEN action */ @@ -1317,7 +1331,7 @@ type OriginalMessageSettlementAccountLocked = { }; /** - * Original message for CARD_ISSUED, CARD_MISSING_ADDRESS, CARD_ASSIGNED, CARD_ISSUED_VIRTUAL and CARD_ISSUED_VIRTUAL actions + * Original message for Expensify Card system message actions */ type IssueNewCardOriginalMessage = OriginalMessage< | typeof CONST.REPORT.ACTIONS.TYPE.CARD_MISSING_ADDRESS @@ -1326,6 +1340,7 @@ type IssueNewCardOriginalMessage = OriginalMessage< | typeof CONST.REPORT.ACTIONS.TYPE.CARD_ASSIGNED | typeof CONST.REPORT.ACTIONS.TYPE.CARD_REPLACED_VIRTUAL | typeof CONST.REPORT.ACTIONS.TYPE.CARD_REPLACED + | typeof CONST.REPORT.ACTIONS.TYPE.CARD_FREEZE >; /** @@ -1434,6 +1449,7 @@ type OriginalMessageMap = { [CONST.REPORT.ACTIONS.TYPE.CARD_REPLACED_VIRTUAL]: OriginalMessageCard; [CONST.REPORT.ACTIONS.TYPE.CARD_REPLACED]: OriginalMessageCard; [CONST.REPORT.ACTIONS.TYPE.CARD_ASSIGNED]: OriginalMessageCard; + [CONST.REPORT.ACTIONS.TYPE.CARD_FREEZE]: OriginalMessageCardFreeze; [CONST.REPORT.ACTIONS.TYPE.PERSONAL_CARD_CONNECTION_BROKEN]: OriginalPersonalCard; [CONST.REPORT.ACTIONS.TYPE.INTEGRATION_SYNC_FAILED]: OriginalMessageIntegrationSyncFailed; [CONST.REPORT.ACTIONS.TYPE.DELETED_TRANSACTION]: OriginalMessageDeletedTransaction; diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 3d621360a45e5..d2fd8d4c18ed6 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -1554,6 +1554,28 @@ describe('ReportActionsUtils', () => { } as Card; const testPolicyID = 'test-policy-123'; + const mockCardFreezeAction: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.CARD_FREEZE, + reportActionID: 'card-freeze-action-123', + actorAccountID: 123, + created: '2024-01-01', + message: [], + originalMessage: { + assigneeAccountID: 456, + cardID: 789, + frozen: true, + }, + } as ReportAction; + + const mockCardUnfreezeAction: ReportAction = { + ...mockCardFreezeAction, + reportActionID: 'card-unfreeze-action-123', + originalMessage: { + assigneeAccountID: 456, + cardID: 789, + frozen: false, + }, + } as ReportAction; describe('render virtual card issued messages', () => { it('should render a plain text message without card link when no card data is available', () => { @@ -1582,6 +1604,26 @@ describe('ReportActionsUtils', () => { ); }); }); + + describe('render card freeze messages', () => { + it('should render the freeze system message when the card is frozen', () => { + const messageResult = getCardIssuedMessage({ + reportAction: mockCardFreezeAction, + translate: translateLocal, + }); + + expect(messageResult).toBe(`froze @456's Expensify Card.`); + }); + + it('should render the unfreeze system message when the card is unfrozen', () => { + const messageResult = getCardIssuedMessage({ + reportAction: mockCardUnfreezeAction, + translate: translateLocal, + }); + + expect(messageResult).toBe(`unfroze @456's Expensify Card.`); + }); + }); }); describe('shouldReportActionBeVisible', () => { From c0fe4f533e0a2057d631f266480631324ae902f9 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 12 Mar 2026 08:06:27 +0530 Subject: [PATCH 02/57] Implement system message for card freeze and unfreeze actions --- src/CONST/index.ts | 3 +- src/languages/de.ts | 2 - src/languages/en.ts | 2 - src/languages/es.ts | 2 - src/languages/fr.ts | 2 - src/languages/it.ts | 2 - src/languages/ja.ts | 2 - src/languages/nl.ts | 2 - src/languages/pl.ts | 2 - src/languages/pt-BR.ts | 2 - src/languages/zh-hans.ts | 2 - src/libs/ReportActionsUtils.ts | 25 +-- .../inbox/report/PureReportActionItem.tsx | 7 + src/types/onyx/OriginalMessage.ts | 22 +-- tests/unit/ReportActionsUtilsTest.ts | 155 +++++++++++++----- 15 files changed, 133 insertions(+), 99 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 58a28e55a79a0..d3484e7984c87 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1287,7 +1287,8 @@ const CONST = { CARD_REPLACED_VIRTUAL: 'CARDREPLACEDVIRTUAL', CARD_REPLACED: 'CARDREPLACED', CARD_ASSIGNED: 'CARDASSIGNED', - CARD_FREEZE: 'CARDFREEZE', + CARD_FROZEN: 'CARDFROZEN', + CARD_UNFROZEN: 'CARDUNFROZEN', PERSONAL_CARD_CONNECTION_BROKEN: 'PERSONALCARDCONNECTIONBROKEN', CHANGE_FIELD: 'CHANGEFIELD', // OldDot Action CHANGE_POLICY: 'CHANGEPOLICY', diff --git a/src/languages/de.ts b/src/languages/de.ts index b7fae10b61348..542b40a8eef42 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -5162,8 +5162,6 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU addedShippingDetails: (assignee: string) => `${assignee} hat Versanddetails hinzugefügt. Die Expensify Karte wird in 2–3 Werktagen ankommen.`, replacedCard: (assignee: string) => `${assignee} hat ihre Expensify Karte ersetzt. Die neue Karte wird in 2–3 Werktagen ankommen.`, replacedVirtualCard: (assignee: string, link: string) => `${assignee} hat ihre virtuelle Expensify Karte ersetzt! Der ${link} kann sofort verwendet werden.`, - frozeCard: (assignee: string) => `hat die Expensify Karte von ${assignee} gesperrt.`, - unfrozeCard: (assignee: string) => `hat die Expensify Karte von ${assignee} entsperrt.`, card: 'Karte', replacementCard: 'Ersatzkarte', verifyingHeader: 'Wird überprüft', diff --git a/src/languages/en.ts b/src/languages/en.ts index 15efac31c6f3a..0b033038a8f46 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -5130,8 +5130,6 @@ const translations = { addedShippingDetails: (assignee: string) => `${assignee} added shipping details. Expensify Card will arrive in 2-3 business days.`, replacedCard: (assignee: string) => `${assignee} replaced their Expensify Card. The new card will arrive in 2-3 business days.`, replacedVirtualCard: (assignee: string, link: string) => `${assignee} replaced their virtual Expensify Card! The ${link} can be used right away.`, - frozeCard: (assignee: string) => `froze ${assignee}'s Expensify Card.`, - unfrozeCard: (assignee: string) => `unfroze ${assignee}'s Expensify Card.`, card: 'card', replacementCard: 'replacement card', verifyingHeader: 'Verifying', diff --git a/src/languages/es.ts b/src/languages/es.ts index 450a8d85d6038..ceca7d565f167 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4982,8 +4982,6 @@ ${amount} para ${merchant} - ${date}`, addedShippingDetails: (assignee) => `${assignee} agregó los detalles de envío. La Tarjeta Expensify llegará en 2-3 días hábiles.`, replacedCard: (assignee) => `${assignee} reemplazó su Tarjeta Expensify. La nueva tarjeta llegará en 2-3 días hábiles.`, replacedVirtualCard: (assignee, link) => `${assignee} reemplazó su Tarjeta Expensify virtual! La ${link} puede utilizarse inmediatamente.`, - frozeCard: (assignee) => `congeló la Tarjeta Expensify de ${assignee}.`, - unfrozeCard: (assignee) => `descongeló la Tarjeta Expensify de ${assignee}.`, card: 'tarjeta', replacementCard: 'tarjeta de reemplazo', verifyingHeader: 'Verificando', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 660bdbe1af6b6..bac2579dfa9dc 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -5171,8 +5171,6 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. addedShippingDetails: (assignee: string) => `${assignee} a ajouté les informations de livraison. La Carte Expensify arrivera sous 2 à 3 jours ouvrés.`, replacedCard: (assignee: string) => `${assignee} a remplacé sa Carte Expensify. La nouvelle carte arrivera sous 2 à 3 jours ouvrés.`, replacedVirtualCard: (assignee: string, link: string) => `${assignee} a remplacé sa Carte Expensify virtuelle ! Le ${link} peut être utilisé immédiatement.`, - frozeCard: (assignee: string) => `a gelé la Carte Expensify de ${assignee}.`, - unfrozeCard: (assignee: string) => `a dégelé la Carte Expensify de ${assignee}.`, card: 'carte', replacementCard: 'carte de remplacement', verifyingHeader: 'Vérification', diff --git a/src/languages/it.ts b/src/languages/it.ts index bc42891189631..c29a275fb8f2e 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -5142,8 +5142,6 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. addedShippingDetails: (assignee: string) => `${assignee} ha aggiunto i dettagli di spedizione. La Carta Expensify arriverà in 2-3 giorni lavorativi.`, replacedCard: (assignee: string) => `${assignee} ha sostituito la propria Carta Expensify. La nuova carta arriverà tra 2-3 giorni lavorativi.`, replacedVirtualCard: (assignee: string, link: string) => `${assignee} ha sostituito la sua Carta Expensify virtuale! Il ${link} può essere usato subito.`, - frozeCard: (assignee: string) => `ha bloccato la Carta Expensify di ${assignee}.`, - unfrozeCard: (assignee: string) => `ha sbloccato la Carta Expensify di ${assignee}.`, card: 'carta', replacementCard: 'carta sostitutiva', verifyingHeader: 'Verifica in corso', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 48043ff1f4d21..1b91fe9524a71 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -5100,8 +5100,6 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO addedShippingDetails: (assignee: string) => `${assignee} さんが配送先情報を追加しました。Expensify カードは営業日2~3日で届きます。`, replacedCard: (assignee: string) => `${assignee} さんが Expensify カードを再発行しました。新しいカードは 2〜3 営業日以内に到着します。`, replacedVirtualCard: (assignee: string, link: string) => `${assignee} はバーチャル Expensify カードを再発行しました!${link} はすぐにご利用いただけます。`, - frozeCard: (assignee: string) => `${assignee} の Expensify カードを一時停止しました。`, - unfrozeCard: (assignee: string) => `${assignee} の Expensify カードの一時停止を解除しました。`, card: 'カード', replacementCard: '再発行カード', verifyingHeader: '確認中', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index c385cc7514b5f..a96ebff19b6e6 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -5133,8 +5133,6 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ addedShippingDetails: (assignee: string) => `${assignee} heeft verzendgegevens toegevoegd. Expensify Kaart komt over 2-3 werkdagen aan.`, replacedCard: (assignee: string) => `${assignee} heeft hun Expensify Kaart vervangen. De nieuwe kaart arriveert binnen 2-3 werkdagen.`, replacedVirtualCard: (assignee: string, link: string) => `${assignee} heeft zijn/haar virtuele Expensify Kaart vervangen! De ${link} kan meteen worden gebruikt.`, - frozeCard: (assignee: string) => `heeft de Expensify Kaart van ${assignee} geblokkeerd.`, - unfrozeCard: (assignee: string) => `heeft de Expensify Kaart van ${assignee} gedeblokkeerd.`, card: 'kaart', replacementCard: 'vervangende kaart', verifyingHeader: 'Bezig met verifiëren', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index ff58ea85f8245..411c7f56b2b7a 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -5124,8 +5124,6 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy addedShippingDetails: (assignee: string) => `${assignee} dodał dane wysyłki. Karta Expensify dotrze w ciągu 2–3 dni roboczych.`, replacedCard: (assignee: string) => `${assignee} wymienił(a) swoją Kartę Expensify. Nowa karta dotrze w ciągu 2–3 dni roboczych.`, replacedVirtualCard: (assignee: string, link: string) => `${assignee} wymienił(-a) swoją wirtualną Kartę Expensify! ${link} można używać od razu.`, - frozeCard: (assignee: string) => `zamroził(-a) Kartę Expensify użytkownika ${assignee}.`, - unfrozeCard: (assignee: string) => `odmroził(-a) Kartę Expensify użytkownika ${assignee}.`, card: 'karta', replacementCard: 'karta zastępcza', verifyingHeader: 'Weryfikowanie', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 1060b841cd7ed..fcd6645a66387 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -5125,8 +5125,6 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS addedShippingDetails: (assignee: string) => `${assignee} adicionou os detalhes de envio. O Cartão Expensify chegará em 2-3 dias úteis.`, replacedCard: (assignee: string) => `${assignee} substituiu o Cartão Expensify. O novo cartão chegará em 2-3 dias úteis.`, replacedVirtualCard: (assignee: string, link: string) => `${assignee} substituiu o cartão virtual Cartão Expensify! O ${link} já pode ser usado.`, - frozeCard: (assignee: string) => `bloqueou o Cartão Expensify de ${assignee}.`, - unfrozeCard: (assignee: string) => `desbloqueou o Cartão Expensify de ${assignee}.`, card: 'cartão', replacementCard: 'cartão de substituição', verifyingHeader: 'Verificando', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index fae627d0b2f1a..687da7fdc2283 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -5024,8 +5024,6 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM addedShippingDetails: (assignee: string) => `${assignee} 已添加运输详情。Expensify 卡将在 2-3 个工作日内送达。`, replacedCard: (assignee: string) => `${assignee} 已更换了他们的 Expensify 卡。新卡将在 2–3 个工作日内送达。`, replacedVirtualCard: (assignee: string, link: string) => `${assignee} 已更换其虚拟 Expensify 卡!现在就可以使用 ${link}。`, - frozeCard: (assignee: string) => `已冻结 ${assignee} 的 Expensify 卡。`, - unfrozeCard: (assignee: string) => `已解冻 ${assignee} 的 Expensify 卡。`, card: '卡片', replacementCard: '替换卡', verifyingHeader: '正在验证', diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 34df095c56c91..ddadc85058544 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -4153,7 +4153,6 @@ function isCardIssuedAction( | typeof CONST.REPORT.ACTIONS.TYPE.CARD_ASSIGNED | typeof CONST.REPORT.ACTIONS.TYPE.CARD_REPLACED_VIRTUAL | typeof CONST.REPORT.ACTIONS.TYPE.CARD_REPLACED - | typeof CONST.REPORT.ACTIONS.TYPE.CARD_FREEZE > { return ( isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.CARD_ISSUED) || @@ -4161,8 +4160,7 @@ function isCardIssuedAction( isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.CARD_MISSING_ADDRESS) || isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.CARD_ASSIGNED) || isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.CARD_REPLACED_VIRTUAL) || - isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.CARD_REPLACED) || - isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.CARD_FREEZE) + isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.CARD_REPLACED) ); } @@ -4189,25 +4187,6 @@ function isCardActive(card?: Card): boolean { return !closedStates.has(card.state); } -function isFreezeActionFrozen(reportAction: OnyxEntry, expensifyCard?: Card): boolean { - if (!isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.CARD_FREEZE)) { - return false; - } - - const originalMessage = getOriginalMessage(reportAction); - if (typeof originalMessage?.frozen === 'boolean') { - return originalMessage.frozen; - } - if (typeof originalMessage?.isFrozen === 'boolean') { - return originalMessage.isFrozen; - } - if (originalMessage?.state !== undefined) { - return String(originalMessage.state) === String(CONST.EXPENSIFY_CARD.STATE.STATE_SUSPENDED); - } - - return expensifyCard?.state === CONST.EXPENSIFY_CARD.STATE.STATE_SUSPENDED; -} - function getCardIssuedMessage({ reportAction, shouldRenderHTML = false, @@ -4256,8 +4235,6 @@ function getCardIssuedMessage({ return translate('workspace.expensifyCard.replacedVirtualCard', assignee, expensifyCardLink(translate('workspace.expensifyCard.replacementCard'))); case CONST.REPORT.ACTIONS.TYPE.CARD_REPLACED: return translate('workspace.expensifyCard.replacedCard', assignee); - case CONST.REPORT.ACTIONS.TYPE.CARD_FREEZE: - return isFreezeActionFrozen(reportAction, expensifyCard) ? translate('workspace.expensifyCard.frozeCard', assignee) : translate('workspace.expensifyCard.unfrozeCard', assignee); default: return ''; } diff --git a/src/pages/inbox/report/PureReportActionItem.tsx b/src/pages/inbox/report/PureReportActionItem.tsx index 874309618929e..24dd36940eb4c 100644 --- a/src/pages/inbox/report/PureReportActionItem.tsx +++ b/src/pages/inbox/report/PureReportActionItem.tsx @@ -117,6 +117,7 @@ import { getRemovedConnectionMessage, getRemovedFromApprovalChainMessage, getRenamedAction, + getReportActionHtml, getReportActionMessage, getReportActionText, getSetAutoJoinMessage, @@ -1485,6 +1486,12 @@ function PureReportActionItem({ children = } />; } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.MERGED_WITH_CASH_TRANSACTION) { children = ; + } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.CARD_FROZEN || action.actionName === CONST.REPORT.ACTIONS.TYPE.CARD_UNFROZEN) { + children = ( + + ${getReportActionHtml(action)}`} /> + + ); } else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.DISMISSED_VIOLATION)) { children = ; } else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.RESOLVED_DUPLICATES)) { diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index bf89a247bd253..5cfa4fae361fd 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -1266,17 +1266,17 @@ type OriginalMessageCard = { }; /** - * Model of CARDFREEZE action + * Model of CARDFROZEN action */ -type OriginalMessageCardFreeze = OriginalMessageCard & { - /** Whether the action froze or unfroze the card */ - frozen?: boolean; +type OriginalMessageCardFrozen = { + /** HTML content of the system message */ + html: string; - /** Fallback boolean the backend may use to indicate the new frozen state */ - isFrozen?: boolean; + /** Whether the action was generated by NewDot */ + isNewDot?: boolean; - /** Fallback state value the backend may use to indicate the new card state */ - state?: number | string; + /** When the action was last modified */ + lastModified?: string; }; /** @@ -1331,7 +1331,7 @@ type OriginalMessageSettlementAccountLocked = { }; /** - * Original message for Expensify Card system message actions + * Original message for Expensify Card issue/replacement actions */ type IssueNewCardOriginalMessage = OriginalMessage< | typeof CONST.REPORT.ACTIONS.TYPE.CARD_MISSING_ADDRESS @@ -1340,7 +1340,6 @@ type IssueNewCardOriginalMessage = OriginalMessage< | typeof CONST.REPORT.ACTIONS.TYPE.CARD_ASSIGNED | typeof CONST.REPORT.ACTIONS.TYPE.CARD_REPLACED_VIRTUAL | typeof CONST.REPORT.ACTIONS.TYPE.CARD_REPLACED - | typeof CONST.REPORT.ACTIONS.TYPE.CARD_FREEZE >; /** @@ -1449,7 +1448,8 @@ type OriginalMessageMap = { [CONST.REPORT.ACTIONS.TYPE.CARD_REPLACED_VIRTUAL]: OriginalMessageCard; [CONST.REPORT.ACTIONS.TYPE.CARD_REPLACED]: OriginalMessageCard; [CONST.REPORT.ACTIONS.TYPE.CARD_ASSIGNED]: OriginalMessageCard; - [CONST.REPORT.ACTIONS.TYPE.CARD_FREEZE]: OriginalMessageCardFreeze; + [CONST.REPORT.ACTIONS.TYPE.CARD_FROZEN]: OriginalMessageCardFrozen; + [CONST.REPORT.ACTIONS.TYPE.CARD_UNFROZEN]: OriginalMessageCardFrozen; [CONST.REPORT.ACTIONS.TYPE.PERSONAL_CARD_CONNECTION_BROKEN]: OriginalPersonalCard; [CONST.REPORT.ACTIONS.TYPE.INTEGRATION_SYNC_FAILED]: OriginalMessageIntegrationSyncFailed; [CONST.REPORT.ACTIONS.TYPE.DELETED_TRANSACTION]: OriginalMessageDeletedTransaction; diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index d2fd8d4c18ed6..247534575a037 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -1262,6 +1262,101 @@ describe('ReportActionsUtils', () => { const expectedHtml = `${expectedText}`; expect(fragments).toEqual([{text: expectedText, html: expectedHtml, type: 'COMMENT'}]); }); + + it('should preserve backend-provided CARDFROZEN fragments', () => { + const cardFrozenMessage = 'A A froze their Expensify Card (ending in 1384). New transactions will be declined until the card is unfrozen.'; + const action: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.CARD_FROZEN, + reportActionID: 'card-frozen-action-123', + actorAccountID: 21052128, + created: '2026-03-12 01:58:43.479', + message: [ + { + html: cardFrozenMessage, + text: cardFrozenMessage, + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + whisperedTo: [], + }, + ], + originalMessage: { + html: cardFrozenMessage, + isNewDot: true, + lastModified: '2026-03-12 01:58:43.479', + }, + }; + + expect(ReportActionsUtils.getReportActionMessageFragments(translateLocal, action)).toEqual(action.message); + }); + + it('should synthesize fragments from originalMessage.html when CARDFROZEN has no message array entries', () => { + const cardFrozenMessage = 'A A froze their Expensify Card (ending in 1384). New transactions will be declined until the card is unfrozen.'; + const action: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.CARD_FROZEN, + reportActionID: 'card-frozen-action-empty-message', + actorAccountID: 21052128, + created: '2026-03-12 01:58:43.479', + message: [], + originalMessage: { + html: cardFrozenMessage, + isNewDot: true, + lastModified: '2026-03-12 01:58:43.479', + }, + }; + + expect(ReportActionsUtils.getReportActionMessageFragments(translateLocal, action)).toEqual([ + { + html: cardFrozenMessage, + text: cardFrozenMessage, + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + }, + ]); + }); + }); + + describe('getReportActionText', () => { + it('should return the backend-provided CARDFROZEN text', () => { + const cardFrozenMessage = 'A A froze their Expensify Card (ending in 1384). New transactions will be declined until the card is unfrozen.'; + const action: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.CARD_FROZEN, + reportActionID: 'card-frozen-action-123', + actorAccountID: 21052128, + created: '2026-03-12 01:58:43.479', + message: [ + { + html: cardFrozenMessage, + text: cardFrozenMessage, + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + whisperedTo: [], + }, + ], + originalMessage: { + html: cardFrozenMessage, + isNewDot: true, + lastModified: '2026-03-12 01:58:43.479', + }, + }; + + expect(ReportActionsUtils.getReportActionText(action)).toBe(cardFrozenMessage); + }); + + it('should return text from originalMessage.html when CARDUNFROZEN has no message array entries', () => { + const cardUnfrozenMessage = 'A A unfroze their Expensify Card (ending in 1384). This card can now be used for transactions.'; + const action: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.CARD_UNFROZEN, + reportActionID: 'card-unfrozen-action-123', + actorAccountID: 21052128, + created: '2026-03-12 02:08:08.128', + message: [], + originalMessage: { + html: cardUnfrozenMessage, + isNewDot: true, + lastModified: '2026-03-12 02:08:08.128', + }, + }; + + expect(ReportActionsUtils.getReportActionText(action)).toBe(cardUnfrozenMessage); + expect(ReportActionsUtils.shouldReportActionBeVisible(action, action.reportActionID, true)).toBe(true); + }); }); describe('getSendMoneyFlowAction', () => { @@ -1491,6 +1586,23 @@ describe('ReportActionsUtils', () => { const reportAction = buildOptimisticCreatedReportForUnapprovedAction('123456', '789012'); expect(ReportActionsUtils.isDeletedAction(reportAction)).toBe(false); }); + + it('should return false for CARDFROZEN action with empty message array when originalMessage.html is provided', () => { + const reportAction: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.CARD_FROZEN, + reportActionID: 'card-frozen-action-empty-message', + actorAccountID: 21052128, + created: '2026-03-12 01:58:43.479', + message: [], + originalMessage: { + html: 'A A froze their Expensify Card (ending in 1384). New transactions will be declined until the card is unfrozen.', + isNewDot: true, + lastModified: '2026-03-12 01:58:43.479', + }, + }; + + expect(ReportActionsUtils.isDeletedAction(reportAction)).toBe(false); + }); }); describe('getRenamedAction', () => { @@ -1554,29 +1666,6 @@ describe('ReportActionsUtils', () => { } as Card; const testPolicyID = 'test-policy-123'; - const mockCardFreezeAction: ReportAction = { - actionName: CONST.REPORT.ACTIONS.TYPE.CARD_FREEZE, - reportActionID: 'card-freeze-action-123', - actorAccountID: 123, - created: '2024-01-01', - message: [], - originalMessage: { - assigneeAccountID: 456, - cardID: 789, - frozen: true, - }, - } as ReportAction; - - const mockCardUnfreezeAction: ReportAction = { - ...mockCardFreezeAction, - reportActionID: 'card-unfreeze-action-123', - originalMessage: { - assigneeAccountID: 456, - cardID: 789, - frozen: false, - }, - } as ReportAction; - describe('render virtual card issued messages', () => { it('should render a plain text message without card link when no card data is available', () => { const messageResult = getCardIssuedMessage({ @@ -1604,26 +1693,6 @@ describe('ReportActionsUtils', () => { ); }); }); - - describe('render card freeze messages', () => { - it('should render the freeze system message when the card is frozen', () => { - const messageResult = getCardIssuedMessage({ - reportAction: mockCardFreezeAction, - translate: translateLocal, - }); - - expect(messageResult).toBe(`froze @456's Expensify Card.`); - }); - - it('should render the unfreeze system message when the card is unfrozen', () => { - const messageResult = getCardIssuedMessage({ - reportAction: mockCardUnfreezeAction, - translate: translateLocal, - }); - - expect(messageResult).toBe(`unfroze @456's Expensify Card.`); - }); - }); }); describe('shouldReportActionBeVisible', () => { From 0cee92bec5c30f122c1cd2d0be2384bdd4a10162 Mon Sep 17 00:00:00 2001 From: Rajat Parashar Date: Sat, 28 Mar 2026 17:18:31 +0530 Subject: [PATCH 03/57] Update `deleteMoneyRequest` to pass currentuseremail --- src/hooks/useDeleteTransactions.ts | 1 + src/libs/actions/IOU/index.ts | 7 +++++-- src/libs/actions/Search.ts | 1 + tests/actions/IOUTest.ts | 17 +++++++++++++++++ 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/hooks/useDeleteTransactions.ts b/src/hooks/useDeleteTransactions.ts index 3706144844f4c..f9d46afdf8c69 100644 --- a/src/hooks/useDeleteTransactions.ts +++ b/src/hooks/useDeleteTransactions.ts @@ -192,6 +192,7 @@ function useDeleteTransactions({report, reportActions, policy}: UseDeleteTransac selectedTransactionIDs: transactionIDs, allTransactionViolationsParam: transactionViolations, currentUserAccountID: currentUserPersonalDetails.accountID, + currentUserEmail: currentUserPersonalDetails.email ?? '', }); deletedTransactionIDs.push(transactionID); if (action.childReportID) { diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index dbff6fb297f39..5c54d968ebc38 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -862,6 +862,7 @@ type DeleteMoneyRequestFunctionParams = { selectedTransactionIDs?: string[]; allTransactionViolationsParam: OnyxCollection; currentUserAccountID: number; + currentUserEmail: string; }; type PayMoneyRequestFunctionParams = { @@ -8569,6 +8570,7 @@ function deleteMoneyRequest({ selectedTransactionIDs, allTransactionViolationsParam, currentUserAccountID, + currentUserEmail, }: DeleteMoneyRequestFunctionParams) { if (!transactionID) { return; @@ -8655,7 +8657,7 @@ function deleteMoneyRequest({ hasOutstandingChildRequest: hasOutstandingChildRequest( chatReport, updatedIOUReport, - deprecatedCurrentUserEmail, + currentUserEmail, currentUserAccountID, allTransactionViolationsParam, undefined, @@ -8681,7 +8683,7 @@ function deleteMoneyRequest({ hasOutstandingChildRequest: hasOutstandingChildRequest( chatReport, iouReport?.reportID, - deprecatedCurrentUserEmail, + currentUserEmail, currentUserAccountID, allTransactionViolationsParam, undefined, @@ -8897,6 +8899,7 @@ function deleteTrackExpense({ isSingleTransactionView, allTransactionViolationsParam, currentUserAccountID, + currentUserEmail: deprecatedCurrentUserEmail, }); return urlToNavigateBack; } diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 1838aae73a576..1748f71fdd2e9 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -937,6 +937,7 @@ function bulkDeleteReports({ selectedTransactionIDs: batchTransactionIDsForReport.length > 0 ? batchTransactionIDsForReport : undefined, allTransactionViolationsParam: transactionsViolations, currentUserAccountID: currentUserAccountIDParam, + currentUserEmail: currentUserEmailParam, }); } diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index dd84a38cc8b15..0eba6538f6079 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -7721,6 +7721,7 @@ describe('actions/IOU', () => { isChatIOUReportArchived: true, allTransactionViolationsParam: {}, currentUserAccountID: TEST_USER_ACCOUNT_ID, + currentUserEmail: TEST_USER_LOGIN, }); } await waitForBatchedUpdates(); @@ -7810,6 +7811,7 @@ describe('actions/IOU', () => { isChatIOUReportArchived: true, allTransactionViolationsParam: {}, currentUserAccountID: TEST_USER_ACCOUNT_ID, + currentUserEmail: TEST_USER_LOGIN, }); } await waitForBatchedUpdates(); @@ -7892,6 +7894,7 @@ describe('actions/IOU', () => { chatReport, allTransactionViolationsParam: {}, currentUserAccountID: TEST_USER_ACCOUNT_ID, + currentUserEmail: TEST_USER_LOGIN, }); } await waitForBatchedUpdates(); @@ -8002,6 +8005,7 @@ describe('actions/IOU', () => { chatReport, allTransactionViolationsParam: {}, currentUserAccountID: TEST_USER_ACCOUNT_ID, + currentUserEmail: TEST_USER_LOGIN, }); } await waitForBatchedUpdates(); @@ -8149,6 +8153,7 @@ describe('actions/IOU', () => { chatReport, allTransactionViolationsParam: {}, currentUserAccountID: TEST_USER_ACCOUNT_ID, + currentUserEmail: TEST_USER_LOGIN, }); } await waitForBatchedUpdates(); @@ -8261,6 +8266,7 @@ describe('actions/IOU', () => { chatReport, allTransactionViolationsParam: {}, currentUserAccountID: TEST_USER_ACCOUNT_ID, + currentUserEmail: TEST_USER_LOGIN, }); } await waitForBatchedUpdates(); @@ -8446,6 +8452,7 @@ describe('actions/IOU', () => { isChatIOUReportArchived: undefined, allTransactionViolationsParam: {}, currentUserAccountID: TEST_USER_ACCOUNT_ID, + currentUserEmail: TEST_USER_LOGIN, }); } await waitForBatchedUpdates(); @@ -8559,6 +8566,7 @@ describe('actions/IOU', () => { isChatIOUReportArchived: undefined, allTransactionViolationsParam: {}, currentUserAccountID: TEST_USER_ACCOUNT_ID, + currentUserEmail: TEST_USER_LOGIN, }); } await waitForBatchedUpdates(); @@ -8662,6 +8670,7 @@ describe('actions/IOU', () => { isSingleTransactionView: true, allTransactionViolationsParam: {}, currentUserAccountID: TEST_USER_ACCOUNT_ID, + currentUserEmail: TEST_USER_LOGIN, }); } @@ -8719,6 +8728,7 @@ describe('actions/IOU', () => { chatReport, allTransactionViolationsParam: {}, currentUserAccountID: TEST_USER_ACCOUNT_ID, + currentUserEmail: TEST_USER_LOGIN, }); } // Then we expect to navigate to the chat report @@ -8882,6 +8892,7 @@ describe('actions/IOU', () => { chatReport, allTransactionViolationsParam: {}, currentUserAccountID: TEST_USER_ACCOUNT_ID, + currentUserEmail: TEST_USER_LOGIN, }); } @@ -9210,6 +9221,7 @@ describe('actions/IOU', () => { describe('bulk deleteMoneyRequest', () => { const TEST_USER_ACCOUNT_ID = 1; + const TEST_USER_LOGIN = 'test@email.com'; it('update IOU report total properly for bulk deletion of expenses', async () => { const expenseReport: Report = { @@ -9274,6 +9286,7 @@ describe('actions/IOU', () => { selectedTransactionIDs, allTransactionViolationsParam: {}, currentUserAccountID: TEST_USER_ACCOUNT_ID, + currentUserEmail: TEST_USER_LOGIN, }); deleteMoneyRequest({ transactionID: transaction2.transactionID, @@ -9286,6 +9299,7 @@ describe('actions/IOU', () => { selectedTransactionIDs, allTransactionViolationsParam: {}, currentUserAccountID: TEST_USER_ACCOUNT_ID, + currentUserEmail: TEST_USER_LOGIN, }); await waitForBatchedUpdates(); @@ -9307,6 +9321,7 @@ describe('actions/IOU', () => { describe('deleteMoneyRequest with allTransactionViolationsParam', () => { const TEST_USER_ACCOUNT_ID = 1; + const TEST_USER_LOGIN = 'test@email.com'; it('should pass transaction violations to hasOutstandingChildRequest correctly', async () => { // Given an expense report with a transaction const expenseReport: Report = { @@ -9361,6 +9376,7 @@ describe('actions/IOU', () => { chatReport: expenseReport, allTransactionViolationsParam: transactionViolations, currentUserAccountID: TEST_USER_ACCOUNT_ID, + currentUserEmail: TEST_USER_LOGIN, }); await waitForBatchedUpdates(); @@ -9424,6 +9440,7 @@ describe('actions/IOU', () => { chatReport: expenseReport, allTransactionViolationsParam: {}, currentUserAccountID: TEST_USER_ACCOUNT_ID, + currentUserEmail: TEST_USER_LOGIN, }); await waitForBatchedUpdates(); From 80aee86a026b85390c9b84abfa7ca12aa665cc3a Mon Sep 17 00:00:00 2001 From: Rajat Parashar Date: Sat, 28 Mar 2026 17:23:28 +0530 Subject: [PATCH 04/57] fix --- src/libs/actions/IOU/index.ts | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 5c54d968ebc38..c23a2427aa28a 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -8654,14 +8654,7 @@ function deleteMoneyRequest({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`, value: { - hasOutstandingChildRequest: hasOutstandingChildRequest( - chatReport, - updatedIOUReport, - currentUserEmail, - currentUserAccountID, - allTransactionViolationsParam, - undefined, - ), + hasOutstandingChildRequest: hasOutstandingChildRequest(chatReport, updatedIOUReport, currentUserEmail, currentUserAccountID, allTransactionViolationsParam, undefined), }, }); } @@ -8680,14 +8673,7 @@ function deleteMoneyRequest({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`, value: { - hasOutstandingChildRequest: hasOutstandingChildRequest( - chatReport, - iouReport?.reportID, - currentUserEmail, - currentUserAccountID, - allTransactionViolationsParam, - undefined, - ), + hasOutstandingChildRequest: hasOutstandingChildRequest(chatReport, iouReport?.reportID, currentUserEmail, currentUserAccountID, allTransactionViolationsParam, undefined), iouReportID: null, ...optimisticLastReportData, }, From a77cdfab9accdd98d00b3cadc9e4e6b49ee9321d Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 1 Apr 2026 12:11:32 +0200 Subject: [PATCH 05/57] Clean up ReportActionCompose: remove manual memo, dead code, derive state Co-Authored-By: Claude Opus 4.6 (1M context) --- .../AgentZeroAwareTypingIndicator.tsx | 13 + .../ReportActionCompose.tsx | 439 +++++++----------- .../report/ReportActionCompose/SendButton.tsx | 4 +- .../ReportActionCompose/SuggestionEmoji.tsx | 213 ++++----- .../useAttachmentUploadValidation.ts | 143 +++--- tests/ui/ReportActionComposeTest.tsx | 10 +- 6 files changed, 353 insertions(+), 469 deletions(-) create mode 100644 src/pages/inbox/report/ReportActionCompose/AgentZeroAwareTypingIndicator.tsx diff --git a/src/pages/inbox/report/ReportActionCompose/AgentZeroAwareTypingIndicator.tsx b/src/pages/inbox/report/ReportActionCompose/AgentZeroAwareTypingIndicator.tsx new file mode 100644 index 0000000000000..835cc1323b4f1 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/AgentZeroAwareTypingIndicator.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import useShouldSuppressConciergeIndicators from '@hooks/useShouldSuppressConciergeIndicators'; +import ReportTypingIndicator from '@pages/inbox/report/ReportTypingIndicator'; + +function AgentZeroAwareTypingIndicator({reportID}: {reportID: string}) { + const shouldSuppress = useShouldSuppressConciergeIndicators(reportID); + if (shouldSuppress) { + return null; + } + return ; +} + +export default AgentZeroAwareTypingIndicator; diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 64b7d7327708f..d113f545d6451 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -1,8 +1,7 @@ import {useRoute} from '@react-navigation/native'; import {Str} from 'expensify-common'; import lodashDebounce from 'lodash/debounce'; -import noop from 'lodash/noop'; -import React, {memo, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useContext, useEffect, useRef, useState} from 'react'; import type {BlurEvent, MeasureInWindowOnSuccessCallback, TextInputSelectionChangeEvent} from 'react-native'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -36,7 +35,6 @@ import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useShortMentionsList from '@hooks/useShortMentionsList'; -import useShouldSuppressConciergeIndicators from '@hooks/useShouldSuppressConciergeIndicators'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {addComment} from '@libs/actions/Report'; @@ -84,7 +82,6 @@ import {generateAccountID} from '@libs/UserUtils'; import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; import {useAgentZeroStatusActions} from '@pages/inbox/AgentZeroStatusContext'; import ParticipantLocalTime from '@pages/inbox/report/ParticipantLocalTime'; -import ReportTypingIndicator from '@pages/inbox/report/ReportTypingIndicator'; import {ActionListContext} from '@pages/inbox/ReportScreenContext'; import {hideEmojiPicker, isActive as isActiveEmojiPickerAction, isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; import {addAttachmentWithComment, setIsComposerFullSize} from '@userActions/Report'; @@ -95,6 +92,7 @@ import SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; import type {FileObject} from '@src/types/utils/Attachment'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import AgentZeroAwareTypingIndicator from './AgentZeroAwareTypingIndicator'; import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; import ComposerWithSuggestions from './ComposerWithSuggestions'; import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; @@ -116,23 +114,12 @@ type ReportActionComposeProps = { reportID: string; }; -function AgentZeroAwareTypingIndicator({reportID}: {reportID: string}) { - const shouldSuppress = useShouldSuppressConciergeIndicators(reportID); - if (shouldSuppress) { - return null; - } - return ; -} - // We want consistent auto focus behavior on input between native and mWeb so we have some auto focus management code that will // prevent auto focus on existing chat for mobile device const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); -// eslint-disable-next-line import/no-mutable-exports -let onSubmitAction = noop; - function ReportActionCompose({reportID}: ReportActionComposeProps) { const styles = useThemeStyles(); const theme = useTheme(); @@ -157,33 +144,21 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); const {reportActions: unfilteredReportActions} = usePaginatedReportActions(report?.reportID); - const filteredReportActions = useMemo(() => getFilteredReportActionsForReportView(unfilteredReportActions), [unfilteredReportActions]); + const filteredReportActions = getFilteredReportActionsForReportView(unfilteredReportActions); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`); const allReportTransactions = useReportTransactionsCollection(reportID); - const reportTransactions = useMemo( - () => getAllNonDeletedTransactions(allReportTransactions, filteredReportActions, isOffline, true), - [allReportTransactions, filteredReportActions, isOffline], - ); - const visibleTransactions = useMemo( - () => reportTransactions?.filter((transaction) => isOffline || transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE), - [reportTransactions, isOffline], - ); - const reportTransactionIDs = useMemo(() => visibleTransactions?.map((t) => t.transactionID), [visibleTransactions]); - const isSentMoneyReport = useMemo(() => filteredReportActions.some((action) => isSentMoneyReportAction(action)), [filteredReportActions]); - const transactionThreadReportID = useMemo( - () => getOneTransactionThreadReportID(report, chatReport, filteredReportActions, isOffline, reportTransactionIDs), - [report, chatReport, filteredReportActions, isOffline, reportTransactionIDs], - ); + const reportTransactions = getAllNonDeletedTransactions(allReportTransactions, filteredReportActions, isOffline, true); + const visibleTransactions = reportTransactions?.filter((transaction) => isOffline || transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + const reportTransactionIDs = visibleTransactions?.map((t) => t.transactionID); + const isSentMoneyReport = filteredReportActions.some((action) => isSentMoneyReportAction(action)); + const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, filteredReportActions, isOffline, reportTransactionIDs); const effectiveTransactionThreadReportID = isSentMoneyReport ? undefined : transactionThreadReportID; const parentReportAction = useParentReportAction(report); const [transactionThreadReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${effectiveTransactionThreadReportID}`); - const transactionThreadReportActionsArray = useMemo(() => (transactionThreadReportActions ? Object.values(transactionThreadReportActions) : []), [transactionThreadReportActions]); - const combinedReportActions = useMemo( - () => getCombinedReportActions(filteredReportActions, effectiveTransactionThreadReportID ?? null, transactionThreadReportActionsArray), - [filteredReportActions, effectiveTransactionThreadReportID, transactionThreadReportActionsArray], - ); + const transactionThreadReportActionsArray = transactionThreadReportActions ? Object.values(transactionThreadReportActions) : []; + const combinedReportActions = getCombinedReportActions(filteredReportActions, effectiveTransactionThreadReportID ?? null, transactionThreadReportActionsArray); const route = useRoute(); const isOnSearchMoneyRequestReport = route.name === SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT || route.name === SCREENS.RIGHT_MODAL.EXPENSE_REPORT; @@ -195,10 +170,7 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { // because ReportActionsView merges thread comments into the visible list, and up-arrow-to-edit // should be able to reach those comments. const actionsForLastEditable = isOnSearchMoneyRequestReport ? filteredReportActions : combinedReportActions; - const lastReportAction = useMemo( - () => [...actionsForLastEditable, parentReportAction].find((action) => !isMoneyRequestAction(action) && canEditReportAction(action, undefined)), - [actionsForLastEditable, parentReportAction], - ); + const lastReportAction = [...actionsForLastEditable, parentReportAction].find((action) => !isMoneyRequestAction(action) && canEditReportAction(action, undefined)); const {reportPendingAction: pendingAction} = getReportOfflinePendingActionAndErrors(report); @@ -243,31 +215,33 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { */ const {hasExceededMaxCommentLength, validateCommentMaxLength, setHasExceededMaxCommentLength} = useHandleExceedMaxCommentLength(); const {hasExceededMaxTaskTitleLength, validateTaskTitleMaxLength, setHasExceededMaxTitleLength} = useHandleExceedMaxTaskTitleLength(); - const [exceededMaxLength, setExceededMaxLength] = useState(null); + + const exceededMaxLength = (() => { + if (hasExceededMaxTaskTitleLength) { + return CONST.TITLE_CHARACTER_LIMIT; + } + if (hasExceededMaxCommentLength) { + return CONST.MAX_COMMENT_LENGTH; + } + return null; + })(); const icons = useMemoizedLazyExpensifyIcons(['MessageInABottle']); const suggestionsRef = useRef(null); const composerRef = useRef(null); - const reportParticipantIDs = useMemo( - () => - Object.keys(report?.participants ?? {}) - .map(Number) - .filter((accountID) => accountID !== currentUserPersonalDetails.accountID), - [currentUserPersonalDetails.accountID, report?.participants], - ); + const reportParticipantIDs = Object.keys(report?.participants ?? {}) + .map(Number) + .filter((accountID) => accountID !== currentUserPersonalDetails.accountID); - const shouldShowReportRecipientLocalTime = useMemo( - () => canShowReportRecipientLocalTime(personalDetails, report, currentUserPersonalDetails.accountID) && !isComposerFullSize, - [personalDetails, report, currentUserPersonalDetails.accountID, isComposerFullSize], - ); + const shouldShowReportRecipientLocalTime = canShowReportRecipientLocalTime(personalDetails, report, currentUserPersonalDetails.accountID) && !isComposerFullSize; - const includesConcierge = useMemo(() => chatIncludesConcierge({participants: report?.participants}), [report?.participants]); - const userBlockedFromConcierge = useMemo(() => isBlockedFromConciergeUserAction(blockedFromConcierge), [blockedFromConcierge]); - const isBlockedFromConcierge = useMemo(() => includesConcierge && userBlockedFromConcierge, [includesConcierge, userBlockedFromConcierge]); + const includesConcierge = chatIncludesConcierge({participants: report?.participants}); + const userBlockedFromConcierge = isBlockedFromConciergeUserAction(blockedFromConcierge); + const isBlockedFromConcierge = includesConcierge && userBlockedFromConcierge; const isReportArchived = useReportIsArchived(report?.reportID); - const isTransactionThreadView = useMemo(() => isReportTransactionThread(report), [report]); - const isExpensesReport = useMemo(() => reportTransactions && reportTransactions.length > 1, [reportTransactions]); + const isTransactionThreadView = isReportTransactionThread(report); + const isExpensesReport = reportTransactions && reportTransactions.length > 1; const [rawReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`, { canEvict: false, @@ -276,11 +250,11 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { const iouAction = rawReportActions ? Object.values(rawReportActions).find((action) => isMoneyRequestAction(action)) : null; const linkedTransactionID = iouAction && !isExpensesReport ? getLinkedTransactionID(iouAction) : undefined; - const transactionID = useMemo(() => getTransactionID(report) ?? linkedTransactionID, [report, linkedTransactionID]); + const transactionID = getTransactionID(report) ?? linkedTransactionID; const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transactionID)}`); - const isSingleTransactionView = useMemo(() => !!transaction && !!reportTransactions && reportTransactions.length === 1, [transaction, reportTransactions]); + const isSingleTransactionView = !!transaction && !!reportTransactions && reportTransactions.length === 1; const effectiveParentReportAction = isSingleTransactionView ? iouAction : getReportAction(report?.parentReportID, report?.parentReportActionID); const canUserPerformWriteAction = !!canUserPerformWriteActionReportUtils(report, isReportArchived); const canEditReceipt = @@ -289,24 +263,19 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { !transaction?.receipt?.isTestDriveReceipt; const shouldAddOrReplaceReceipt = (isTransactionThreadView || isSingleTransactionView) && canEditReceipt; - const hasReceipt = useMemo(() => hasReceiptTransactionUtils(transaction), [transaction]); + const hasReceipt = hasReceiptTransactionUtils(transaction); - const shouldDisplayDualDropZone = useMemo(() => { + const shouldDisplayDualDropZone = (() => { const parentReport = getParentReport(report); const isSettledOrApproved = isSettled(report) || isSettled(parentReport) || isReportApproved({report}) || isReportApproved({report: parentReport}); const hasMoneyRequestOptions = !!temporary_getMoneyRequestOptions(report, policy, reportParticipantIDs, betas, isReportArchived, isRestrictedToPreferredPolicy).length; const canModifyReceipt = shouldAddOrReplaceReceipt && !isSettledOrApproved; const isRoomOrGroupChat = isChatRoom(report) || isGroupChat(report); return !isRoomOrGroupChat && (canModifyReceipt || hasMoneyRequestOptions) && !isInvoiceReport(report); - }, [shouldAddOrReplaceReceipt, report, reportParticipantIDs, policy, isReportArchived, isRestrictedToPreferredPolicy, betas]); + })(); // Placeholder to display in the chat input. - const inputPlaceholder = useMemo(() => { - if (includesConcierge && userBlockedFromConcierge) { - return translate('reportActionCompose.blockedFromConcierge'); - } - return translate('reportActionCompose.writeSomething'); - }, [includesConcierge, translate, userBlockedFromConcierge]); + const inputPlaceholder = includesConcierge && userBlockedFromConcierge ? translate('reportActionCompose.blockedFromConcierge') : translate('reportActionCompose.writeSomething'); const focus = () => { if (composerRef.current === null) { @@ -319,39 +288,34 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { const isNextModalWillOpenRef = useRef(false); const containerRef = useRef(null); - const measureContainer = useCallback( - (callback: MeasureInWindowOnSuccessCallback) => { - if (!containerRef.current) { - return; - } - containerRef.current.measureInWindow(callback); - }, - // We added isComposerFullSize in dependencies so that when this value changes, we recalculate the position of the popup - // eslint-disable-next-line react-hooks/exhaustive-deps - [isComposerFullSize], - ); + const measureContainer = (callback: MeasureInWindowOnSuccessCallback) => { + if (!containerRef.current) { + return; + } + containerRef.current.measureInWindow(callback); + }; - const onAddActionPressed = useCallback(() => { + const onAddActionPressed = () => { if (!willBlurTextInputOnTapOutside) { isKeyboardVisibleWhenShowingModalRef.current = !!composerRef.current?.isFocused(); } composerRef.current?.blur(); - }, []); + }; - const onItemSelected = useCallback(() => { + const onItemSelected = () => { isKeyboardVisibleWhenShowingModalRef.current = false; - }, []); + }; - const updateShouldShowSuggestionMenuToFalse = useCallback(() => { + const updateShouldShowSuggestionMenuToFalse = () => { if (!suggestionsRef.current) { return; } suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false); - }, []); + }; const attachmentFileRef = useRef(null); - const addAttachment = useCallback((file: FileObject | FileObject[]) => { + const addAttachment = (file: FileObject | FileObject[]) => { attachmentFileRef.current = file; const clearWorklet = composerRef.current?.clearWorklet; @@ -361,137 +325,119 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { } scheduleOnUI(clearWorklet); - }, []); + }; /** * Event handler to update the state after the attachment preview is closed. */ - const onAttachmentPreviewClose = useCallback(() => { + const onAttachmentPreviewClose = () => { updateShouldShowSuggestionMenuToFalse(); setIsAttachmentPreviewActive(false); // This enables Composer refocus when the attachments modal is closed by the browser navigation ComposerFocusManager.setReadyToFocus(); - }, [updateShouldShowSuggestionMenuToFalse]); + }; /** * Add a new comment to this chat */ - const submitForm = useCallback( - (newComment: string) => { - const newCommentTrimmed = newComment.trim(); - - kickoffWaitingIndicator(); - - if (attachmentFileRef.current) { - addAttachmentWithComment({ - report: targetReport, - notifyReportID: reportID, - ancestors: targetReportAncestors, - attachments: attachmentFileRef.current, - currentUserAccountID: currentUserPersonalDetails.accountID, - text: newCommentTrimmed, - timezone: currentUserPersonalDetails.timezone, - shouldPlaySound: true, - isInSidePanel, - }); - attachmentFileRef.current = null; - } else { - const taskMatch = newCommentTrimmed.match(CONST.REGEX.TASK_TITLE_WITH_OPTIONAL_SHORT_MENTION); - if (taskMatch) { - let taskTitle = taskMatch[3] ? taskMatch[3].trim().replaceAll('\n', ' ') : undefined; - if (taskTitle) { - const mention = taskMatch[1] ? taskMatch[1].trim() : ''; - const currentUserPrivateDomain = isEmailPublicDomain(currentUserEmail) ? '' : Str.extractEmailDomain(currentUserEmail); - const mentionWithDomain = addDomainToShortMention(mention, availableLoginsList, currentUserPrivateDomain) ?? mention; - const isValidMention = Str.isValidEmail(mentionWithDomain); - - let assignee: OnyxEntry; - let assigneeChatReport; - if (mentionWithDomain) { - if (isValidMention) { - assignee = Object.values(personalDetails ?? {}).find((value) => value?.login === mentionWithDomain) ?? undefined; - if (!Object.keys(assignee ?? {}).length) { - const optimisticDataForNewAssignee = setNewOptimisticAssignee(currentUserPersonalDetails.accountID, { - accountID: generateAccountID(mentionWithDomain), - login: mentionWithDomain, - }); - assignee = optimisticDataForNewAssignee.assignee; - assigneeChatReport = optimisticDataForNewAssignee.assigneeReport; - } - } else { - taskTitle = `@${mentionWithDomain} ${taskTitle}`; + const submitForm = (newComment: string) => { + const newCommentTrimmed = newComment.trim(); + + kickoffWaitingIndicator(); + + if (attachmentFileRef.current) { + addAttachmentWithComment({ + report: targetReport, + notifyReportID: reportID, + ancestors: targetReportAncestors, + attachments: attachmentFileRef.current, + currentUserAccountID: currentUserPersonalDetails.accountID, + text: newCommentTrimmed, + timezone: currentUserPersonalDetails.timezone, + shouldPlaySound: true, + isInSidePanel, + }); + attachmentFileRef.current = null; + } else { + const taskMatch = newCommentTrimmed.match(CONST.REGEX.TASK_TITLE_WITH_OPTIONAL_SHORT_MENTION); + if (taskMatch) { + let taskTitle = taskMatch[3] ? taskMatch[3].trim().replaceAll('\n', ' ') : undefined; + if (taskTitle) { + const mention = taskMatch[1] ? taskMatch[1].trim() : ''; + const currentUserPrivateDomain = isEmailPublicDomain(currentUserEmail) ? '' : Str.extractEmailDomain(currentUserEmail); + const mentionWithDomain = addDomainToShortMention(mention, availableLoginsList, currentUserPrivateDomain) ?? mention; + const isValidMention = Str.isValidEmail(mentionWithDomain); + + let assignee: OnyxEntry; + let assigneeChatReport; + if (mentionWithDomain) { + if (isValidMention) { + assignee = Object.values(personalDetails ?? {}).find((value) => value?.login === mentionWithDomain) ?? undefined; + if (!Object.keys(assignee ?? {}).length) { + const optimisticDataForNewAssignee = setNewOptimisticAssignee(currentUserPersonalDetails.accountID, { + accountID: generateAccountID(mentionWithDomain), + login: mentionWithDomain, + }); + assignee = optimisticDataForNewAssignee.assignee; + assigneeChatReport = optimisticDataForNewAssignee.assigneeReport; } + } else { + taskTitle = `@${mentionWithDomain} ${taskTitle}`; } - createTaskAndNavigate({ - parentReport: report, - title: taskTitle, - description: '', - assigneeEmail: assignee?.login ?? '', - currentUserAccountID: currentUserPersonalDetails.accountID, - currentUserEmail, - assigneeAccountID: assignee?.accountID, - assigneeChatReport, - policyID: report?.policyID, - isCreatedUsingMarkdown: true, - quickAction, - ancestors: reportAncestors, - }); - return; } - } - - // Pre-generate the reportActionID so we can correlate the Sentry send-message span with the exact message - const optimisticReportActionID = rand64(); - - // The list is inverted, so an offset near 0 means the user is at the bottom (newest messages visible). - const isScrolledToBottom = scrollOffsetRef.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; - if (isScrolledToBottom) { - startSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE}_${optimisticReportActionID}`, { - name: 'send-message', - op: CONST.TELEMETRY.SPAN_SEND_MESSAGE, - attributes: { - [CONST.TELEMETRY.ATTRIBUTE_REPORT_ID]: reportID, - [CONST.TELEMETRY.ATTRIBUTE_MESSAGE_LENGTH]: newCommentTrimmed.length, - }, + createTaskAndNavigate({ + parentReport: report, + title: taskTitle, + description: '', + assigneeEmail: assignee?.login ?? '', + currentUserAccountID: currentUserPersonalDetails.accountID, + currentUserEmail, + assigneeAccountID: assignee?.accountID, + assigneeChatReport, + policyID: report?.policyID, + isCreatedUsingMarkdown: true, + quickAction, + ancestors: reportAncestors, }); + return; } - addComment({ - report: targetReport, - notifyReportID: reportID, - ancestors: targetReportAncestors, - text: newCommentTrimmed, - timezoneParam: currentUserPersonalDetails.timezone ?? CONST.DEFAULT_TIME_ZONE, - currentUserAccountID: currentUserPersonalDetails.accountID, - shouldPlaySound: true, - isInSidePanel, - reportActionID: optimisticReportActionID, + } + + // Pre-generate the reportActionID so we can correlate the Sentry send-message span with the exact message + const optimisticReportActionID = rand64(); + + // The list is inverted, so an offset near 0 means the user is at the bottom (newest messages visible). + const isScrolledToBottom = scrollOffsetRef.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; + if (isScrolledToBottom) { + startSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE}_${optimisticReportActionID}`, { + name: 'send-message', + op: CONST.TELEMETRY.SPAN_SEND_MESSAGE, + attributes: { + [CONST.TELEMETRY.ATTRIBUTE_REPORT_ID]: reportID, + [CONST.TELEMETRY.ATTRIBUTE_MESSAGE_LENGTH]: newCommentTrimmed.length, + }, }); } - }, - [ - kickoffWaitingIndicator, - targetReport, - report, - reportID, - targetReportAncestors, - reportAncestors, - currentUserPersonalDetails.accountID, - currentUserPersonalDetails.timezone, - isInSidePanel, - currentUserEmail, - availableLoginsList, - personalDetails, - quickAction, - scrollOffsetRef, - ], - ); + addComment({ + report: targetReport, + notifyReportID: reportID, + ancestors: targetReportAncestors, + text: newCommentTrimmed, + timezoneParam: currentUserPersonalDetails.timezone ?? CONST.DEFAULT_TIME_ZONE, + currentUserAccountID: currentUserPersonalDetails.accountID, + shouldPlaySound: true, + isInSidePanel, + reportActionID: optimisticReportActionID, + }); + } + }; - const onTriggerAttachmentPicker = useCallback(() => { + const onTriggerAttachmentPicker = () => { isNextModalWillOpenRef.current = true; isKeyboardVisibleWhenShowingModalRef.current = true; - }, []); + }; - const onBlur = useCallback((event: BlurEvent) => { + const onBlur = (event: BlurEvent) => { const webEvent = event as unknown as FocusEvent; setIsFocused(false); if (suggestionsRef.current) { @@ -500,21 +446,11 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { if (webEvent.relatedTarget && webEvent.relatedTarget === actionButtonRef.current) { isKeyboardVisibleWhenShowingModalRef.current = true; } - }, []); + }; - const onFocus = useCallback(() => { + const onFocus = () => { setIsFocused(true); - }, []); - - useEffect(() => { - if (hasExceededMaxTaskTitleLength) { - setExceededMaxLength(CONST.TITLE_CHARACTER_LIMIT); - } else if (hasExceededMaxCommentLength) { - setExceededMaxLength(CONST.MAX_COMMENT_LENGTH); - } else { - setExceededMaxLength(null); - } - }, [hasExceededMaxTaskTitleLength, hasExceededMaxCommentLength]); + }; useEffect(() => { if (didHideComposerInput || shouldShowComposeInput) { @@ -525,7 +461,7 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { setDidHideComposerInput(true); }, [shouldShowComposeInput, didHideComposerInput]); - // We are returning a callback here as we want to invoke the method on unmount only + // Hide emoji picker on unmount or when switching reports useEffect( () => () => { if (!isActiveEmojiPickerAction(report?.reportID)) { @@ -533,12 +469,11 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { } hideEmojiPicker(); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [], + [report?.reportID], ); // When we invite someone to a room they don't have the policy object, but we still want them to be able to mention other reports they are members of, so we only check if the policyID in the report is from a workspace - const isGroupPolicyReport = useMemo(() => !!report?.policyID && report.policyID !== CONST.POLICY.ID_FAKE, [report?.policyID]); + const isGroupPolicyReport = !!report?.policyID && report.policyID !== CONST.POLICY.ID_FAKE; const reportRecipientAccountIDs = getReportRecipientAccountIDs(report, currentUserPersonalDetails.accountID); const reportRecipient = personalDetails?.[reportRecipientAccountIDs[0]]; const shouldUseFocusedColor = !isBlockedFromConcierge && isFocused; @@ -547,27 +482,24 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { 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 validateMaxLength = (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}); + }; - const debouncedValidate = useMemo(() => lodashDebounce(validateMaxLength, CONST.TIMING.COMMENT_LENGTH_DEBOUNCE_TIME, {leading: true}), [validateMaxLength]); + const debouncedValidate = lodashDebounce(validateMaxLength, CONST.TIMING.COMMENT_LENGTH_DEBOUNCE_TIME, {leading: true}); // 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 handleSendMessage = useCallback(() => { + const handleSendMessage = () => { if (isSendDisabled || !debouncedValidate.flush()) { return; } @@ -586,49 +518,21 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { clearWorklet?.(); }); - }, [isSendDisabled, debouncedValidate, isComposerFullSize, reportID, composerRefShared]); - - onSubmitAction = handleSendMessage; - - const emojiPositionValues = useMemo( - () => ({ - secondaryRowHeight: styles.chatItemComposeSecondaryRow.height, - secondaryRowMarginTop: styles.chatItemComposeSecondaryRow.marginTop, - secondaryRowMarginBottom: styles.chatItemComposeSecondaryRow.marginBottom, - composeBoxMinHeight: styles.chatItemComposeBox.minHeight, - emojiButtonHeight: styles.chatItemEmojiButton.height, - }), - [ - styles.chatItemComposeSecondaryRow.height, - styles.chatItemComposeSecondaryRow.marginTop, - styles.chatItemComposeSecondaryRow.marginBottom, - styles.chatItemComposeBox.minHeight, - styles.chatItemEmojiButton.height, - ], - ); + }; - const emojiShiftVertical = useMemo(() => { - const chatItemComposeSecondaryRowHeight = emojiPositionValues.secondaryRowHeight + emojiPositionValues.secondaryRowMarginTop + emojiPositionValues.secondaryRowMarginBottom; - const reportActionComposeHeight = emojiPositionValues.composeBoxMinHeight + chatItemComposeSecondaryRowHeight; - const emojiOffsetWithComposeBox = (emojiPositionValues.composeBoxMinHeight - emojiPositionValues.emojiButtonHeight) / 2; + const emojiShiftVertical = (() => { + const chatItemComposeSecondaryRowHeight = styles.chatItemComposeSecondaryRow.height + styles.chatItemComposeSecondaryRow.marginTop + styles.chatItemComposeSecondaryRow.marginBottom; + const reportActionComposeHeight = styles.chatItemComposeBox.minHeight + chatItemComposeSecondaryRowHeight; + const emojiOffsetWithComposeBox = (styles.chatItemComposeBox.minHeight - styles.chatItemEmojiButton.height) / 2; return reportActionComposeHeight - emojiOffsetWithComposeBox - CONST.MENU_POSITION_REPORT_ACTION_COMPOSE_BOTTOM; - }, [ - emojiPositionValues.secondaryRowHeight, - emojiPositionValues.secondaryRowMarginTop, - emojiPositionValues.secondaryRowMarginBottom, - emojiPositionValues.composeBoxMinHeight, - emojiPositionValues.emojiButtonHeight, - ]); - - const onValueChange = useCallback( - (value: string) => { - if (value.length === 0 && isComposerFullSize) { - setIsComposerFullSize(reportID, false); - } - debouncedValidate(value); - }, - [isComposerFullSize, reportID, debouncedValidate], - ); + })(); + + const onValueChange = (value: string) => { + if (value.length === 0 && isComposerFullSize) { + setIsComposerFullSize(reportID, false); + } + debouncedValidate(value); + }; const {validateAttachments, onReceiptDropped, PDFValidationComponent, ErrorModal} = useAttachmentUploadValidation({ policy, @@ -803,6 +707,5 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { ); } -export default memo(ReportActionCompose); -export {onSubmitAction}; +export default ReportActionCompose; export type {SuggestionsRef, ComposerRef, ReportActionComposeProps}; diff --git a/src/pages/inbox/report/ReportActionCompose/SendButton.tsx b/src/pages/inbox/report/ReportActionCompose/SendButton.tsx index 34f2030933c3b..5b0e5a8e41a06 100644 --- a/src/pages/inbox/report/ReportActionCompose/SendButton.tsx +++ b/src/pages/inbox/report/ReportActionCompose/SendButton.tsx @@ -1,4 +1,4 @@ -import React, {memo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import {Gesture, GestureDetector} from 'react-native-gesture-handler'; import Icon from '@components/Icon'; @@ -79,4 +79,4 @@ function SendButton({isDisabled: isDisabledProp, handleSendMessage}: SendButtonP ); } -export default memo(SendButton); +export default SendButton; diff --git a/src/pages/inbox/report/ReportActionCompose/SuggestionEmoji.tsx b/src/pages/inbox/report/ReportActionCompose/SuggestionEmoji.tsx index 79c55c5ffbc0b..3f2a4205d0944 100644 --- a/src/pages/inbox/report/ReportActionCompose/SuggestionEmoji.tsx +++ b/src/pages/inbox/report/ReportActionCompose/SuggestionEmoji.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; +import React, {useEffect, useImperativeHandle, useRef, useState} from 'react'; import type {Emoji} from '@assets/emojis/types'; import EmojiSuggestions from '@components/EmojiSuggestions'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; @@ -68,134 +68,118 @@ function SuggestionEmoji({ /** * Replace the code of emoji and update selection - * @param {Number} selectedEmoji */ - const insertSelectedEmoji = useCallback( - (highlightedEmojiIndexInner: number) => { - const emojiObject = highlightedEmojiIndexInner !== -1 ? suggestionValues.suggestedEmojis.at(highlightedEmojiIndexInner) : undefined; - if (!emojiObject) { - return; - } + const insertSelectedEmoji = (highlightedEmojiIndexInner: number) => { + const emojiObject = highlightedEmojiIndexInner !== -1 ? suggestionValues.suggestedEmojis.at(highlightedEmojiIndexInner) : undefined; + if (!emojiObject) { + return; + } - const commentBeforeColon = value.slice(0, suggestionValues.colonIndex); - const commentAfterColonWithEmojiNameRemoved = value.slice(selection.end); - const isInsideCodeBlock = isPositionInsideCodeBlock(value, suggestionValues.colonIndex); - const emojiOrShortcode = getEmojiCodeForInsertion(emojiObject, preferredSkinTone, isInsideCodeBlock); - - updateComment(`${commentBeforeColon}${emojiOrShortcode} ${trimLeadingSpace(commentAfterColonWithEmojiNameRemoved)}`, true); - - // In some Android phones keyboard, the text to search for the emoji is not cleared - // will be added after the user starts typing again on the keyboard. This package is - // a workaround to reset the keyboard natively. - resetKeyboardInput?.(); - - setSelection({ - start: suggestionValues.colonIndex + emojiOrShortcode.length + CONST.SPACE_LENGTH, - end: suggestionValues.colonIndex + emojiOrShortcode.length + CONST.SPACE_LENGTH, - }); - setSuggestionValues((prevState) => ({...prevState, suggestedEmojis: []})); - }, - [preferredSkinTone, resetKeyboardInput, selection.end, setSelection, suggestionValues.colonIndex, suggestionValues.suggestedEmojis, updateComment, value], - ); + const commentBeforeColon = value.slice(0, suggestionValues.colonIndex); + const commentAfterColonWithEmojiNameRemoved = value.slice(selection.end); + const isInsideCodeBlock = isPositionInsideCodeBlock(value, suggestionValues.colonIndex); + const emojiOrShortcode = getEmojiCodeForInsertion(emojiObject, preferredSkinTone, isInsideCodeBlock); + + updateComment(`${commentBeforeColon}${emojiOrShortcode} ${trimLeadingSpace(commentAfterColonWithEmojiNameRemoved)}`, true); + + // In some Android phones keyboard, the text to search for the emoji is not cleared + // will be added after the user starts typing again on the keyboard. This package is + // a workaround to reset the keyboard natively. + resetKeyboardInput?.(); + + setSelection({ + start: suggestionValues.colonIndex + emojiOrShortcode.length + CONST.SPACE_LENGTH, + end: suggestionValues.colonIndex + emojiOrShortcode.length + CONST.SPACE_LENGTH, + }); + setSuggestionValues((prevState) => ({...prevState, suggestedEmojis: []})); + }; /** * Clean data related to suggestions */ - const resetSuggestions = useCallback(() => { + const resetSuggestions = () => { setSuggestionValues(defaultSuggestionsValues); - }, []); + }; - const updateShouldShowSuggestionMenuToFalse = useCallback(() => { + const updateShouldShowSuggestionMenuToFalse = () => { setSuggestionValues((prevState) => { if (prevState.shouldShowSuggestionMenu) { return {...prevState, shouldShowSuggestionMenu: false}; } return prevState; }); - }, []); + }; /** * Listens for keyboard shortcuts and applies the action */ - const triggerHotkeyActions = useCallback( - (e: KeyboardEvent) => { - const suggestionsExist = suggestionValues.suggestedEmojis.length > 0; - - if (((!e.shiftKey && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) || e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestionsExist) { - e.preventDefault(); - if (suggestionValues.suggestedEmojis.length > 0) { - insertSelectedEmoji(highlightedEmojiIndex); - } - return true; - } + const triggerHotkeyActions = (e: KeyboardEvent) => { + const suggestionsExist = suggestionValues.suggestedEmojis.length > 0; - if (e.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) { - e.preventDefault(); + if (((!e.shiftKey && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) || e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestionsExist) { + e.preventDefault(); + if (suggestionValues.suggestedEmojis.length > 0) { + insertSelectedEmoji(highlightedEmojiIndex); + } + return true; + } - if (suggestionsExist) { - resetSuggestions(); - } + if (e.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) { + e.preventDefault(); - return true; + if (suggestionsExist) { + resetSuggestions(); } - }, - [highlightedEmojiIndex, insertSelectedEmoji, resetSuggestions, suggestionValues.suggestedEmojis.length], - ); + + return true; + } + }; /** * Calculates and cares about the content of an Emoji Suggester */ - const calculateEmojiSuggestion = useCallback( - (newValue: string, selectionStart?: number, selectionEnd?: number) => { - if (selectionStart !== selectionEnd || !selectionEnd || shouldBlockCalc.current || !newValue || (selectionStart === 0 && selectionEnd === 0)) { - shouldBlockCalc.current = false; - resetSuggestions(); - return; - } - const leftString = newValue.substring(0, selectionEnd); - const colonIndex = leftString.lastIndexOf(':'); + const calculateEmojiSuggestion = (newValue: string, selectionStart?: number, selectionEnd?: number) => { + if (selectionStart !== selectionEnd || !selectionEnd || shouldBlockCalc.current || !newValue || (selectionStart === 0 && selectionEnd === 0)) { + shouldBlockCalc.current = false; + resetSuggestions(); + return; + } + const leftString = newValue.substring(0, selectionEnd); + const colonIndex = leftString.lastIndexOf(':'); - // Skip emoji suggestions if cursor is inside a code block - if (colonIndex !== -1 && isPositionInsideCodeBlock(newValue, colonIndex)) { - resetSuggestions(); - return; - } + // Skip emoji suggestions if cursor is inside a code block + if (colonIndex !== -1 && isPositionInsideCodeBlock(newValue, colonIndex)) { + resetSuggestions(); + return; + } - const isCurrentlyShowingEmojiSuggestion = isEmojiCode(newValue, selectionEnd); + const isCurrentlyShowingEmojiSuggestion = isEmojiCode(newValue, selectionEnd); - const nextState: SuggestionsValue = { - suggestedEmojis: [], - colonIndex, - shouldShowSuggestionMenu: false, - }; - const newSuggestedEmojis = suggestEmojis(leftString, preferredLocale); + const nextState: SuggestionsValue = { + suggestedEmojis: [], + colonIndex, + shouldShowSuggestionMenu: false, + }; + const newSuggestedEmojis = suggestEmojis(leftString, preferredLocale); - if (newSuggestedEmojis?.length && isCurrentlyShowingEmojiSuggestion) { - nextState.suggestedEmojis = newSuggestedEmojis; - nextState.shouldShowSuggestionMenu = !isEmptyObject(newSuggestedEmojis); - } + if (newSuggestedEmojis?.length && isCurrentlyShowingEmojiSuggestion) { + nextState.suggestedEmojis = newSuggestedEmojis; + nextState.shouldShowSuggestionMenu = !isEmptyObject(newSuggestedEmojis); + } - // Early return if there is no update - const currentState = suggestionValuesRef.current; - if (nextState.suggestedEmojis.length === 0 && currentState.suggestedEmojis.length === 0) { - return; - } + // Early return if there is no update + const currentState = suggestionValuesRef.current; + if (nextState.suggestedEmojis.length === 0 && currentState.suggestedEmojis.length === 0) { + return; + } - setSuggestionValues((prevState) => ({...prevState, ...nextState})); - setHighlightedEmojiIndex(0); - }, - [preferredLocale, setHighlightedEmojiIndex, resetSuggestions], - ); + setSuggestionValues((prevState) => ({...prevState, ...nextState})); + setHighlightedEmojiIndex(0); + }; - const debouncedCalculateEmojiSuggestion = useDebounce( - useCallback( - (newValue: string, selectionStart?: number, selectionEnd?: number) => { - calculateEmojiSuggestion(newValue, selectionStart, selectionEnd); - }, - [calculateEmojiSuggestion], - ), - CONST.TIMING.SUGGESTION_DEBOUNCE_TIME, - ); + const debouncedCalculateEmojiSuggestion = useDebounce((newValue: string, selectionStart?: number, selectionEnd?: number) => { + calculateEmojiSuggestion(newValue, selectionStart, selectionEnd); + }, CONST.TIMING.SUGGESTION_DEBOUNCE_TIME); useEffect(() => { if (!isComposerFocused) { @@ -205,29 +189,22 @@ function SuggestionEmoji({ debouncedCalculateEmojiSuggestion(value, selection.start, selection.end); }, [value, selection.start, selection.end, debouncedCalculateEmojiSuggestion, isComposerFocused]); - const setShouldBlockSuggestionCalc = useCallback( - (shouldBlockSuggestionCalc: boolean) => { - shouldBlockCalc.current = shouldBlockSuggestionCalc; - }, - [shouldBlockCalc], - ); + const setShouldBlockSuggestionCalc = (shouldBlockSuggestionCalc: boolean) => { + shouldBlockCalc.current = shouldBlockSuggestionCalc; + }; - const getSuggestions = useCallback(() => suggestionValues.suggestedEmojis, [suggestionValues.suggestedEmojis]); - - const getIsSuggestionsMenuVisible = useCallback(() => isEmojiSuggestionsMenuVisible, [isEmojiSuggestionsMenuVisible]); - - useImperativeHandle( - ref, - () => ({ - resetSuggestions, - triggerHotkeyActions, - setShouldBlockSuggestionCalc, - updateShouldShowSuggestionMenuToFalse, - getSuggestions, - getIsSuggestionsMenuVisible, - }), - [resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse, getSuggestions, getIsSuggestionsMenuVisible], - ); + const getSuggestions = () => suggestionValues.suggestedEmojis; + + const getIsSuggestionsMenuVisible = () => isEmojiSuggestionsMenuVisible; + + useImperativeHandle(ref, () => ({ + resetSuggestions, + triggerHotkeyActions, + setShouldBlockSuggestionCalc, + updateShouldShowSuggestionMenuToFalse, + getSuggestions, + getIsSuggestionsMenuVisible, + })); if (!isEmojiSuggestionsMenuVisible) { return null; diff --git a/src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts b/src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts index a0215ce1fbbad..3f02902b44e68 100644 --- a/src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts +++ b/src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts @@ -1,5 +1,5 @@ import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; -import {useCallback, useContext, useMemo, useRef} from 'react'; +import {useContext, useRef} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import useFilesValidation from '@hooks/useFilesValidation'; import useLocalize from '@hooks/useLocalize'; @@ -59,26 +59,23 @@ function useAttachmentUploadValidation({ const personalPolicy = usePersonalPolicy(); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); - const hasOnlyPersonalPolicies = useMemo(() => hasOnlyPersonalPoliciesUtil(allPolicies), [allPolicies]); + const hasOnlyPersonalPolicies = hasOnlyPersonalPoliciesUtil(allPolicies); const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); const reportAttachmentsContext = useContext(AttachmentModalContext); - const showAttachmentModalScreen = useCallback( - (file: FileObject | FileObject[], dataTransferItems?: DataTransferItem[]) => { - reportAttachmentsContext.setCurrentAttachment({ - reportID, - file, - dataTransferItems, - headerTitle: translate('reportActionCompose.sendAttachment'), - onConfirm: addAttachment, - onShow: () => setIsAttachmentPreviewActive(true), - onClose: onAttachmentPreviewClose, - shouldDisableSendButton: !!exceededMaxLength, - }); - Navigation.navigate(ROUTES.REPORT_ADD_ATTACHMENT.getRoute(reportID)); - }, - [addAttachment, exceededMaxLength, onAttachmentPreviewClose, reportAttachmentsContext, reportID, setIsAttachmentPreviewActive, translate], - ); + const showAttachmentModalScreen = (file: FileObject | FileObject[], dataTransferItems?: DataTransferItem[]) => { + reportAttachmentsContext.setCurrentAttachment({ + reportID, + file, + dataTransferItems, + headerTitle: translate('reportActionCompose.sendAttachment'), + onConfirm: addAttachment, + onShow: () => setIsAttachmentPreviewActive(true), + onClose: onAttachmentPreviewClose, + shouldDisableSendButton: !!exceededMaxLength, + }); + Navigation.navigate(ROUTES.REPORT_ADD_ATTACHMENT.getRoute(reportID)); + }; const attachmentUploadType = useRef<'receipt' | 'attachment'>(undefined); const onFilesValidated = (files: FileObject[], dataTransferItems: DataTransferItem[]) => { @@ -135,79 +132,73 @@ function useAttachmentUploadValidation({ const {validateFiles, PDFValidationComponent, ErrorModal} = useFilesValidation(onFilesValidated); - const validateAttachments = useCallback( - ({dragEvent, files}: {dragEvent?: DragEvent; files?: FileObject | FileObject[]}) => { - if (isAttachmentPreviewActive) { + const validateAttachments = ({dragEvent, files}: {dragEvent?: DragEvent; files?: FileObject | FileObject[]}) => { + if (isAttachmentPreviewActive) { + return; + } + + let extractedFiles: FileObject[] = []; + + if (files) { + extractedFiles = Array.isArray(files) ? files : [files]; + } else { + if (!dragEvent) { return; } - let extractedFiles: FileObject[] = []; + extractedFiles = getFilesFromClipboardEvent(dragEvent); + } + + const dataTransferItems = Array.from(dragEvent?.dataTransfer?.items ?? []); + if (extractedFiles.length === 0) { + return; + } - if (files) { - extractedFiles = Array.isArray(files) ? files : [files]; - } else { - if (!dragEvent) { - return; + const validIndices: number[] = []; + const fileObjects = extractedFiles + .map((item, index) => { + const fileObject = cleanFileObject(item); + const cleanedFileObject = cleanFileObjectName(fileObject); + if (cleanedFileObject !== null) { + validIndices.push(index); } + return cleanedFileObject; + }) + .filter((fileObject) => fileObject !== null); - extractedFiles = getFilesFromClipboardEvent(dragEvent); - } + if (!fileObjects.length) { + return; + } - const dataTransferItems = Array.from(dragEvent?.dataTransfer?.items ?? []); - if (extractedFiles.length === 0) { - return; - } + // Create a filtered items array that matches the fileObjects + const filteredItems = dataTransferItems && validIndices.length > 0 ? validIndices.map((index) => dataTransferItems.at(index) ?? ({} as DataTransferItem)) : undefined; - const validIndices: number[] = []; - const fileObjects = extractedFiles - .map((item, index) => { - const fileObject = cleanFileObject(item); - const cleanedFileObject = cleanFileObjectName(fileObject); - if (cleanedFileObject !== null) { - validIndices.push(index); - } - return cleanedFileObject; - }) - .filter((fileObject) => fileObject !== null); - - if (!fileObjects.length) { - return; - } + attachmentUploadType.current = 'attachment'; + validateFiles(fileObjects, filteredItems, {isValidatingReceipts: false}); + }; - // Create a filtered items array that matches the fileObjects - const filteredItems = dataTransferItems && validIndices.length > 0 ? validIndices.map((index) => dataTransferItems.at(index) ?? ({} as DataTransferItem)) : undefined; + const onReceiptDropped = (e: DragEvent) => { + if (policy && shouldRestrictUserBillableActions(policy.id, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); + return; + } - attachmentUploadType.current = 'attachment'; - validateFiles(fileObjects, filteredItems, {isValidatingReceipts: false}); - }, - [isAttachmentPreviewActive, validateFiles], - ); + const files = getFilesFromClipboardEvent(e); + const items = Array.from(e.dataTransfer?.items ?? []); - const onReceiptDropped = useCallback( - (e: DragEvent) => { - if (policy && shouldRestrictUserBillableActions(policy.id, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed)) { - Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); + if (shouldAddOrReplaceReceipt && transactionID) { + const file = files.at(0); + if (!file) { return; } - const files = getFilesFromClipboardEvent(e); - const items = Array.from(e.dataTransfer?.items ?? []); - - if (shouldAddOrReplaceReceipt && transactionID) { - const file = files.at(0); - if (!file) { - return; - } - - attachmentUploadType.current = 'receipt'; - validateFiles([file], items); - } - attachmentUploadType.current = 'receipt'; - validateFiles(files, items, {isValidatingReceipts: true}); - }, - [policy, userBillingGracePeriodEnds, ownerBillingGracePeriodEnd, shouldAddOrReplaceReceipt, transactionID, validateFiles, amountOwed], - ); + validateFiles([file], items); + } + + attachmentUploadType.current = 'receipt'; + validateFiles(files, items, {isValidatingReceipts: true}); + }; return { validateAttachments, diff --git a/tests/ui/ReportActionComposeTest.tsx b/tests/ui/ReportActionComposeTest.tsx index 9e2e0edc835ee..e3b3904de27e1 100644 --- a/tests/ui/ReportActionComposeTest.tsx +++ b/tests/ui/ReportActionComposeTest.tsx @@ -7,7 +7,7 @@ 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 ReportActionCompose from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import * as LHNTestUtils from '../utils/LHNTestUtils'; @@ -387,8 +387,8 @@ describe('ReportActionCompose Integration Tests', () => { const validMessage = 'x'.repeat(CONST.MAX_COMMENT_LENGTH); fireEvent.changeText(composer, validMessage); - // When the message is submitted - act(onSubmitAction); + // When the message is submitted via Enter key + fireEvent(composer, 'keyPress', {key: 'Enter', shiftKey: false, preventDefault: jest.fn()}); // scheduleOnUI mock uses setTimeout(() => ..., 0) act(() => { @@ -407,8 +407,8 @@ describe('ReportActionCompose Integration Tests', () => { const invalidMessage = 'x'.repeat(CONST.MAX_COMMENT_LENGTH + 1); fireEvent.changeText(composer, invalidMessage); - // When the message is submitted - act(onSubmitAction); + // When the message is submitted via Enter key + fireEvent(composer, 'keyPress', {key: 'Enter', shiftKey: false, preventDefault: jest.fn()}); // Then the message should NOT be sent expect(mockForceClearInput).toHaveBeenCalledTimes(0); From db163b455e76d78a1c56aa390833151f490a378e Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 1 Apr 2026 12:13:56 +0200 Subject: [PATCH 06/57] =?UTF-8?q?Remove=20didHideComposerInput=20latch=20?= =?UTF-8?q?=E2=80=94=20modal=20refocus=20is=20platform-driven?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ComposerWithSuggestions.tsx | 10 +++------- .../ReportActionCompose/ReportActionCompose.tsx | 11 ----------- 2 files changed, 3 insertions(+), 18 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 5b957d6a45d3d..03c19308a4cc0 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -147,9 +147,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; }; @@ -245,7 +242,6 @@ function ComposerWithSuggestions({ // For testing children, - didHideComposerInput, // Fullstory forwardedFSClass, @@ -291,7 +287,7 @@ function ComposerWithSuggestions({ const {shouldUseNarrowLayout} = useResponsiveLayout(); 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) && shouldShowComposeInput && areAllModalsHidden() && isFocused; const delayedAutoFocusRouteKeyRef = useRef(null); const valueRef = useRef(value); @@ -805,7 +801,7 @@ function ComposerWithSuggestions({ // We want to focus or refocus the input when a modal has been closed or the underlying screen is refocused. // We avoid doing this on native platforms since the software keyboard popping // open creates a jarring and broken UX. - if (!((willBlurTextInputOnTapOutside || shouldAutoFocus) && !isNextModalWillOpenRef.current && !isModalVisible && (!!prevIsModalVisible || !prevIsFocused))) { + if (!(willBlurTextInputOnTapOutside && !isNextModalWillOpenRef.current && !isModalVisible && (!!prevIsModalVisible || !prevIsFocused))) { return; } @@ -814,7 +810,7 @@ function ComposerWithSuggestions({ return; } focus(true); - }, [focus, prevIsFocused, editFocused, prevIsModalVisible, isFocused, modal?.isVisible, isNextModalWillOpenRef, shouldAutoFocus, isSidePanelHiddenOrLargeScreen]); + }, [focus, prevIsFocused, editFocused, prevIsModalVisible, isFocused, modal?.isVisible, isNextModalWillOpenRef, isSidePanelHiddenOrLargeScreen]); useEffect(() => { // Scrolls the composer to the bottom and sets the selection to the end, so that longer drafts are easier to edit diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index d113f545d6451..61ecc365a3b2f 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -207,7 +207,6 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { */ const [isMenuVisible, setMenuVisibility] = useState(false); const [isAttachmentPreviewActive, setIsAttachmentPreviewActive] = useState(false); - const [didHideComposerInput, setDidHideComposerInput] = useState(!shouldShowComposeInput); /** * Updates the composer when the comment length is exceeded @@ -452,15 +451,6 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { setIsFocused(true); }; - 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]); - // Hide emoji picker on unmount or when switching reports useEffect( () => () => { @@ -633,7 +623,6 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { onBlur={onBlur} measureParentContainer={measureContainer} onValueChange={onValueChange} - didHideComposerInput={didHideComposerInput} forwardedFSClass={fsClass} /> {shouldDisplayDualDropZone && ( From 7c8ffac91a745c8c735630318552afd35484cbf2 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 1 Apr 2026 12:23:52 +0200 Subject: [PATCH 07/57] SuggestionMention: fix double sub, self-subscribe, remove manual memo --- .../inbox/report/PureReportActionItem.tsx | 1 - .../ComposerWithSuggestions.tsx | 2 - .../ReportActionCompose/SuggestionMention.tsx | 622 ++++++++---------- .../ReportActionCompose/Suggestions.tsx | 65 +- .../report/ReportActionItemMessageEdit.tsx | 6 - tests/unit/SuggestionMentionTest.tsx | 2 - 6 files changed, 317 insertions(+), 381 deletions(-) diff --git a/src/pages/inbox/report/PureReportActionItem.tsx b/src/pages/inbox/report/PureReportActionItem.tsx index 0e8b9881a5548..a542d9c8d11f4 100644 --- a/src/pages/inbox/report/PureReportActionItem.tsx +++ b/src/pages/inbox/report/PureReportActionItem.tsx @@ -1865,7 +1865,6 @@ function PureReportActionItem({ draftMessage={draftMessage} reportID={reportID} originalReportID={originalReportID} - policyID={report?.policyID} index={index} ref={composerTextInputRef} shouldDisableEmojiPicker={ diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 03c19308a4cc0..eb4a97985c4e3 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -984,8 +984,6 @@ function ComposerWithSuggestions({ isComposerFocused={textInputRef.current?.isFocused()} updateComment={updateComment} measureParentContainerAndReportCursor={measureParentContainerAndReportCursor} - isGroupPolicyReport={isGroupPolicyReport} - policyID={policyID} // Input value={value} selection={selection} diff --git a/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx b/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx index e643583941b2b..0dcc15d76bc29 100644 --- a/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx +++ b/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx @@ -1,7 +1,7 @@ import {Str} from 'expensify-common'; import lodashMapValues from 'lodash/mapValues'; import lodashSortBy from 'lodash/sortBy'; -import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; import type {OnyxCollection} from 'react-native-onyx'; import type {Mention} from '@components/MentionSuggestions'; import MentionSuggestions from '@components/MentionSuggestions'; @@ -56,29 +56,27 @@ type SuggestionPersonalDetailsList = Record< | null >; -function SuggestionMention({ - value, - selection, - setSelection, - updateComment, - isAutoSuggestionPickerLarge, - measureParentContainerAndReportCursor, - isComposerFocused, - isGroupPolicyReport, - policyID, - ref, -}: SuggestionProps) { +function SuggestionMention({value, selection, setSelection, updateComment, isAutoSuggestionPickerLarge, measureParentContainerAndReportCursor, isComposerFocused, ref}: SuggestionProps) { const personalDetails = usePersonalDetails(); const {translate, formatPhoneNumber, localeCompare} = useLocalize(); const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues); const suggestionValuesRef = useRef(suggestionValues); - const policy = usePolicy(policyID); + // eslint-disable-next-line react-hooks/refs -- intentional sync-ref pattern: keeps ref up to date without effect overhead suggestionValuesRef.current = suggestionValues; + const {currentReportID} = useCurrentReportIDState(); + const [currentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${currentReportID}`); + + const policyID = currentReport?.policyID; + const isGroupPolicyReport = !!policyID && policyID !== CONST.POLICY.ID_FAKE; + + const policy = usePolicy(policyID); + // Filter reports to only include those that can be mentioned within the current policy + // useCallback is required by the rulesdir/no-inline-useOnyx-selector ESLint rule const mentionableReportsSelector = useCallback( - (reports: OnyxCollection) => { - return Object.keys(reports ?? {}).reduce( + (reports: OnyxCollection) => + Object.keys(reports ?? {}).reduce( (acc, reportID) => { const report = reports?.[reportID]; if (report && canReportBeMentionedWithinPolicy(report, policyID)) { @@ -87,8 +85,7 @@ function SuggestionMention({ return acc; }, {} as Record, - ); - }, + ), [policyID], ); @@ -100,36 +97,29 @@ function SuggestionMention({ const expensifyIcons = useMemoizedLazyExpensifyIcons(['Megaphone', 'FallbackAvatar']); - const {currentReportID} = useCurrentReportIDState(); - const [currentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${currentReportID}`); - // Smaller weight means higher order in suggestion list - const getPersonalDetailsWeight = useCallback( - (detail: PersonalDetails, policyEmployeeAccountIDs: number[]): number => { - if (isReportParticipant(detail.accountID, currentReport)) { - return 0; - } - if (policyEmployeeAccountIDs.includes(detail.accountID)) { - return 1; - } - return 2; - }, - [currentReport], - ); - const weightedPersonalDetails: PersonalDetailsList | SuggestionPersonalDetailsList | undefined = useMemo(() => { - const policyEmployeeAccountIDs = getPolicyEmployeeAccountIDs(policy, currentUserPersonalDetails.accountID); - if (!isGroupChat(currentReport) && !doesReportBelongToWorkspace(currentReport, policyEmployeeAccountIDs, policyID, conciergeReportID)) { - return personalDetails; + function getPersonalDetailsWeight(detail: PersonalDetails, policyEmployeeAccountIDs: number[]): number { + if (isReportParticipant(detail.accountID, currentReport)) { + return 0; + } + if (policyEmployeeAccountIDs.includes(detail.accountID)) { + return 1; } - return lodashMapValues(personalDetails, (detail) => - detail - ? { - ...detail, - weight: getPersonalDetailsWeight(detail, policyEmployeeAccountIDs), - } - : null, - ); - }, [policyID, policy, currentReport, personalDetails, getPersonalDetailsWeight, currentUserPersonalDetails.accountID, conciergeReportID]); + return 2; + } + + const policyEmployeeAccountIDs = getPolicyEmployeeAccountIDs(policy, currentUserPersonalDetails.accountID); + const weightedPersonalDetails: PersonalDetailsList | SuggestionPersonalDetailsList | undefined = + !isGroupChat(currentReport) && !doesReportBelongToWorkspace(currentReport, policyEmployeeAccountIDs, policyID, conciergeReportID) + ? personalDetails + : lodashMapValues(personalDetails, (detail) => + detail + ? { + ...detail, + weight: getPersonalDetailsWeight(detail, policyEmployeeAccountIDs), + } + : null, + ); const [highlightedMentionIndex, setHighlightedMentionIndex] = useArrowKeyFocusManager({ isActive: isMentionSuggestionsMenuVisible, @@ -142,8 +132,10 @@ function SuggestionMention({ // Used to detect if the selection has changed since the last suggestion insertion // If so, we reset the suggestionInsertionIndexRef + // eslint-disable-next-line react-hooks/refs -- reading ref during render to detect cursor movement; write clears a transient flag const hasSelectionChanged = !(selection.end === selection.start && selection.start === suggestionInsertionIndexRef.current); if (hasSelectionChanged) { + // eslint-disable-next-line react-hooks/refs -- clearing transient insertion-index flag during render; harmless side-effect suggestionInsertionIndexRef.current = null; } @@ -155,42 +147,33 @@ function SuggestionMention({ * * The function is debounced to not perform requests on every keystroke. */ - const debouncedSearchInServer = useDebounce( - useCallback(() => { - const foundSuggestionsCount = suggestionValues.suggestedMentions.length; - if (suggestionValues.prefixType === '#' && foundSuggestionsCount < 5 && isGroupPolicyReport) { - searchInServer(suggestionValues.mentionPrefix, policyID); - } - }, [suggestionValues.suggestedMentions.length, suggestionValues.prefixType, suggestionValues.mentionPrefix, policyID, isGroupPolicyReport]), - CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME, - ); + const debouncedSearchInServer = useDebounce(() => { + const foundSuggestionsCount = suggestionValues.suggestedMentions.length; + if (suggestionValues.prefixType === '#' && foundSuggestionsCount < 5 && isGroupPolicyReport) { + searchInServer(suggestionValues.mentionPrefix, policyID); + } + }, CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); - const formatLoginPrivateDomain = useCallback( - (displayText = '', userLogin = '') => { - if (userLogin !== displayText) { - return displayText; - } - // If the emails are not in the same private domain, we also return the displayText - if (!areEmailsFromSamePrivateDomain(displayText, currentUserPersonalDetails.login ?? '')) { - return Str.removeSMSDomain(displayText); - } + function formatLoginPrivateDomain(displayText = '', userLogin = '') { + if (userLogin !== displayText) { + return displayText; + } + // If the emails are not in the same private domain, we also return the displayText + if (!areEmailsFromSamePrivateDomain(displayText, currentUserPersonalDetails.login ?? '')) { + return Str.removeSMSDomain(displayText); + } - // Otherwise, the emails must be of the same private domain, so we should remove the domain part - return displayText.split('@').at(0); - }, - [currentUserPersonalDetails.login], - ); + // Otherwise, the emails must be of the same private domain, so we should remove the domain part + return displayText.split('@').at(0); + } - const getMentionCode = useCallback( - (mention: Mention, mentionType: string): string => { - if (mentionType === '#') { - // room mention case - return mention.handle ?? ''; - } - return mention.text === CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT ? CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT : `@${formatLoginPrivateDomain(mention.handle, mention.handle)}`; - }, - [formatLoginPrivateDomain], - ); + function getMentionCode(mention: Mention, mentionType: string): string { + if (mentionType === '#') { + // room mention case + return mention.handle ?? ''; + } + return mention.text === CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT ? CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT : `@${formatLoginPrivateDomain(mention.handle, mention.handle)}`; + } function getOriginalMentionText(inputValue: string, atSignIndex: number, whiteSpacesLength = 0) { const rest = inputValue.slice(atSignIndex); @@ -212,271 +195,248 @@ function SuggestionMention({ /** * Replace the code of mention and update selection */ - const insertSelectedMention = useCallback( - (highlightedMentionIndexInner: number) => { - const commentBeforeAtSign = value.slice(0, suggestionValues.atSignIndex); - const mentionObject = suggestionValues.suggestedMentions.at(highlightedMentionIndexInner); - if (!mentionObject || highlightedMentionIndexInner === -1) { - return; - } + function insertSelectedMention(highlightedMentionIndexInner: number) { + const commentBeforeAtSign = value.slice(0, suggestionValues.atSignIndex); + const mentionObject = suggestionValues.suggestedMentions.at(highlightedMentionIndexInner); + if (!mentionObject || highlightedMentionIndexInner === -1) { + return; + } - const mentionCode = getMentionCode(mentionObject, suggestionValues.prefixType); - const originalMention = getOriginalMentionText(value, suggestionValues.atSignIndex, StringUtils.countWhiteSpaces(suggestionValues.mentionPrefix)); + const mentionCode = getMentionCode(mentionObject, suggestionValues.prefixType); + const originalMention = getOriginalMentionText(value, suggestionValues.atSignIndex, StringUtils.countWhiteSpaces(suggestionValues.mentionPrefix)); - // We split trailing dot from the mention token so selecting `@a.` can become `@adam.` - // (preserve sentence punctuation) instead of consuming the `.` into the replacement. - let trailingDot = ''; - let mentionToReplace = originalMention; - if (suggestionValues.prefixType === '@' && suggestionValues.mentionPrefix.endsWith('.')) { - trailingDot = originalMention.match(CONST.REGEX.TRAILING_DOTS)?.[0] ?? ''; - mentionToReplace = originalMention.slice(0, originalMention.length - trailingDot.length); - } + // We split trailing dot from the mention token so selecting `@a.` can become `@adam.` + // (preserve sentence punctuation) instead of consuming the `.` into the replacement. + let trailingDot = ''; + let mentionToReplace = originalMention; + if (suggestionValues.prefixType === '@' && suggestionValues.mentionPrefix.endsWith('.')) { + trailingDot = originalMention.match(CONST.REGEX.TRAILING_DOTS)?.[0] ?? ''; + mentionToReplace = originalMention.slice(0, originalMention.length - trailingDot.length); + } - // Append a preserved trailing dot only when it is sentence punctuation, not part of the selected mention match. - const dotToAppend = - trailingDot && ![mentionObject.text, mentionObject.alternateText].some((mentionText) => mentionText.toLowerCase().includes(suggestionValues.mentionPrefix.toLowerCase())) - ? trailingDot - : ''; + // Append a preserved trailing dot only when it is sentence punctuation, not part of the selected mention match. + const dotToAppend = + trailingDot && ![mentionObject.text, mentionObject.alternateText].some((mentionText) => mentionText.toLowerCase().includes(suggestionValues.mentionPrefix.toLowerCase())) + ? trailingDot + : ''; - const commentAfterMention = value.slice( - suggestionValues.atSignIndex + Math.max(mentionToReplace.length, suggestionValues.mentionPrefix.length + suggestionValues.prefixType.length), - ); + const commentAfterMention = value.slice(suggestionValues.atSignIndex + Math.max(mentionToReplace.length, suggestionValues.mentionPrefix.length + suggestionValues.prefixType.length)); - const trimmedCommentAfterMention = trimLeadingSpace(commentAfterMention); - const spacer = !trimmedCommentAfterMention || !CONST.REGEX.STARTS_WITH_PUNCTUATION.test(trimmedCommentAfterMention) ? ' ' : ''; + const trimmedCommentAfterMention = trimLeadingSpace(commentAfterMention); + const spacer = !trimmedCommentAfterMention || !CONST.REGEX.STARTS_WITH_PUNCTUATION.test(trimmedCommentAfterMention) ? ' ' : ''; - updateComment(`${commentBeforeAtSign}${mentionCode}${dotToAppend}${spacer}${trimmedCommentAfterMention}`, true); - const selectionPosition = suggestionValues.atSignIndex + mentionCode.length + dotToAppend.length + spacer.length; - setSelection({ - start: selectionPosition, - end: selectionPosition, - }); - suggestionInsertionIndexRef.current = selectionPosition; - setSuggestionValues((prevState) => ({ - ...prevState, - suggestedMentions: [], - shouldShowSuggestionMenu: false, - })); - }, - [value, suggestionValues.atSignIndex, suggestionValues.suggestedMentions, suggestionValues.prefixType, getMentionCode, updateComment, setSelection, suggestionValues.mentionPrefix], - ); + updateComment(`${commentBeforeAtSign}${mentionCode}${dotToAppend}${spacer}${trimmedCommentAfterMention}`, true); + const selectionPosition = suggestionValues.atSignIndex + mentionCode.length + dotToAppend.length + spacer.length; + setSelection({ + start: selectionPosition, + end: selectionPosition, + }); + suggestionInsertionIndexRef.current = selectionPosition; + setSuggestionValues((prevState) => ({ + ...prevState, + suggestedMentions: [], + shouldShowSuggestionMenu: false, + })); + } /** * Clean data related to suggestions */ - const resetSuggestions = useCallback(() => { + function resetSuggestions() { setSuggestionValues(defaultSuggestionsValues); - }, []); + } /** * Listens for keyboard shortcuts and applies the action */ - const triggerHotkeyActions = useCallback( - (event: KeyboardEvent) => { - const suggestionsExist = suggestionValues.suggestedMentions.length > 0; - - if (((!event.shiftKey && event.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) || event.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestionsExist) { - event.preventDefault(); - if (suggestionValues.suggestedMentions.length > 0) { - insertSelectedMention(highlightedMentionIndex); - return true; - } + function triggerHotkeyActions(event: KeyboardEvent) { + const suggestionsExist = suggestionValues.suggestedMentions.length > 0; + + if (((!event.shiftKey && event.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) || event.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestionsExist) { + event.preventDefault(); + if (suggestionValues.suggestedMentions.length > 0) { + insertSelectedMention(highlightedMentionIndex); + return true; + } + } + + if (event.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) { + event.preventDefault(); + + if (suggestionsExist) { + resetSuggestions(); } - if (event.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) { - event.preventDefault(); + return true; + } + } - if (suggestionsExist) { - resetSuggestions(); - } + function getUserMentionOptions(personalDetailsParam: PersonalDetailsList | SuggestionPersonalDetailsList | undefined, searchValue = ''): Mention[] { + const suggestions: Mention[] = []; + + if (CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT.includes(searchValue.toLowerCase())) { + suggestions.push({ + text: CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT, + alternateText: translate('mentionSuggestions.hereAlternateText'), + icons: [ + { + source: expensifyIcons.Megaphone, + type: CONST.ICON_TYPE_AVATAR, + }, + ], + }); + } - return true; + // Create a set to track logins that have already been seen + const seenLogins = new Set(); + const filteredPersonalDetails = Object.values(personalDetailsParam ?? {}).filter((detail) => { + // If we don't have user's primary login, that member is not known to the current user and hence we do not allow them to be mentioned + if (!detail?.login || detail.isOptimisticPersonalDetail) { + return false; + } + // We don't want to mention system emails like notifications@expensify.com + if (CONST.RESTRICTED_EMAILS.includes(detail.login) || CONST.RESTRICTED_ACCOUNT_IDS.includes(detail.accountID)) { + return false; + } + const displayName = getDisplayNameOrDefault(detail); + const displayText = displayName === formatPhoneNumber(detail.login) ? displayName : `${displayName} ${detail.login}`; + if (searchValue && !displayText.toLowerCase().includes(searchValue.toLowerCase())) { + return false; } - }, - [highlightedMentionIndex, insertSelectedMention, resetSuggestions, suggestionValues.suggestedMentions.length], - ); - const getUserMentionOptions = useCallback( - (personalDetailsParam: PersonalDetailsList | SuggestionPersonalDetailsList | undefined, searchValue = ''): Mention[] => { - const suggestions: Mention[] = []; - - if (CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT.includes(searchValue.toLowerCase())) { - suggestions.push({ - text: CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT, - alternateText: translate('mentionSuggestions.hereAlternateText'), - icons: [ - { - source: expensifyIcons.Megaphone, - type: CONST.ICON_TYPE_AVATAR, - }, - ], - }); + // Given the mention is inserted by user, we don't want to show the mention options unless the + // selection index changes. In that case, suggestionInsertionIndexRef.current will be null. + // See https://github.com/Expensify/App/issues/38358 for more context + if (suggestionInsertionIndexRef.current) { + return false; } - // Create a set to track logins that have already been seen - const seenLogins = new Set(); - const filteredPersonalDetails = Object.values(personalDetailsParam ?? {}).filter((detail) => { - // If we don't have user's primary login, that member is not known to the current user and hence we do not allow them to be mentioned - if (!detail?.login || detail.isOptimisticPersonalDetail) { - return false; - } - // We don't want to mention system emails like notifications@expensify.com - if (CONST.RESTRICTED_EMAILS.includes(detail.login) || CONST.RESTRICTED_ACCOUNT_IDS.includes(detail.accountID)) { - return false; - } - const displayName = getDisplayNameOrDefault(detail); - const displayText = displayName === formatPhoneNumber(detail.login) ? displayName : `${displayName} ${detail.login}`; - if (searchValue && !displayText.toLowerCase().includes(searchValue.toLowerCase())) { - return false; - } - - // Given the mention is inserted by user, we don't want to show the mention options unless the - // selection index changes. In that case, suggestionInsertionIndexRef.current will be null. - // See https://github.com/Expensify/App/issues/38358 for more context - if (suggestionInsertionIndexRef.current) { - return false; - } - - // on staging server, in specific cases (see issue) BE returns duplicated personalDetails - // entries with the same `login` which we need to filter out - if (seenLogins.has(detail.login)) { - return false; - } - seenLogins.add(detail.login); - return true; - }) as Array; - - // At this point we are sure that the details are not null, since empty user details have been filtered in the previous step - const sortedPersonalDetails = getSortedPersonalDetails(filteredPersonalDetails, localeCompare); - - for (const detail of sortedPersonalDetails.slice(0, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS - suggestions.length)) { - suggestions.push({ - text: `${formatLoginPrivateDomain(getDisplayNameOrDefault(detail), detail?.login)}`, - alternateText: `@${formatLoginPrivateDomain(detail?.login, detail?.login)}`, - handle: detail?.login, - icons: [ - { - name: detail?.login, - source: detail?.avatar ?? expensifyIcons.FallbackAvatar, - type: CONST.ICON_TYPE_AVATAR, - fallbackIcon: detail?.fallbackIcon, - id: detail?.accountID, - }, - ], - }); + // on staging server, in specific cases (see issue) BE returns duplicated personalDetails + // entries with the same `login` which we need to filter out + if (seenLogins.has(detail.login)) { + return false; } + seenLogins.add(detail.login); + return true; + }) as Array; + + // At this point we are sure that the details are not null, since empty user details have been filtered in the previous step + const sortedPersonalDetails = getSortedPersonalDetails(filteredPersonalDetails, localeCompare); + + for (const detail of sortedPersonalDetails.slice(0, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS - suggestions.length)) { + suggestions.push({ + text: `${formatLoginPrivateDomain(getDisplayNameOrDefault(detail), detail?.login)}`, + alternateText: `@${formatLoginPrivateDomain(detail?.login, detail?.login)}`, + handle: detail?.login, + icons: [ + { + name: detail?.login, + source: detail?.avatar ?? expensifyIcons.FallbackAvatar, + type: CONST.ICON_TYPE_AVATAR, + fallbackIcon: detail?.fallbackIcon, + id: detail?.accountID, + }, + ], + }); + } - return suggestions; - }, - [localeCompare, translate, expensifyIcons.Megaphone, expensifyIcons.FallbackAvatar, formatPhoneNumber, formatLoginPrivateDomain], - ); + return suggestions; + } - const getRoomMentionOptions = useCallback( - (searchTerm: string): Mention[] => { - const filteredRoomMentions: Mention[] = []; - for (const report of Object.values(mentionableReports ?? {})) { - if (report?.reportName?.toLowerCase().includes(searchTerm.toLowerCase())) { - filteredRoomMentions.push({ - text: report.reportName, - handle: report.reportName, - alternateText: report.reportName, - }); - } + function getRoomMentionOptions(searchTerm: string): Mention[] { + const filteredRoomMentions: Mention[] = []; + for (const report of Object.values(mentionableReports ?? {})) { + if (report?.reportName?.toLowerCase().includes(searchTerm.toLowerCase())) { + filteredRoomMentions.push({ + text: report.reportName, + handle: report.reportName, + alternateText: report.reportName, + }); } + } - return lodashSortBy(filteredRoomMentions, 'handle').slice(0, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS); - }, - [mentionableReports], - ); + return lodashSortBy(filteredRoomMentions, 'handle').slice(0, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS); + } - const calculateMentionSuggestion = useCallback( - (newValue: string, selectionStart?: number, selectionEnd?: number) => { - if (selectionEnd !== selectionStart || !selectionEnd || shouldBlockCalc.current || selectionEnd < 1 || !isComposerFocused) { - shouldBlockCalc.current = false; - resetSuggestions(); - return; - } + function calculateMentionSuggestion(newValue: string, selectionStart?: number, selectionEnd?: number) { + if (selectionEnd !== selectionStart || !selectionEnd || shouldBlockCalc.current || selectionEnd < 1 || !isComposerFocused) { + shouldBlockCalc.current = false; + resetSuggestions(); + return; + } - const afterLastBreakLineIndex = newValue.lastIndexOf('\n', selectionEnd - 1) + 1; - const leftString = newValue.substring(afterLastBreakLineIndex, selectionEnd); - const words = leftString.split(CONST.REGEX.SPACE_OR_EMOJI); - const lastWord: string = words.at(-1) ?? ''; - const secondToLastWord = words.at(-3); - - let atSignIndex: number | undefined; - let suggestionWord = ''; - let prefix: string; - let prefixType = ''; - - // Detect if the last two words contain a mention (two words are needed to detect a mention with a space in it) - if (lastWord.startsWith('@') || lastWord.startsWith('#')) { - atSignIndex = leftString.lastIndexOf(lastWord) + afterLastBreakLineIndex; - suggestionWord = lastWord; - - prefix = suggestionWord.substring(1); - prefixType = suggestionWord.substring(0, 1); - } else if (secondToLastWord && secondToLastWord.startsWith('@') && secondToLastWord.length > 1) { - atSignIndex = leftString.lastIndexOf(secondToLastWord) + afterLastBreakLineIndex; - suggestionWord = `${secondToLastWord} ${lastWord}`; - - prefix = suggestionWord.substring(1); - prefixType = suggestionWord.substring(0, 1); - } else { - prefix = lastWord.substring(1); - } + const afterLastBreakLineIndex = newValue.lastIndexOf('\n', selectionEnd - 1) + 1; + const leftString = newValue.substring(afterLastBreakLineIndex, selectionEnd); + const words = leftString.split(CONST.REGEX.SPACE_OR_EMOJI); + const lastWord: string = words.at(-1) ?? ''; + const secondToLastWord = words.at(-3); + + let atSignIndex: number | undefined; + let suggestionWord = ''; + let prefix: string; + let prefixType = ''; + + // Detect if the last two words contain a mention (two words are needed to detect a mention with a space in it) + if (lastWord.startsWith('@') || lastWord.startsWith('#')) { + atSignIndex = leftString.lastIndexOf(lastWord) + afterLastBreakLineIndex; + suggestionWord = lastWord; + + prefix = suggestionWord.substring(1); + prefixType = suggestionWord.substring(0, 1); + } else if (secondToLastWord && secondToLastWord.startsWith('@') && secondToLastWord.length > 1) { + atSignIndex = leftString.lastIndexOf(secondToLastWord) + afterLastBreakLineIndex; + suggestionWord = `${secondToLastWord} ${lastWord}`; + + prefix = suggestionWord.substring(1); + prefixType = suggestionWord.substring(0, 1); + } else { + prefix = lastWord.substring(1); + } - // Treat a trailing dot as punctuation so short mentions like "@a." still match "@a". - const hasTrailingDot = prefixType === '@' && prefix.length > 1 && prefix.endsWith('.'); - const normalizedPrefix = hasTrailingDot ? prefix.slice(0, -1) : prefix; - // Keep the raw prefix for highlight so dots are preserved in the UI. - const mentionPrefix = prefix; - - const nextState: Partial = { - suggestedMentions: [], - atSignIndex, - mentionPrefix, - prefixType, - }; - - if (isMentionCode(suggestionWord) && prefixType === '@') { - const suggestions = getUserMentionOptions(weightedPersonalDetails, normalizedPrefix); - nextState.suggestedMentions = suggestions; - nextState.shouldShowSuggestionMenu = !!suggestions.length; - } + // Treat a trailing dot as punctuation so short mentions like "@a." still match "@a". + const hasTrailingDot = prefixType === '@' && prefix.length > 1 && prefix.endsWith('.'); + const normalizedPrefix = hasTrailingDot ? prefix.slice(0, -1) : prefix; + // Keep the raw prefix for highlight so dots are preserved in the UI. + const mentionPrefix = prefix; + + const nextState: Partial = { + suggestedMentions: [], + atSignIndex, + mentionPrefix, + prefixType, + }; + + if (isMentionCode(suggestionWord) && prefixType === '@') { + const suggestions = getUserMentionOptions(weightedPersonalDetails, normalizedPrefix); + nextState.suggestedMentions = suggestions; + nextState.shouldShowSuggestionMenu = !!suggestions.length; + } - const shouldDisplayRoomMentionsSuggestions = isGroupPolicyReport && (isValidRoomName(suggestionWord.toLowerCase()) || normalizedPrefix === ''); - if (prefixType === '#' && shouldDisplayRoomMentionsSuggestions) { - // Filter reports by room name and current policy - nextState.suggestedMentions = getRoomMentionOptions(normalizedPrefix); + const shouldDisplayRoomMentionsSuggestions = isGroupPolicyReport && (isValidRoomName(suggestionWord.toLowerCase()) || normalizedPrefix === ''); + if (prefixType === '#' && shouldDisplayRoomMentionsSuggestions) { + // Filter reports by room name and current policy + nextState.suggestedMentions = getRoomMentionOptions(normalizedPrefix); - // Even if there are no reports, we should show the suggestion menu - to perform live search - nextState.shouldShowSuggestionMenu = true; - } + // Even if there are no reports, we should show the suggestion menu - to perform live search + nextState.shouldShowSuggestionMenu = true; + } - // Early return if there is no update - const currentState = suggestionValuesRef.current; - if (currentState.suggestedMentions.length === 0 && nextState.suggestedMentions?.length === 0) { - return; - } + // Early return if there is no update + const currentState = suggestionValuesRef.current; + if (currentState.suggestedMentions.length === 0 && nextState.suggestedMentions?.length === 0) { + return; + } - setSuggestionValues((prevState) => ({ - ...prevState, - ...nextState, - })); - setHighlightedMentionIndex(0); - }, - [isComposerFocused, isGroupPolicyReport, setHighlightedMentionIndex, resetSuggestions, getUserMentionOptions, weightedPersonalDetails, getRoomMentionOptions], - ); + setSuggestionValues((prevState) => ({ + ...prevState, + ...nextState, + })); + setHighlightedMentionIndex(0); + } - const debouncedCalculateMentionSuggestion = useDebounce( - useCallback( - (newValue: string, selectionStart?: number, selectionEnd?: number) => { - calculateMentionSuggestion(newValue, selectionStart, selectionEnd); - }, - [calculateMentionSuggestion], - ), - CONST.TIMING.SUGGESTION_DEBOUNCE_TIME, - ); + const debouncedCalculateMentionSuggestion = useDebounce((newValue: string, selectionStart?: number, selectionEnd?: number) => { + calculateMentionSuggestion(newValue, selectionStart, selectionEnd); + }, CONST.TIMING.SUGGESTION_DEBOUNCE_TIME); useEffect(() => { debouncedCalculateMentionSuggestion(value, selection.start, selection.end); @@ -486,37 +446,35 @@ function SuggestionMention({ debouncedSearchInServer(); }, [suggestionValues.suggestedMentions.length, suggestionValues.prefixType, policyID, value, debouncedSearchInServer]); - const updateShouldShowSuggestionMenuToFalse = useCallback(() => { + function updateShouldShowSuggestionMenuToFalse() { setSuggestionValues((prevState) => { if (prevState.shouldShowSuggestionMenu) { return {...prevState, shouldShowSuggestionMenu: false}; } return prevState; }); - }, []); + } - const setShouldBlockSuggestionCalc = useCallback( - (shouldBlockSuggestionCalc: boolean) => { - shouldBlockCalc.current = shouldBlockSuggestionCalc; - }, - [shouldBlockCalc], - ); + function setShouldBlockSuggestionCalc(shouldBlockSuggestionCalc: boolean) { + shouldBlockCalc.current = shouldBlockSuggestionCalc; + } - const getSuggestions = useCallback(() => suggestionValues.suggestedMentions, [suggestionValues.suggestedMentions]); - const getIsSuggestionsMenuVisible = useCallback(() => isMentionSuggestionsMenuVisible, [isMentionSuggestionsMenuVisible]); - - useImperativeHandle( - ref, - () => ({ - resetSuggestions, - triggerHotkeyActions, - setShouldBlockSuggestionCalc, - updateShouldShowSuggestionMenuToFalse, - getSuggestions, - getIsSuggestionsMenuVisible, - }), - [resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse, getSuggestions, getIsSuggestionsMenuVisible], - ); + function getSuggestions() { + return suggestionValues.suggestedMentions; + } + + function getIsSuggestionsMenuVisible() { + return isMentionSuggestionsMenuVisible; + } + + useImperativeHandle(ref, () => ({ + resetSuggestions, + triggerHotkeyActions, + setShouldBlockSuggestionCalc, + updateShouldShowSuggestionMenuToFalse, + getSuggestions, + getIsSuggestionsMenuVisible, + })); if (!isMentionSuggestionsMenuVisible) { return null; @@ -527,9 +485,11 @@ function SuggestionMention({ highlightedMentionIndex={highlightedMentionIndex} mentions={suggestionValues.suggestedMentions} prefix={suggestionValues.mentionPrefix} + // eslint-disable-next-line react/jsx-no-bind -- React Compiler memoizes these automatically onSelect={insertSelectedMention} isMentionPickerLarge={!!isAutoSuggestionPickerLarge} measureParentContainerAndReportCursor={measureParentContainerAndReportCursor} + // eslint-disable-next-line react/jsx-no-bind -- React Compiler memoizes these automatically resetSuggestions={resetSuggestions} /> ); diff --git a/src/pages/inbox/report/ReportActionCompose/Suggestions.tsx b/src/pages/inbox/report/ReportActionCompose/Suggestions.tsx index ab3342f75d16a..8e2a4694fd847 100644 --- a/src/pages/inbox/report/ReportActionCompose/Suggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/Suggestions.tsx @@ -1,5 +1,5 @@ import type {ForwardedRef} from 'react'; -import React, {useCallback, useEffect, useImperativeHandle, useRef} from 'react'; +import React, {useEffect, useImperativeHandle, useRef} from 'react'; import type {TextInputSelectionChangeEvent} from 'react-native'; import {View} from 'react-native'; import type {MeasureParentContainerAndCursorCallback} from '@components/AutoCompleteSuggestions/types'; @@ -38,12 +38,6 @@ type SuggestionProps = { /** The height of the composer */ composerHeight?: number; - /** If current composer is connected with report from group policy */ - isGroupPolicyReport: boolean; - - /** The policyID of the report connected to current composer */ - policyID?: string; - /** Reference to the outer element */ ref?: ForwardedRef; }; @@ -62,8 +56,6 @@ function Suggestions({ measureParentContainerAndReportCursor, isAutoSuggestionPickerLarge = true, isComposerFocused, - isGroupPolicyReport, - policyID, ref, }: SuggestionProps) { const suggestionEmojiRef = useRef(null); @@ -71,7 +63,7 @@ function Suggestions({ const {isDraggingOver} = useDragAndDropState(); const prevIsDraggingOver = usePrevious(isDraggingOver); - const getSuggestions = useCallback(() => { + function getSuggestions() { if (suggestionEmojiRef.current?.getSuggestions) { const emojiSuggestions = suggestionEmojiRef.current.getSuggestions(); if (emojiSuggestions.length > 0) { @@ -87,59 +79,56 @@ function Suggestions({ } return []; - }, []); + } /** * Clean data related to EmojiSuggestions */ - const resetSuggestions = useCallback(() => { + function resetSuggestions() { suggestionEmojiRef.current?.resetSuggestions(); suggestionMentionRef.current?.resetSuggestions(); - }, []); + } /** * Listens for keyboard shortcuts and applies the action */ - const triggerHotkeyActions = useCallback((e: KeyboardEvent) => { + function triggerHotkeyActions(e: KeyboardEvent) { const emojiHandler = suggestionEmojiRef.current?.triggerHotkeyActions(e); const mentionHandler = suggestionMentionRef.current?.triggerHotkeyActions(e); return emojiHandler ?? mentionHandler; - }, []); + } - const onSelectionChange = useCallback((e: TextInputSelectionChangeEvent) => { + function onSelectionChange(e: TextInputSelectionChangeEvent) { const emojiHandler = suggestionEmojiRef.current?.onSelectionChange?.(e); suggestionMentionRef.current?.onSelectionChange?.(e); return emojiHandler; - }, []); + } - const updateShouldShowSuggestionMenuToFalse = useCallback(() => { + function updateShouldShowSuggestionMenuToFalse() { suggestionEmojiRef.current?.updateShouldShowSuggestionMenuToFalse(); suggestionMentionRef.current?.updateShouldShowSuggestionMenuToFalse(); - }, []); + } - const setShouldBlockSuggestionCalc = useCallback((shouldBlock: boolean) => { + function setShouldBlockSuggestionCalc(shouldBlock: boolean) { suggestionEmojiRef.current?.setShouldBlockSuggestionCalc(shouldBlock); suggestionMentionRef.current?.setShouldBlockSuggestionCalc(shouldBlock); - }, []); - const getIsSuggestionsMenuVisible = useCallback((): boolean => { + } + + function getIsSuggestionsMenuVisible(): boolean { const isEmojiVisible = suggestionEmojiRef.current?.getIsSuggestionsMenuVisible() ?? false; const isSuggestionVisible = suggestionMentionRef.current?.getIsSuggestionsMenuVisible() ?? false; return isEmojiVisible || isSuggestionVisible; - }, []); - - useImperativeHandle( - ref, - () => ({ - resetSuggestions, - onSelectionChange, - triggerHotkeyActions, - updateShouldShowSuggestionMenuToFalse, - setShouldBlockSuggestionCalc, - getSuggestions, - getIsSuggestionsMenuVisible, - }), - [onSelectionChange, resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse, getSuggestions, getIsSuggestionsMenuVisible], - ); + } + + useImperativeHandle(ref, () => ({ + resetSuggestions, + onSelectionChange, + triggerHotkeyActions, + updateShouldShowSuggestionMenuToFalse, + setShouldBlockSuggestionCalc, + getSuggestions, + getIsSuggestionsMenuVisible, + })); useEffect(() => { if (!(!prevIsDraggingOver && isDraggingOver)) { @@ -156,8 +145,6 @@ function Suggestions({ isAutoSuggestionPickerLarge, measureParentContainerAndReportCursor, isComposerFocused, - isGroupPolicyReport, - policyID, }; return ( diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index 97e5b5fcff096..67ffa2fb4fda8 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -73,9 +73,6 @@ type ReportActionItemMessageEditProps = { /** ID of the original report from which the given reportAction is first created */ originalReportID: string; - /** PolicyID of the policy the report belongs to */ - policyID?: string; - /** Position index of the report action in the overall report FlatList view */ index: number; @@ -104,7 +101,6 @@ function ReportActionItemMessageEdit({ draftMessage, reportID, originalReportID, - policyID, index, isGroupPolicyReport, shouldDisableEmojiPicker = false, @@ -594,8 +590,6 @@ function ReportActionItemMessageEdit({ isComposerFocused={textInputRef.current?.isFocused()} updateComment={updateDraft} measureParentContainerAndReportCursor={measureParentContainerAndReportCursor} - isGroupPolicyReport={isGroupPolicyReport} - policyID={policyID} value={draft} selection={selection} setSelection={setSelection} diff --git a/tests/unit/SuggestionMentionTest.tsx b/tests/unit/SuggestionMentionTest.tsx index e9f148640eaaf..108f6e49a7dbc 100644 --- a/tests/unit/SuggestionMentionTest.tsx +++ b/tests/unit/SuggestionMentionTest.tsx @@ -89,8 +89,6 @@ function renderSuggestionMention(value: string, updateComment = jest.fn(), selec isAutoSuggestionPickerLarge measureParentContainerAndReportCursor={() => {}} isComposerFocused - isGroupPolicyReport={false} - policyID="policyID" />, ); From 99ff23835f073b59255b56b419837133d5d49386 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 1 Apr 2026 12:26:25 +0200 Subject: [PATCH 08/57] useAttachmentUploadValidation: self-subscribe to policy, parentReport, date, user --- .../ReportActionCompose.tsx | 8 +------- .../useAttachmentUploadValidation.ts | 18 +++++++----------- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 61ecc365a3b2f..15481dfa85529 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -133,7 +133,6 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { const currentUserPersonalDetails = useCurrentUserPersonalDetails(); 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 [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); const {availableLoginsList} = useShortMentionsList(); @@ -176,7 +175,6 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`); const [initialModalState] = useOnyx(ONYXKEYS.MODAL); - const [newParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`); const [draftComment] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`); const [betas] = useOnyx(ONYXKEYS.BETAS); @@ -525,17 +523,13 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { }; const {validateAttachments, onReceiptDropped, PDFValidationComponent, ErrorModal} = useAttachmentUploadValidation({ - policy, reportID, + report, addAttachment, onAttachmentPreviewClose, exceededMaxLength, shouldAddOrReplaceReceipt, transactionID, - report, - newParentReport, - currentDate, - currentUserPersonalDetails, isAttachmentPreviewActive, setIsAttachmentPreviewActive, }); diff --git a/src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts b/src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts index 3f02902b44e68..73c61e8b712da 100644 --- a/src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts +++ b/src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts @@ -1,6 +1,7 @@ import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; import {useContext, useRef} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useFilesValidation from '@hooks/useFilesValidation'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -18,41 +19,36 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; -import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; import type {FileObject} from '@src/types/utils/Attachment'; type AttachmentUploadValidationProps = { - policy: OnyxEntry; reportID: string; + report: OnyxEntry; addAttachment: (file: FileObject | FileObject[]) => void; onAttachmentPreviewClose: () => void; exceededMaxLength: boolean | number | null; shouldAddOrReplaceReceipt: boolean; transactionID: string | undefined; - report: OnyxEntry; - newParentReport: OnyxEntry; - currentDate: string | undefined; - currentUserPersonalDetails: CurrentUserPersonalDetails; isAttachmentPreviewActive: boolean; setIsAttachmentPreviewActive: (isActive: boolean) => void; }; function useAttachmentUploadValidation({ - policy, reportID, + report, addAttachment, onAttachmentPreviewClose, exceededMaxLength, shouldAddOrReplaceReceipt, transactionID, - report, - newParentReport, - currentDate, - currentUserPersonalDetails, isAttachmentPreviewActive, setIsAttachmentPreviewActive, }: AttachmentUploadValidationProps) { const {translate} = useLocalize(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`); + const [newParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`); + const [currentDate] = useOnyx(ONYXKEYS.CURRENT_DATE); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy?.id}`); const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); From 5ec8f1a514154a25b157f0e2b0c3f20602d4677e Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 1 Apr 2026 12:34:12 +0200 Subject: [PATCH 09/57] Extract ComposerDropZone: guard-wrapped, self-subscribing drop zone Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ReportActionCompose/ComposerDropZone.tsx | 143 ++++++++++++++++++ .../ReportActionCompose.tsx | 59 ++------ 2 files changed, 152 insertions(+), 50 deletions(-) create mode 100644 src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx new file mode 100644 index 0000000000000..d989b39120081 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx @@ -0,0 +1,143 @@ +import React from 'react'; +import DragAndDropConsumer from '@components/DragAndDrop/Consumer'; +import DropZoneUI from '@components/DropZone/DropZoneUI'; +import DualDropZone from '@components/DropZone/DualDropZone'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePreferredPolicy from '@hooks/usePreferredPolicy'; +import useReportIsArchived from '@hooks/useReportIsArchived'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {getParentReport, isChatRoom, isGroupChat, isInvoiceReport, isReportApproved, isSettled, temporary_getMoneyRequestOptions} from '@libs/ReportUtils'; +import {hasReceipt as hasReceiptTransactionUtils} from '@libs/TransactionUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; + +type ComposerDropZoneProps = { + /** The ID of the report */ + reportID: string; + + /** Whether the current view allows adding or replacing a receipt */ + shouldAddOrReplaceReceipt: boolean; + + /** The transaction ID relevant to this report, if any */ + transactionID: string | undefined; + + /** Callback when an attachment file is dropped */ + onAttachmentDrop: (dragEvent: DragEvent) => void; + + /** Callback when a receipt file is dropped */ + onReceiptDrop: (dragEvent: DragEvent) => void; +}; + +type RichDropZoneProps = { + /** The ID of the report */ + reportID: string; + + /** Whether the current view allows adding or replacing a receipt */ + shouldAddOrReplaceReceipt: boolean; + + /** The transaction ID relevant to this report, if any */ + transactionID: string | undefined; + + /** Callback when an attachment file is dropped */ + onAttachmentDrop: (dragEvent: DragEvent) => void; + + /** Callback when a receipt file is dropped */ + onReceiptDrop: (dragEvent: DragEvent) => void; +}; + +function SimpleDropZone({onAttachmentDrop}: {onAttachmentDrop: (dragEvent: DragEvent) => void}) { + const styles = useThemeStyles(); + const theme = useTheme(); + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['MessageInABottle']); + + return ( + + + + ); +} + +function RichDropZone({reportID, shouldAddOrReplaceReceipt, transactionID, onAttachmentDrop, onReceiptDrop}: RichDropZoneProps) { + const styles = useThemeStyles(); + const theme = useTheme(); + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['MessageInABottle']); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`); + const [betas] = useOnyx(ONYXKEYS.BETAS); + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transactionID)}`); + const isReportArchived = useReportIsArchived(report?.reportID); + const {isRestrictedToPreferredPolicy} = usePreferredPolicy(); + + const reportParticipantIDs = Object.keys(report?.participants ?? {}) + .map(Number) + .filter((accountID) => accountID !== currentUserPersonalDetails.accountID); + + const hasReceipt = hasReceiptTransactionUtils(transaction); + + const shouldDisplayDualDropZone = (() => { + const parentReport = getParentReport(report); + const isSettledOrApproved = isSettled(report) || isSettled(parentReport) || isReportApproved({report}) || isReportApproved({report: parentReport}); + const hasMoneyRequestOptions = !!temporary_getMoneyRequestOptions(report, policy, reportParticipantIDs, betas, isReportArchived, isRestrictedToPreferredPolicy).length; + const canModifyReceipt = shouldAddOrReplaceReceipt && !isSettledOrApproved; + return canModifyReceipt || hasMoneyRequestOptions; + })(); + + if (shouldDisplayDualDropZone) { + return ( + + ); + } + + return ( + + + + ); +} + +function ComposerDropZone({reportID, shouldAddOrReplaceReceipt, transactionID, onAttachmentDrop, onReceiptDrop}: ComposerDropZoneProps) { + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + + // Cheap gate: rooms, groups, and invoices never show the dual drop zone. + // ~60% of chats hit this path with zero extra subscriptions. + if (isChatRoom(report) || isGroupChat(report) || isInvoiceReport(report)) { + return ; + } + + return ( + + ); +} + +export default ComposerDropZone; diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 15481dfa85529..4cadfe124bf7a 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -8,9 +8,6 @@ import type {OnyxEntry} from 'react-native-onyx'; import {useSharedValue} from 'react-native-reanimated'; import {scheduleOnUI} from 'react-native-worklets'; import type {Emoji} from '@assets/emojis/types'; -import DragAndDropConsumer from '@components/DragAndDrop/Consumer'; -import DropZoneUI from '@components/DropZone/DropZoneUI'; -import DualDropZone from '@components/DropZone/DualDropZone'; import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; import ExceededCommentLength from '@components/ExceededCommentLength'; import ImportedStateIndicator from '@components/ImportedStateIndicator'; @@ -24,18 +21,15 @@ import useHandleExceedMaxCommentLength from '@hooks/useHandleExceedMaxCommentLen import useHandleExceedMaxTaskTitleLength from '@hooks/useHandleExceedMaxTaskTitleLength'; import useIsInSidePanel from '@hooks/useIsInSidePanel'; import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTriggered'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; import useParentReportAction from '@hooks/useParentReportAction'; -import usePreferredPolicy from '@hooks/usePreferredPolicy'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useShortMentionsList from '@hooks/useShortMentionsList'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {addComment} from '@libs/actions/Report'; import {createTaskAndNavigate, setNewOptimisticAssignee} from '@libs/actions/Task'; @@ -65,19 +59,12 @@ import { canUserPerformWriteAction as canUserPerformWriteActionReportUtils, chatIncludesChronos, chatIncludesConcierge, - getParentReport, getReportOfflinePendingActionAndErrors, getReportRecipientAccountIDs, - isChatRoom, - isGroupChat, - isInvoiceReport, - isReportApproved, isReportTransactionThread, - isSettled, - temporary_getMoneyRequestOptions, } from '@libs/ReportUtils'; import {startSpan} from '@libs/telemetry/activeSpans'; -import {getTransactionID, hasReceipt as hasReceiptTransactionUtils} from '@libs/TransactionUtils'; +import {getTransactionID} from '@libs/TransactionUtils'; import {generateAccountID} from '@libs/UserUtils'; import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; import {useAgentZeroStatusActions} from '@pages/inbox/AgentZeroStatusContext'; @@ -94,6 +81,7 @@ import type {FileObject} from '@src/types/utils/Attachment'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import AgentZeroAwareTypingIndicator from './AgentZeroAwareTypingIndicator'; import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; +import ComposerDropZone from './ComposerDropZone'; import ComposerWithSuggestions from './ComposerWithSuggestions'; import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; import SendButton from './SendButton'; @@ -122,7 +110,6 @@ const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); function ReportActionCompose({reportID}: ReportActionComposeProps) { const styles = useThemeStyles(); - const theme = useTheme(); const {translate} = useLocalize(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth, isMediumScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); @@ -137,7 +124,6 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); const {availableLoginsList} = useShortMentionsList(); const currentUserEmail = currentUserPersonalDetails.email ?? ''; - const {isRestrictedToPreferredPolicy} = usePreferredPolicy(); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); @@ -173,10 +159,8 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { const {reportPendingAction: pendingAction} = getReportOfflinePendingActionAndErrors(report); - const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`); const [initialModalState] = useOnyx(ONYXKEYS.MODAL); const [draftComment] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`); - const [betas] = useOnyx(ONYXKEYS.BETAS); const shouldFocusComposerOnScreenFocus = shouldFocusInputOnScreenFocus || !!draftComment; @@ -223,8 +207,6 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { return null; })(); - const icons = useMemoizedLazyExpensifyIcons(['MessageInABottle']); - const suggestionsRef = useRef(null); const composerRef = useRef(null); const reportParticipantIDs = Object.keys(report?.participants ?? {}) @@ -260,17 +242,6 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { !transaction?.receipt?.isTestDriveReceipt; const shouldAddOrReplaceReceipt = (isTransactionThreadView || isSingleTransactionView) && canEditReceipt; - const hasReceipt = hasReceiptTransactionUtils(transaction); - - const shouldDisplayDualDropZone = (() => { - const parentReport = getParentReport(report); - const isSettledOrApproved = isSettled(report) || isSettled(parentReport) || isReportApproved({report}) || isReportApproved({report: parentReport}); - const hasMoneyRequestOptions = !!temporary_getMoneyRequestOptions(report, policy, reportParticipantIDs, betas, isReportArchived, isRestrictedToPreferredPolicy).length; - const canModifyReceipt = shouldAddOrReplaceReceipt && !isSettledOrApproved; - const isRoomOrGroupChat = isChatRoom(report) || isGroupChat(report); - return !isRoomOrGroupChat && (canModifyReceipt || hasMoneyRequestOptions) && !isInvoiceReport(report); - })(); - // Placeholder to display in the chat input. const inputPlaceholder = includesConcierge && userBlockedFromConcierge ? translate('reportActionCompose.blockedFromConcierge') : translate('reportActionCompose.writeSomething'); @@ -619,25 +590,13 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { onValueChange={onValueChange} forwardedFSClass={fsClass} /> - {shouldDisplayDualDropZone && ( - validateAttachments({dragEvent})} - onReceiptDrop={onReceiptDropped} - shouldAcceptSingleReceipt={shouldAddOrReplaceReceipt} - /> - )} - {!shouldDisplayDualDropZone && ( - validateAttachments({dragEvent})}> - - - )} + validateAttachments({dragEvent})} + onReceiptDrop={onReceiptDropped} + /> {canUseTouchScreen() && isMediumScreenWidth ? null : ( Date: Wed, 1 Apr 2026 12:40:49 +0200 Subject: [PATCH 10/57] Extract ComposerLocalTime (guard-wrapped) and ComposerFooter --- .../ReportActionCompose/ComposerFooter.tsx | 37 ++++++++++ .../ReportActionCompose/ComposerLocalTime.tsx | 71 +++++++++++++++++++ .../ReportActionCompose.tsx | 50 ++++--------- 3 files changed, 123 insertions(+), 35 deletions(-) create mode 100644 src/pages/inbox/report/ReportActionCompose/ComposerFooter.tsx create mode 100644 src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerFooter.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerFooter.tsx new file mode 100644 index 0000000000000..250517b59e6ab --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ComposerFooter.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import {View} from 'react-native'; +import ExceededCommentLength from '@components/ExceededCommentLength'; +import OfflineIndicator from '@components/OfflineIndicator'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +import AgentZeroAwareTypingIndicator from './AgentZeroAwareTypingIndicator'; + +type ComposerFooterProps = { + reportID: string; + exceededMaxLength: number | null; + hasExceededMaxTaskTitleLength: boolean; + isOffline: boolean; +}; + +function ComposerFooter({reportID, exceededMaxLength, hasExceededMaxTaskTitleLength, isOffline}: ComposerFooterProps) { + const styles = useThemeStyles(); + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); + + return ( + + {!shouldUseNarrowLayout && } + + {!!exceededMaxLength && ( + + )} + + ); +} + +export default ComposerFooter; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx new file mode 100644 index 0000000000000..29aff73d0e0bc --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import {usePersonalDetails} from '@components/OnyxListItemProvider'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import {canShowReportRecipientLocalTime, getReportRecipientAccountIDs} from '@libs/ReportUtils'; +import ParticipantLocalTime from '@pages/inbox/report/ParticipantLocalTime'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PendingAction} from '@src/types/onyx/OnyxCommon'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +// Outer guard: cheap selector — only participant count and chatType. +// Returns true only for likely 1:1 DMs (no chatType, at most 2 participants). +function useLooksLikeDM(reportID: string): boolean { + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, { + selector: (r: OnyxEntry<{participants?: Record; chatType?: string}>) => ({ + participantCount: Object.keys(r?.participants ?? {}).length, + chatType: r?.chatType, + }), + }); + return (report?.participantCount ?? 0) <= 2 && !report?.chatType; +} + +type ComposerLocalTimeProps = { + reportID: string; + pendingAction: PendingAction | undefined; + isComposerFullSize: boolean; +}; + +function ComposerLocalTimeInner({reportID, pendingAction, isComposerFullSize}: ComposerLocalTimeProps) { + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const personalDetails = usePersonalDetails(); + const {isOffline} = useNetwork(); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + + const shouldShowReportRecipientLocalTime = canShowReportRecipientLocalTime(personalDetails, report, currentUserPersonalDetails.accountID) && !isComposerFullSize; + + const reportRecipientAccountIDs = getReportRecipientAccountIDs(report, currentUserPersonalDetails.accountID); + const reportRecipient = personalDetails?.[reportRecipientAccountIDs[0]]; + const hasReportRecipient = !isEmptyObject(reportRecipient); + + if (!shouldShowReportRecipientLocalTime || !hasReportRecipient || isOffline) { + return null; + } + + return ( + + + + ); +} + +function ComposerLocalTime({reportID, pendingAction, isComposerFullSize}: ComposerLocalTimeProps) { + const looksLikeDM = useLooksLikeDM(reportID); + + if (!looksLikeDM) { + return null; + } + + return ( + + ); +} + +export default ComposerLocalTime; diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 4cadfe124bf7a..412f1575b93f6 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -9,10 +9,8 @@ import {useSharedValue} from 'react-native-reanimated'; import {scheduleOnUI} from 'react-native-worklets'; import type {Emoji} from '@assets/emojis/types'; import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; -import ExceededCommentLength from '@components/ExceededCommentLength'; import ImportedStateIndicator from '@components/ImportedStateIndicator'; import type {Mention} from '@components/MentionSuggestions'; -import OfflineIndicator from '@components/OfflineIndicator'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import useAncestors from '@hooks/useAncestors'; @@ -55,12 +53,10 @@ import { import { canEditFieldOfMoneyRequest, canEditReportAction, - canShowReportRecipientLocalTime, canUserPerformWriteAction as canUserPerformWriteActionReportUtils, chatIncludesChronos, chatIncludesConcierge, getReportOfflinePendingActionAndErrors, - getReportRecipientAccountIDs, isReportTransactionThread, } from '@libs/ReportUtils'; import {startSpan} from '@libs/telemetry/activeSpans'; @@ -68,7 +64,6 @@ import {getTransactionID} from '@libs/TransactionUtils'; import {generateAccountID} from '@libs/UserUtils'; import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; import {useAgentZeroStatusActions} from '@pages/inbox/AgentZeroStatusContext'; -import ParticipantLocalTime from '@pages/inbox/report/ParticipantLocalTime'; import {ActionListContext} from '@pages/inbox/ReportScreenContext'; import {hideEmojiPicker, isActive as isActiveEmojiPickerAction, isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; import {addAttachmentWithComment, setIsComposerFullSize} from '@userActions/Report'; @@ -78,10 +73,10 @@ import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; import type {FileObject} from '@src/types/utils/Attachment'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import AgentZeroAwareTypingIndicator from './AgentZeroAwareTypingIndicator'; import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; import ComposerDropZone from './ComposerDropZone'; +import ComposerFooter from './ComposerFooter'; +import ComposerLocalTime from './ComposerLocalTime'; import ComposerWithSuggestions from './ComposerWithSuggestions'; import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; import SendButton from './SendButton'; @@ -112,7 +107,7 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {isSmallScreenWidth, isMediumScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); + const {isSmallScreenWidth, isMediumScreenWidth} = useResponsiveLayout(); const {isOffline} = useNetwork(); const isInSidePanel = useIsInSidePanel(); const {kickoffWaitingIndicator} = useAgentZeroStatusActions(); @@ -213,8 +208,6 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { .map(Number) .filter((accountID) => accountID !== currentUserPersonalDetails.accountID); - const shouldShowReportRecipientLocalTime = canShowReportRecipientLocalTime(personalDetails, report, currentUserPersonalDetails.accountID) && !isComposerFullSize; - const includesConcierge = chatIncludesConcierge({participants: report?.participants}); const userBlockedFromConcierge = isBlockedFromConciergeUserAction(blockedFromConcierge); const isBlockedFromConcierge = includesConcierge && userBlockedFromConcierge; @@ -433,12 +426,8 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { // When we invite someone to a room they don't have the policy object, but we still want them to be able to mention other reports they are members of, so we only check if the policyID in the report is from a workspace const isGroupPolicyReport = !!report?.policyID && report.policyID !== CONST.POLICY.ID_FAKE; - const reportRecipientAccountIDs = getReportRecipientAccountIDs(report, currentUserPersonalDetails.accountID); - const reportRecipient = personalDetails?.[reportRecipientAccountIDs[0]]; const shouldUseFocusedColor = !isBlockedFromConcierge && isFocused; - const hasReportRecipient = !isEmptyObject(reportRecipient); - const isSendDisabled = isCommentEmpty || isBlockedFromConcierge || !!exceededMaxLength; const validateMaxLength = (value: string) => { @@ -512,10 +501,12 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { const fsClass = FS.getChatFSClass(report); return ( - - - {shouldShowReportRecipientLocalTime && hasReportRecipient && } - + + {ErrorModal} - - {!shouldUseNarrowLayout && } - - {!!exceededMaxLength && ( - - )} - + {!isSmallScreenWidth && ( From e51aa0bb8c065731d2a45b77fd92665e1f51ae05 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 1 Apr 2026 12:46:44 +0200 Subject: [PATCH 11/57] Extract useComposerSubmit + useComposerFocus hooks Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ReportActionCompose.tsx | 218 ++---------------- .../ReportActionCompose/useComposerFocus.ts | 62 +++++ .../ReportActionCompose/useComposerSubmit.ts | 186 +++++++++++++++ 3 files changed, 270 insertions(+), 196 deletions(-) create mode 100644 src/pages/inbox/report/ReportActionCompose/useComposerFocus.ts create mode 100644 src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 412f1575b93f6..b87ffa17536ac 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -1,10 +1,8 @@ import {useRoute} from '@react-navigation/native'; -import {Str} from 'expensify-common'; import lodashDebounce from 'lodash/debounce'; -import React, {useContext, useEffect, useRef, useState} from 'react'; -import type {BlurEvent, MeasureInWindowOnSuccessCallback, TextInputSelectionChangeEvent} from 'react-native'; +import React, {useEffect, useRef, useState} from 'react'; +import type {MeasureInWindowOnSuccessCallback, TextInputSelectionChangeEvent} from 'react-native'; import {View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; import {useSharedValue} from 'react-native-reanimated'; import {scheduleOnUI} from 'react-native-worklets'; import type {Emoji} from '@assets/emojis/types'; @@ -12,12 +10,9 @@ import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; import ImportedStateIndicator from '@components/ImportedStateIndicator'; import type {Mention} from '@components/MentionSuggestions'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import {usePersonalDetails} from '@components/OnyxListItemProvider'; -import useAncestors from '@hooks/useAncestors'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useHandleExceedMaxCommentLength from '@hooks/useHandleExceedMaxCommentLength'; import useHandleExceedMaxTaskTitleLength from '@hooks/useHandleExceedMaxTaskTitleLength'; -import useIsInSidePanel from '@hooks/useIsInSidePanel'; import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTriggered'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -27,20 +22,13 @@ import useParentReportAction from '@hooks/useParentReportAction'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useShortMentionsList from '@hooks/useShortMentionsList'; import useThemeStyles from '@hooks/useThemeStyles'; -import {addComment} from '@libs/actions/Report'; -import {createTaskAndNavigate, setNewOptimisticAssignee} from '@libs/actions/Task'; import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; -import ComposerFocusManager from '@libs/ComposerFocusManager'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import DomUtils from '@libs/DomUtils'; import FS from '@libs/Fullstory'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {isEmailPublicDomain} from '@libs/LoginUtils'; import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; -import {rand64} from '@libs/NumberUtils'; -import {addDomainToShortMention} from '@libs/ParsingUtils'; import { getCombinedReportActions, getFilteredReportActionsForReportView, @@ -59,20 +47,13 @@ import { getReportOfflinePendingActionAndErrors, isReportTransactionThread, } from '@libs/ReportUtils'; -import {startSpan} from '@libs/telemetry/activeSpans'; import {getTransactionID} from '@libs/TransactionUtils'; -import {generateAccountID} from '@libs/UserUtils'; -import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; -import {useAgentZeroStatusActions} from '@pages/inbox/AgentZeroStatusContext'; -import {ActionListContext} from '@pages/inbox/ReportScreenContext'; import {hideEmojiPicker, isActive as isActiveEmojiPickerAction, isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; -import {addAttachmentWithComment, setIsComposerFullSize} from '@userActions/Report'; +import {setIsComposerFullSize} from '@userActions/Report'; import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; -import type * as OnyxTypes from '@src/types/onyx'; -import type {FileObject} from '@src/types/utils/Attachment'; import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; import ComposerDropZone from './ComposerDropZone'; import ComposerFooter from './ComposerFooter'; @@ -81,6 +62,8 @@ import ComposerWithSuggestions from './ComposerWithSuggestions'; import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; import SendButton from './SendButton'; import useAttachmentUploadValidation from './useAttachmentUploadValidation'; +import useComposerFocus from './useComposerFocus'; +import useComposerSubmit from './useComposerSubmit'; type SuggestionsRef = { resetSuggestions: () => void; @@ -101,24 +84,16 @@ type ReportActionComposeProps = { // prevent auto focus on existing chat for mobile device const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); -const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); - function ReportActionCompose({reportID}: ReportActionComposeProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth, isMediumScreenWidth} = useResponsiveLayout(); const {isOffline} = useNetwork(); - const isInSidePanel = useIsInSidePanel(); - const {kickoffWaitingIndicator} = useAgentZeroStatusActions(); const actionButtonRef = useRef(null); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const personalDetails = usePersonalDetails(); const [blockedFromConcierge] = useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); const [shouldShowComposeInput = true] = useOnyx(ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT); - const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); - const {availableLoginsList} = useShortMentionsList(); - const currentUserEmail = currentUserPersonalDetails.email ?? ''; const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); @@ -159,11 +134,6 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { 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 */ @@ -238,16 +208,6 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { // Placeholder to display in the chat input. const inputPlaceholder = includesConcierge && userBlockedFromConcierge ? translate('reportActionCompose.blockedFromConcierge') : translate('reportActionCompose.writeSomething'); - const focus = () => { - if (composerRef.current === null) { - return; - } - composerRef.current?.focus(true); - }; - - const isKeyboardVisibleWhenShowingModalRef = useRef(false); - const isNextModalWillOpenRef = useRef(false); - const containerRef = useRef(null); const measureContainer = (callback: MeasureInWindowOnSuccessCallback) => { if (!containerRef.current) { @@ -256,17 +216,6 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { containerRef.current.measureInWindow(callback); }; - const onAddActionPressed = () => { - if (!willBlurTextInputOnTapOutside) { - isKeyboardVisibleWhenShowingModalRef.current = !!composerRef.current?.isFocused(); - } - composerRef.current?.blur(); - }; - - const onItemSelected = () => { - isKeyboardVisibleWhenShowingModalRef.current = false; - }; - const updateShouldShowSuggestionMenuToFalse = () => { if (!suggestionsRef.current) { return; @@ -274,144 +223,25 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false); }; - const attachmentFileRef = useRef(null); - - const addAttachment = (file: FileObject | FileObject[]) => { - attachmentFileRef.current = file; - - 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.'); - } - - scheduleOnUI(clearWorklet); - }; - - /** - * Event handler to update the state after the attachment preview is closed. - */ - const onAttachmentPreviewClose = () => { - updateShouldShowSuggestionMenuToFalse(); - setIsAttachmentPreviewActive(false); - // This enables Composer refocus when the attachments modal is closed by the browser navigation - ComposerFocusManager.setReadyToFocus(); - }; - - /** - * Add a new comment to this chat - */ - const submitForm = (newComment: string) => { - const newCommentTrimmed = newComment.trim(); - - kickoffWaitingIndicator(); - - if (attachmentFileRef.current) { - addAttachmentWithComment({ - report: targetReport, - notifyReportID: reportID, - ancestors: targetReportAncestors, - attachments: attachmentFileRef.current, - currentUserAccountID: currentUserPersonalDetails.accountID, - text: newCommentTrimmed, - timezone: currentUserPersonalDetails.timezone, - shouldPlaySound: true, - isInSidePanel, - }); - attachmentFileRef.current = null; - } else { - const taskMatch = newCommentTrimmed.match(CONST.REGEX.TASK_TITLE_WITH_OPTIONAL_SHORT_MENTION); - if (taskMatch) { - let taskTitle = taskMatch[3] ? taskMatch[3].trim().replaceAll('\n', ' ') : undefined; - if (taskTitle) { - const mention = taskMatch[1] ? taskMatch[1].trim() : ''; - const currentUserPrivateDomain = isEmailPublicDomain(currentUserEmail) ? '' : Str.extractEmailDomain(currentUserEmail); - const mentionWithDomain = addDomainToShortMention(mention, availableLoginsList, currentUserPrivateDomain) ?? mention; - const isValidMention = Str.isValidEmail(mentionWithDomain); - - let assignee: OnyxEntry; - let assigneeChatReport; - if (mentionWithDomain) { - if (isValidMention) { - assignee = Object.values(personalDetails ?? {}).find((value) => value?.login === mentionWithDomain) ?? undefined; - if (!Object.keys(assignee ?? {}).length) { - const optimisticDataForNewAssignee = setNewOptimisticAssignee(currentUserPersonalDetails.accountID, { - accountID: generateAccountID(mentionWithDomain), - login: mentionWithDomain, - }); - assignee = optimisticDataForNewAssignee.assignee; - assigneeChatReport = optimisticDataForNewAssignee.assigneeReport; - } - } else { - taskTitle = `@${mentionWithDomain} ${taskTitle}`; - } - } - createTaskAndNavigate({ - parentReport: report, - title: taskTitle, - description: '', - assigneeEmail: assignee?.login ?? '', - currentUserAccountID: currentUserPersonalDetails.accountID, - currentUserEmail, - assigneeAccountID: assignee?.accountID, - assigneeChatReport, - policyID: report?.policyID, - isCreatedUsingMarkdown: true, - quickAction, - ancestors: reportAncestors, - }); - return; - } - } - - // Pre-generate the reportActionID so we can correlate the Sentry send-message span with the exact message - const optimisticReportActionID = rand64(); - - // The list is inverted, so an offset near 0 means the user is at the bottom (newest messages visible). - const isScrolledToBottom = scrollOffsetRef.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; - if (isScrolledToBottom) { - startSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE}_${optimisticReportActionID}`, { - name: 'send-message', - op: CONST.TELEMETRY.SPAN_SEND_MESSAGE, - attributes: { - [CONST.TELEMETRY.ATTRIBUTE_REPORT_ID]: reportID, - [CONST.TELEMETRY.ATTRIBUTE_MESSAGE_LENGTH]: newCommentTrimmed.length, - }, - }); - } - addComment({ - report: targetReport, - notifyReportID: reportID, - ancestors: targetReportAncestors, - text: newCommentTrimmed, - timezoneParam: currentUserPersonalDetails.timezone ?? CONST.DEFAULT_TIME_ZONE, - currentUserAccountID: currentUserPersonalDetails.accountID, - shouldPlaySound: true, - isInSidePanel, - reportActionID: optimisticReportActionID, - }); - } - }; - - const onTriggerAttachmentPicker = () => { - isNextModalWillOpenRef.current = true; - isKeyboardVisibleWhenShowingModalRef.current = true; - }; + // 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 onBlur = (event: BlurEvent) => { - const webEvent = event as unknown as FocusEvent; - setIsFocused(false); - if (suggestionsRef.current) { - suggestionsRef.current.resetSuggestions(); - } - if (webEvent.relatedTarget && webEvent.relatedTarget === actionButtonRef.current) { - isKeyboardVisibleWhenShowingModalRef.current = true; - } - }; + const {onBlur, onFocus, focus, onAddActionPressed, onItemSelected, onTriggerAttachmentPicker, isNextModalWillOpenRef} = useComposerFocus({ + composerRef, + suggestionsRef, + actionButtonRef, + setIsFocused, + }); - const onFocus = () => { - setIsFocused(true); - }; + const {submitForm, addAttachment, onAttachmentPreviewClose} = useComposerSubmit({ + report, + reportID, + effectiveTransactionThreadReportID, + composerRefShared, + updateShouldShowSuggestionMenuToFalse, + setIsAttachmentPreviewActive, + }); // Hide emoji picker on unmount or when switching reports useEffect( @@ -443,10 +273,6 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { const debouncedValidate = lodashDebounce(validateMaxLength, CONST.TIMING.COMMENT_LENGTH_DEBOUNCE_TIME, {leading: true}); - // 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 handleSendMessage = () => { if (isSendDisabled || !debouncedValidate.flush()) { return; diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerFocus.ts b/src/pages/inbox/report/ReportActionCompose/useComposerFocus.ts new file mode 100644 index 0000000000000..ac0d5059843a5 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/useComposerFocus.ts @@ -0,0 +1,62 @@ +import {useRef} from 'react'; +import type {RefObject} from 'react'; +import type {BlurEvent, View} from 'react-native'; +import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; +import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; +import type {SuggestionsRef} from './ReportActionCompose'; + +const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); + +type UseComposerFocusParams = { + composerRef: RefObject; + suggestionsRef: RefObject; + actionButtonRef: RefObject; + setIsFocused: (value: boolean) => void; +}; + +function useComposerFocus({composerRef, suggestionsRef, actionButtonRef, setIsFocused}: UseComposerFocusParams) { + const isKeyboardVisibleWhenShowingModalRef = useRef(false); + const isNextModalWillOpenRef = useRef(false); + + const focus = () => { + if (composerRef.current === null) { + return; + } + composerRef.current?.focus(true); + }; + + const onAddActionPressed = () => { + if (!willBlurTextInputOnTapOutside) { + isKeyboardVisibleWhenShowingModalRef.current = !!composerRef.current?.isFocused(); + } + composerRef.current?.blur(); + }; + + const onItemSelected = () => { + isKeyboardVisibleWhenShowingModalRef.current = false; + }; + + const onTriggerAttachmentPicker = () => { + isNextModalWillOpenRef.current = true; + isKeyboardVisibleWhenShowingModalRef.current = true; + }; + + const onBlur = (event: BlurEvent) => { + const webEvent = event as unknown as FocusEvent; + setIsFocused(false); + if (suggestionsRef.current) { + suggestionsRef.current.resetSuggestions(); + } + if (webEvent.relatedTarget && webEvent.relatedTarget === actionButtonRef.current) { + isKeyboardVisibleWhenShowingModalRef.current = true; + } + }; + + const onFocus = () => { + setIsFocused(true); + }; + + return {onBlur, onFocus, focus, onAddActionPressed, onItemSelected, onTriggerAttachmentPicker, isNextModalWillOpenRef}; +} + +export default useComposerFocus; diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts new file mode 100644 index 0000000000000..e7169518b0a1a --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts @@ -0,0 +1,186 @@ +import {Str} from 'expensify-common'; +import {useContext, useRef} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import {scheduleOnUI} from 'react-native-worklets'; +import {usePersonalDetails} from '@components/OnyxListItemProvider'; +import useAncestors from '@hooks/useAncestors'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useIsInSidePanel from '@hooks/useIsInSidePanel'; +import useOnyx from '@hooks/useOnyx'; +import useShortMentionsList from '@hooks/useShortMentionsList'; +import {addComment} from '@libs/actions/Report'; +import ComposerFocusManager from '@libs/ComposerFocusManager'; +import {isEmailPublicDomain} from '@libs/LoginUtils'; +import {rand64} from '@libs/NumberUtils'; +import {addDomainToShortMention} from '@libs/ParsingUtils'; +import {startSpan} from '@libs/telemetry/activeSpans'; +import {generateAccountID} from '@libs/UserUtils'; +import {useAgentZeroStatusActions} from '@pages/inbox/AgentZeroStatusContext'; +import {ActionListContext} from '@pages/inbox/ReportScreenContext'; +import {addAttachmentWithComment} from '@userActions/Report'; +import {createTaskAndNavigate, setNewOptimisticAssignee} from '@userActions/Task'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; +import type {FileObject} from '@src/types/utils/Attachment'; +import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; + +type UseComposerSubmitParams = { + report: OnyxEntry; + reportID: string; + effectiveTransactionThreadReportID?: string; + composerRefShared: {get: () => Partial}; + updateShouldShowSuggestionMenuToFalse: () => void; + setIsAttachmentPreviewActive: (value: boolean) => void; +}; + +function useComposerSubmit({ + report, + reportID, + effectiveTransactionThreadReportID, + composerRefShared, + updateShouldShowSuggestionMenuToFalse, + setIsAttachmentPreviewActive, +}: UseComposerSubmitParams) { + const isInSidePanel = useIsInSidePanel(); + const {kickoffWaitingIndicator} = useAgentZeroStatusActions(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const personalDetails = usePersonalDetails(); + const {availableLoginsList} = useShortMentionsList(); + const {scrollOffsetRef} = useContext(ActionListContext); + + const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); + const [targetReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${effectiveTransactionThreadReportID ?? reportID}`); + + const reportAncestors = useAncestors(report); + const targetReportAncestors = useAncestors(targetReport); + + const attachmentFileRef = useRef(null); + + const currentUserEmail = currentUserPersonalDetails.email ?? ''; + + const addAttachment = (file: FileObject | FileObject[]) => { + attachmentFileRef.current = file; + + const clearWorklet = composerRefShared.get().clearWorklet; + + if (!clearWorklet) { + throw new Error('The composerRef.clearWorklet function is not set yet. This should never happen, and indicates a developer error.'); + } + + scheduleOnUI(clearWorklet); + }; + + const onAttachmentPreviewClose = () => { + updateShouldShowSuggestionMenuToFalse(); + setIsAttachmentPreviewActive(false); + // This enables Composer refocus when the attachments modal is closed by the browser navigation + ComposerFocusManager.setReadyToFocus(); + }; + + const handleCreateTask = (text: string): boolean => { + const match = text.match(CONST.REGEX.TASK_TITLE_WITH_OPTIONAL_SHORT_MENTION); + if (!match) { + return false; + } + let title = match[3] ? match[3].trim().replaceAll('\n', ' ') : undefined; + if (!title) { + return false; + } + + const mention = match[1] ? match[1].trim() : ''; + const currentUserPrivateDomain = isEmailPublicDomain(currentUserEmail) ? '' : Str.extractEmailDomain(currentUserEmail); + const mentionWithDomain = addDomainToShortMention(mention, availableLoginsList, currentUserPrivateDomain) ?? mention; + const isValidMention = Str.isValidEmail(mentionWithDomain); + + let assignee: OnyxEntry; + let assigneeChatReport; + if (mentionWithDomain) { + if (isValidMention) { + assignee = Object.values(personalDetails ?? {}).find((value) => value?.login === mentionWithDomain) ?? undefined; + if (!Object.keys(assignee ?? {}).length) { + const optimisticDataForNewAssignee = setNewOptimisticAssignee(currentUserPersonalDetails.accountID, { + accountID: generateAccountID(mentionWithDomain), + login: mentionWithDomain, + }); + assignee = optimisticDataForNewAssignee.assignee; + assigneeChatReport = optimisticDataForNewAssignee.assigneeReport; + } + } else { + title = `@${mentionWithDomain} ${title}`; + } + } + createTaskAndNavigate({ + parentReport: report, + title, + description: '', + assigneeEmail: assignee?.login ?? '', + currentUserAccountID: currentUserPersonalDetails.accountID, + currentUserEmail, + assigneeAccountID: assignee?.accountID, + assigneeChatReport, + policyID: report?.policyID, + isCreatedUsingMarkdown: true, + quickAction, + ancestors: reportAncestors, + }); + return true; + }; + + const submitForm = (newComment: string) => { + const newCommentTrimmed = newComment.trim(); + + kickoffWaitingIndicator(); + + if (attachmentFileRef.current) { + addAttachmentWithComment({ + report: targetReport, + notifyReportID: reportID, + ancestors: targetReportAncestors, + attachments: attachmentFileRef.current, + currentUserAccountID: currentUserPersonalDetails.accountID, + text: newCommentTrimmed, + timezone: currentUserPersonalDetails.timezone, + shouldPlaySound: true, + isInSidePanel, + }); + attachmentFileRef.current = null; + return; + } + + if (handleCreateTask(newCommentTrimmed)) { + return; + } + + // Pre-generate the reportActionID so we can correlate the Sentry send-message span with the exact message + const optimisticReportActionID = rand64(); + + // The list is inverted, so an offset near 0 means the user is at the bottom (newest messages visible). + const isScrolledToBottom = scrollOffsetRef.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; + if (isScrolledToBottom) { + startSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE}_${optimisticReportActionID}`, { + name: 'send-message', + op: CONST.TELEMETRY.SPAN_SEND_MESSAGE, + attributes: { + [CONST.TELEMETRY.ATTRIBUTE_REPORT_ID]: reportID, + [CONST.TELEMETRY.ATTRIBUTE_MESSAGE_LENGTH]: newCommentTrimmed.length, + }, + }); + } + addComment({ + report: targetReport, + notifyReportID: reportID, + ancestors: targetReportAncestors, + text: newCommentTrimmed, + timezoneParam: currentUserPersonalDetails.timezone ?? CONST.DEFAULT_TIME_ZONE, + currentUserAccountID: currentUserPersonalDetails.accountID, + shouldPlaySound: true, + isInSidePanel, + reportActionID: optimisticReportActionID, + }); + }; + + return {submitForm, addAttachment, onAttachmentPreviewClose}; +} + +export default useComposerSubmit; From a791e48c6860405dd0b35540764cd8d882e430f0 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 1 Apr 2026 12:57:53 +0200 Subject: [PATCH 12/57] ComposerProvider + ComposerBox + Composer orchestrator with compound API Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ReportActionCompose/ComposerBox.tsx | 87 +++ .../ReportActionCompose/ComposerContext.ts | 152 +++++ .../ReportActionCompose/ComposerProvider.tsx | 231 ++++++++ .../ComposerWithSuggestions.tsx | 2 +- .../ReportActionCompose.tsx | 537 +++++++----------- .../ReportActionCompose/Suggestions.tsx | 2 +- .../ReportActionCompose/useComposerFocus.ts | 2 +- .../report/ReportActionItemMessageEdit.tsx | 2 +- 8 files changed, 677 insertions(+), 338 deletions(-) create mode 100644 src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx create mode 100644 src/pages/inbox/report/ReportActionCompose/ComposerContext.ts create mode 100644 src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx new file mode 100644 index 0000000000000..d89ae6f1145cf --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx @@ -0,0 +1,87 @@ +import React, {createContext, useContext, useEffect, useRef} from 'react'; +import type {MeasureInWindowOnSuccessCallback} from 'react-native'; +import {View} from 'react-native'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {hideEmojiPicker, isActive as isActiveEmojiPickerAction} from '@userActions/EmojiPickerAction'; +import type {PendingAction} from '@src/types/onyx/OnyxCommon'; +import {useComposerInternalsData, useComposerSendState, useComposerState} from './ComposerContext'; + +type ComposerBoxContextValue = { + measureContainer: (callback: MeasureInWindowOnSuccessCallback) => void; +}; + +const ComposerBoxContext = createContext({ + measureContainer: () => {}, +}); + +function useComposerBox() { + return useContext(ComposerBoxContext); +} + +type ComposerBoxProps = { + reportID: string; + isComposerFullSize: boolean; + pendingAction: PendingAction | undefined; + children: React.ReactNode; +}; + +function ComposerBox({reportID, isComposerFullSize, pendingAction, children}: ComposerBoxProps) { + const styles = useThemeStyles(); + const {isFocused} = useComposerState(); + const {exceededMaxLength, isBlockedFromConcierge} = useComposerSendState(); + const {PDFValidationComponent, ErrorModal} = useComposerInternalsData(); + + const shouldUseFocusedColor = !isBlockedFromConcierge && isFocused; + + const containerRef = useRef(null); + const measureContainer = (callback: MeasureInWindowOnSuccessCallback) => { + if (!containerRef.current) { + return; + } + containerRef.current.measureInWindow(callback); + }; + + // Hide emoji picker on unmount or when switching reports + useEffect( + () => () => { + if (!isActiveEmojiPickerAction(reportID)) { + return; + } + hideEmojiPicker(); + }, + [reportID], + ); + + const contextValue = {measureContainer}; + + return ( + + + + {PDFValidationComponent} + {children} + + {ErrorModal} + + + ); +} + +export default ComposerBox; +export {useComposerBox}; +export type {ComposerBoxProps}; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts new file mode 100644 index 0000000000000..5a87220fbe497 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts @@ -0,0 +1,152 @@ +import type {ReactNode, 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 {Mention} from '@components/MentionSuggestions'; +import type {FileObject} from '@src/types/utils/Attachment'; +import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; + +type SuggestionsRef = { + resetSuggestions: () => void; + onSelectionChange?: (event: TextInputSelectionChangeEvent) => void; + triggerHotkeyActions: (event: KeyboardEvent) => boolean | undefined; + updateShouldShowSuggestionMenuToFalse: (shouldShowSuggestionMenu?: boolean) => void; + setShouldBlockSuggestionCalc: (shouldBlock: boolean) => void; + getSuggestions: () => Mention[] | Emoji[]; + getIsSuggestionsMenuVisible: () => boolean; +}; + +type ComposerState = { + isFocused: boolean; + isFullComposerAvailable: boolean; + isComposerFullSize: boolean; + isMenuVisible: boolean; +}; + +type ComposerSendState = { + isEmpty: boolean; + exceededMaxLength: number | null; + isSendDisabled: boolean; + isBlockedFromConcierge: boolean; + hasExceededMaxTaskTitleLength: boolean; +}; + +type ComposerActions = { + setIsFocused: (v: boolean) => void; + setIsFullComposerAvailable: (v: boolean) => void; + setMenuVisibility: (v: boolean) => void; + setIsCommentEmpty: (isEmpty: boolean) => void; + handleSendMessage: () => void; + focus: () => void; + onValueChange: (value: string) => void; + validateMaxLength: (value: string) => boolean; + debouncedValidate: { + (value: string): boolean | undefined; + cancel: () => void; + flush: () => boolean | undefined; + }; +}; + +type ComposerInternalsData = { + composerRef: RefObject; + suggestionsRef: RefObject; + actionButtonRef: RefObject; + isNextModalWillOpenRef: RefObject; + shouldFocusComposerOnScreenFocus: boolean; + shouldShowComposeInput: boolean; + isAttachmentPreviewActive: boolean; + userBlockedFromConcierge: boolean; + PDFValidationComponent: ReactNode; + ErrorModal: ReactNode; +}; + +type ComposerInternalsActions = { + setComposerRef: (ref: ComposerRef | null) => void; + onBlur: (event: BlurEvent) => void; + onFocus: () => void; + onAddActionPressed: () => void; + onItemSelected: () => void; + onTriggerAttachmentPicker: () => void; + submitForm: (newComment: string) => void; + addAttachment: (file: FileObject | FileObject[]) => void; + onAttachmentPreviewClose: () => void; + setIsAttachmentPreviewActive: (isActive: boolean) => void; + onReceiptDropped: (event: DragEvent) => void; + validateAttachments: (args: {dragEvent?: DragEvent; files?: FileObject | FileObject[]}) => void; +}; + +const defaultState: ComposerState = { + isFocused: false, + isFullComposerAvailable: false, + isComposerFullSize: false, + isMenuVisible: false, +}; + +const defaultSendState: ComposerSendState = { + isEmpty: true, + exceededMaxLength: null, + isSendDisabled: true, + isBlockedFromConcierge: false, + hasExceededMaxTaskTitleLength: false, +}; + +const noop = () => {}; +const defaultActions: ComposerActions = { + setIsFocused: noop, + setIsFullComposerAvailable: noop, + setMenuVisibility: noop, + setIsCommentEmpty: noop, + handleSendMessage: noop, + focus: noop, + onValueChange: noop, + validateMaxLength: () => true, + debouncedValidate: Object.assign(() => true as boolean | undefined, {cancel: noop, flush: () => true as boolean | undefined}), +}; + +const ComposerStateContext = createContext(defaultState); +const ComposerSendStateContext = createContext(defaultSendState); +const ComposerActionsContext = createContext(defaultActions); +const ComposerInternalsDataContext = createContext(null); +const ComposerInternalsActionsContext = createContext(null); + +function useComposerState() { + return useContext(ComposerStateContext); +} + +function useComposerSendState() { + return useContext(ComposerSendStateContext); +} + +function useComposerActions() { + return useContext(ComposerActionsContext); +} + +function useComposerInternalsData() { + const ctx = useContext(ComposerInternalsDataContext); + if (!ctx) { + throw new Error('useComposerInternalsData must be used inside ComposerProvider'); + } + return ctx; +} + +function useComposerInternalsActions() { + const ctx = useContext(ComposerInternalsActionsContext); + if (!ctx) { + throw new Error('useComposerInternalsActions must be used inside ComposerProvider'); + } + return ctx; +} + +export { + ComposerStateContext, + ComposerSendStateContext, + ComposerActionsContext, + ComposerInternalsDataContext, + ComposerInternalsActionsContext, + useComposerState, + useComposerSendState, + useComposerActions, + useComposerInternalsData, + useComposerInternalsActions, +}; +export type {SuggestionsRef, ComposerState, ComposerSendState, ComposerActions, ComposerInternalsData, ComposerInternalsActions}; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx new file mode 100644 index 0000000000000..367d625b3eea3 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx @@ -0,0 +1,231 @@ +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 {setIsComposerFullSize} from '@userActions/Report'; +import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {ComposerActionsContext, ComposerInternalsActionsContext, ComposerInternalsDataContext, ComposerSendStateContext, ComposerStateContext} from './ComposerContext'; +import type {SuggestionsRef} from './ComposerContext'; +import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; +import useAttachmentUploadValidation from './useAttachmentUploadValidation'; +import useComposerFocus from './useComposerFocus'; +import useComposerSubmit from './useComposerSubmit'; + +const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); + +type ComposerProviderProps = { + reportID: string; + transactionThreadReportID?: string; + shouldAddOrReplaceReceipt: boolean; + transactionID: string | undefined; + children: React.ReactNode; +}; + +function ComposerProvider({children, reportID, transactionThreadReportID, shouldAddOrReplaceReceipt, transactionID}: 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}`); + const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); + + const shouldFocusComposerOnScreenFocus = shouldFocusInputOnScreenFocus || !!draftComment; + + const [isFocused, setIsFocused] = useState(() => { + return shouldFocusComposerOnScreenFocus && shouldShowComposeInput && !initialModalState?.isVisible && !initialModalState?.willAlertModalBecomeVisible; + }); + + const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(isComposerFullSize); + const [isMenuVisible, setMenuVisibility] = useState(false); + const [isAttachmentPreviewActive, setIsAttachmentPreviewActive] = useState(false); + + const [isCommentEmpty, setIsCommentEmpty] = useState(() => { + return !draftComment || !!draftComment.match(CONST.REGEX.EMPTY_COMMENT); + }); + + const includesConcierge = chatIncludesConcierge({participants: report?.participants}); + const userBlockedFromConcierge = isBlockedFromConciergeUserAction(blockedFromConcierge); + const isBlockedFromConcierge = includesConcierge && userBlockedFromConcierge; + + const {hasExceededMaxCommentLength, validateCommentMaxLength, setHasExceededMaxCommentLength} = useHandleExceedMaxCommentLength(); + const {hasExceededMaxTaskTitleLength, validateTaskTitleMaxLength, setHasExceededMaxTitleLength} = useHandleExceedMaxTaskTitleLength(); + + const exceededMaxLength = (() => { + if (hasExceededMaxTaskTitleLength) { + return CONST.TITLE_CHARACTER_LIMIT; + } + if (hasExceededMaxCommentLength) { + return CONST.MAX_COMMENT_LENGTH; + } + return null; + })(); + + const isSendDisabled = isCommentEmpty || 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 suggestionsRef = useRef(null); + const composerRef = useRef(null); + const actionButtonRef = useRef(null); + + const composerRefShared = useSharedValue>({}); + + const updateShouldShowSuggestionMenuToFalse = () => { + if (!suggestionsRef.current) { + return; + } + suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false); + }; + + const {onBlur, onFocus, focus, onAddActionPressed, onItemSelected, onTriggerAttachmentPicker, isNextModalWillOpenRef} = useComposerFocus({ + composerRef, + suggestionsRef, + actionButtonRef, + setIsFocused, + }); + + const {submitForm, addAttachment, onAttachmentPreviewClose} = useComposerSubmit({ + report, + reportID, + effectiveTransactionThreadReportID: transactionThreadReportID, + composerRefShared, + updateShouldShowSuggestionMenuToFalse, + setIsAttachmentPreviewActive, + }); + + const {validateAttachments, onReceiptDropped, PDFValidationComponent, ErrorModal} = useAttachmentUploadValidation({ + reportID, + report, + addAttachment, + onAttachmentPreviewClose, + exceededMaxLength, + shouldAddOrReplaceReceipt, + transactionID, + isAttachmentPreviewActive, + setIsAttachmentPreviewActive, + }); + + const handleSendMessage = () => { + if (isSendDisabled || !debouncedValidate.flush()) { + return; + } + + composerRef.current?.resetHeight(); + if (isComposerFullSize) { + setIsComposerFullSize(reportID, false); + } + + 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?.(); + }); + }; + + const setComposerRef = (ref: ComposerRef | null) => { + composerRef.current = ref; + composerRefShared.set({ + clearWorklet: ref?.clearWorklet, + }); + }; + + const onValueChange = (v: string) => { + if (v.length === 0 && isComposerFullSize) { + setIsComposerFullSize(reportID, false); + } + debouncedValidate(v); + }; + + const composerState = { + isFocused, + isFullComposerAvailable, + isComposerFullSize, + isMenuVisible, + }; + + const composerSendState = { + isEmpty: isCommentEmpty, + exceededMaxLength, + isSendDisabled, + isBlockedFromConcierge, + hasExceededMaxTaskTitleLength, + }; + + const composerActions = { + setIsFocused, + setIsFullComposerAvailable, + setMenuVisibility, + setIsCommentEmpty, + handleSendMessage, + focus, + onValueChange, + validateMaxLength, + debouncedValidate, + }; + + const composerInternalsData = { + composerRef, + suggestionsRef, + actionButtonRef, + isNextModalWillOpenRef, + shouldFocusComposerOnScreenFocus, + shouldShowComposeInput, + isAttachmentPreviewActive, + userBlockedFromConcierge, + PDFValidationComponent, + ErrorModal, + }; + + const composerInternalsActions = { + setComposerRef, + onBlur, + onFocus, + onAddActionPressed, + onItemSelected, + onTriggerAttachmentPicker, + submitForm, + addAttachment, + onAttachmentPreviewClose, + setIsAttachmentPreviewActive, + onReceiptDropped, + validateAttachments, + }; + + return ( + + + + + {children} + + + + + ); +} + +export default ComposerProvider; +export type {ComposerProviderProps}; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index eb4a97985c4e3..ec9a3e1968e8f 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -47,9 +47,9 @@ import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManag import {isValidReportIDFromPath, shouldAutoFocusOnKeyPress} from '@libs/ReportUtils'; import updateMultilineInputRange from '@libs/updateMultilineInputRange'; import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; +import type {SuggestionsRef} from '@pages/inbox/report/ReportActionCompose/ComposerContext'; 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'; diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index b87ffa17536ac..e31f7f5894536 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -1,18 +1,10 @@ import {useRoute} from '@react-navigation/native'; -import lodashDebounce from 'lodash/debounce'; -import React, {useEffect, useRef, useState} from 'react'; -import type {MeasureInWindowOnSuccessCallback, TextInputSelectionChangeEvent} from 'react-native'; +import React from 'react'; import {View} from 'react-native'; -import {useSharedValue} from 'react-native-reanimated'; -import {scheduleOnUI} from 'react-native-worklets'; -import type {Emoji} from '@assets/emojis/types'; +import type {OnyxEntry} from 'react-native-onyx'; import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; import ImportedStateIndicator from '@components/ImportedStateIndicator'; -import type {Mention} from '@components/MentionSuggestions'; -import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useHandleExceedMaxCommentLength from '@hooks/useHandleExceedMaxCommentLength'; -import useHandleExceedMaxTaskTitleLength from '@hooks/useHandleExceedMaxTaskTitleLength'; import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTriggered'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -23,7 +15,6 @@ import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; -import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import DomUtils from '@libs/DomUtils'; import FS from '@libs/Fullstory'; @@ -48,56 +39,198 @@ import { isReportTransactionThread, } from '@libs/ReportUtils'; import {getTransactionID} from '@libs/TransactionUtils'; -import {hideEmojiPicker, isActive as isActiveEmojiPickerAction, isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; -import {setIsComposerFullSize} from '@userActions/Report'; -import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User'; +import {isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; +import type * as OnyxTypes from '@src/types/onyx'; import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; +import ComposerBox, {useComposerBox} from './ComposerBox'; +import type {SuggestionsRef} from './ComposerContext'; +import {useComposerActions, useComposerInternalsActions, useComposerInternalsData, useComposerSendState, useComposerState} from './ComposerContext'; import ComposerDropZone from './ComposerDropZone'; import ComposerFooter from './ComposerFooter'; import ComposerLocalTime from './ComposerLocalTime'; +import ComposerProvider from './ComposerProvider'; import ComposerWithSuggestions from './ComposerWithSuggestions'; import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; import SendButton from './SendButton'; -import useAttachmentUploadValidation from './useAttachmentUploadValidation'; -import useComposerFocus from './useComposerFocus'; -import useComposerSubmit from './useComposerSubmit'; - -type SuggestionsRef = { - resetSuggestions: () => void; - onSelectionChange?: (event: TextInputSelectionChangeEvent) => void; - triggerHotkeyActions: (event: KeyboardEvent) => boolean | undefined; - updateShouldShowSuggestionMenuToFalse: (shouldShowSuggestionMenu?: boolean) => void; - setShouldBlockSuggestionCalc: (shouldBlock: boolean) => void; - getSuggestions: () => Mention[] | Emoji[]; - getIsSuggestionsMenuVisible: () => boolean; -}; type ReportActionComposeProps = { /** The ID of the report this composer is for */ reportID: string; }; -// We want consistent auto focus behavior on input between native and mWeb so we have some auto focus management code that will -// prevent auto focus on existing chat for mobile device -const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); +// --------------------------------------------------------------------------- +// ComposerBoxContent — reads from all contexts, passes props to children. +// Transitional: shrinks as children self-subscribe to context. +// --------------------------------------------------------------------------- + +type ComposerBoxContentProps = { + reportID: string; + lastReportAction: OnyxEntry; +}; -function ReportActionCompose({reportID}: ReportActionComposeProps) { +function ComposerBoxContent({reportID, lastReportAction}: ComposerBoxContentProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {isSmallScreenWidth, isMediumScreenWidth} = useResponsiveLayout(); - const {isOffline} = useNetwork(); - const actionButtonRef = useRef(null); + const {isMediumScreenWidth} = useResponsiveLayout(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const [blockedFromConcierge] = useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); - const [shouldShowComposeInput = true] = useOnyx(ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT); + + const {isComposerFullSize, isFullComposerAvailable, isMenuVisible} = useComposerState(); + const {isBlockedFromConcierge, isSendDisabled, exceededMaxLength} = useComposerSendState(); + const {setMenuVisibility, setIsFullComposerAvailable, setIsCommentEmpty, handleSendMessage, focus, onValueChange} = useComposerActions(); + const {composerRef, suggestionsRef, actionButtonRef, isNextModalWillOpenRef, shouldFocusComposerOnScreenFocus, shouldShowComposeInput, userBlockedFromConcierge} = + useComposerInternalsData(); + const {onBlur, onFocus, onAddActionPressed, onItemSelected, onTriggerAttachmentPicker, submitForm, validateAttachments, setComposerRef} = useComposerInternalsActions(); + const {measureContainer} = useComposerBox(); + + const {isScrollLayoutTriggered, raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); + + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + + const reportParticipantIDs = Object.keys(report?.participants ?? {}) + .map(Number) + .filter((accountID) => accountID !== currentUserPersonalDetails.accountID); + + const includesConcierge = chatIncludesConcierge({participants: report?.participants}); + const isGroupPolicyReport = !!report?.policyID && report.policyID !== CONST.POLICY.ID_FAKE; + const inputPlaceholder = includesConcierge && userBlockedFromConcierge ? translate('reportActionCompose.blockedFromConcierge') : translate('reportActionCompose.writeSomething'); + const fsClass = report ? FS.getChatFSClass(report) : undefined; + + const emojiShiftVertical = (() => { + const chatItemComposeSecondaryRowHeight = styles.chatItemComposeSecondaryRow.height + styles.chatItemComposeSecondaryRow.marginTop + styles.chatItemComposeSecondaryRow.marginBottom; + const reportActionComposeHeight = styles.chatItemComposeBox.minHeight + chatItemComposeSecondaryRowHeight; + const emojiOffsetWithComposeBox = (styles.chatItemComposeBox.minHeight - styles.chatItemEmojiButton.height) / 2; + return reportActionComposeHeight - emojiOffsetWithComposeBox - CONST.MENU_POSITION_REPORT_ACTION_COMPOSE_BOTTOM; + })(); + + return ( + <> + 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} + /> + validateAttachments({files})} + onClear={submitForm} + disabled={isBlockedFromConcierge || isEmojiPickerVisible()} + setIsCommentEmpty={setIsCommentEmpty} + onEnterKeyPress={handleSendMessage} + shouldShowComposeInput={shouldShowComposeInput} + onFocus={onFocus} + onBlur={onBlur} + measureParentContainer={measureContainer} + onValueChange={onValueChange} + forwardedFSClass={fsClass} + /> + {canUseTouchScreen() && isMediumScreenWidth ? null : ( + { + if (isNavigating) { + return; + } + const activeElementId = DomUtils.getActiveElement()?.id; + if (activeElementId === CONST.COMPOSER.NATIVE_ID || activeElementId === CONST.EMOJI_PICKER_BUTTON_NATIVE_ID) { + return; + } + focus(); + }} + onEmojiSelected={(...args) => composerRef.current?.replaceSelectionWithText(...args)} + emojiPickerID={report?.reportID} + shiftVertical={emojiShiftVertical} + /> + )} + + + ); +} + +// --------------------------------------------------------------------------- +// Thin wrappers that bridge context → existing child components. +// --------------------------------------------------------------------------- + +function ComposerDropZoneWrapper({reportID, shouldAddOrReplaceReceipt, transactionID}: {reportID: string; shouldAddOrReplaceReceipt: boolean; transactionID: string | undefined}) { + const {validateAttachments, onReceiptDropped} = useComposerInternalsActions(); + return ( + validateAttachments({dragEvent})} + onReceiptDrop={onReceiptDropped} + /> + ); +} + +function ComposerFooterWrapper({reportID}: {reportID: string}) { + const {isOffline} = useNetwork(); + const {exceededMaxLength, hasExceededMaxTaskTitleLength} = useComposerSendState(); + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Orchestrator — layout + report-level data resolution. +// --------------------------------------------------------------------------- + +function Composer({reportID}: ReportActionComposeProps) { + const styles = useThemeStyles(); + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); + const {isOffline} = useNetwork(); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); + const {reportPendingAction: pendingAction} = getReportOfflinePendingActionAndErrors(report); + + // --- Report actions & transaction resolution --- const {reportActions: unfilteredReportActions} = usePaginatedReportActions(report?.reportID); const filteredReportActions = getFilteredReportActionsForReportView(unfilteredReportActions); @@ -110,6 +243,7 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, filteredReportActions, isOffline, reportTransactionIDs); const effectiveTransactionThreadReportID = isSentMoneyReport ? undefined : transactionThreadReportID; + // --- lastReportAction (for up-arrow-to-edit) --- const parentReportAction = useParentReportAction(report); const [transactionThreadReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${effectiveTransactionThreadReportID}`); const transactionThreadReportActionsArray = transactionThreadReportActions ? Object.values(transactionThreadReportActions) : []; @@ -117,85 +251,20 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { const route = useRoute(); const isOnSearchMoneyRequestReport = route.name === SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT || route.name === SCREENS.RIGHT_MODAL.EXPENSE_REPORT; - - // On the search money request report page (MoneyRequestReportView), lastReportAction uses only - // the parent report's actions — not combined with transaction thread actions. The table view - // doesn't display transaction thread comments inline, so the last editable action should only - // come from what's visible in the parent report. ReportScreen (inbox) uses combinedReportActions - // because ReportActionsView merges thread comments into the visible list, and up-arrow-to-edit - // should be able to reach those comments. const actionsForLastEditable = isOnSearchMoneyRequestReport ? filteredReportActions : combinedReportActions; const lastReportAction = [...actionsForLastEditable, parentReportAction].find((action) => !isMoneyRequestAction(action) && canEditReportAction(action, undefined)); - const {reportPendingAction: pendingAction} = getReportOfflinePendingActionAndErrors(report); - - const [initialModalState] = useOnyx(ONYXKEYS.MODAL); - const [draftComment] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`); - - const shouldFocusComposerOnScreenFocus = shouldFocusInputOnScreenFocus || !!draftComment; - - /** - * Updates the Highlight state of the composer - */ - const [isFocused, setIsFocused] = useState(() => { - return shouldFocusComposerOnScreenFocus && shouldShowComposeInput && !initialModalState?.isVisible && !initialModalState?.willAlertModalBecomeVisible; - }); - - const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(isComposerFullSize); - - const {isScrollLayoutTriggered, raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); - - const [isCommentEmpty, setIsCommentEmpty] = useState(() => { - return !draftComment || !!draftComment.match(CONST.REGEX.EMPTY_COMMENT); - }); - - /** - * Updates the visibility state of the menu - */ - 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 = (() => { - if (hasExceededMaxTaskTitleLength) { - return CONST.TITLE_CHARACTER_LIMIT; - } - if (hasExceededMaxCommentLength) { - return CONST.MAX_COMMENT_LENGTH; - } - return null; - })(); - - const suggestionsRef = useRef(null); - const composerRef = useRef(null); - const reportParticipantIDs = Object.keys(report?.participants ?? {}) - .map(Number) - .filter((accountID) => accountID !== currentUserPersonalDetails.accountID); - - const includesConcierge = chatIncludesConcierge({participants: report?.participants}); - const userBlockedFromConcierge = isBlockedFromConciergeUserAction(blockedFromConcierge); - const isBlockedFromConcierge = includesConcierge && userBlockedFromConcierge; + // --- shouldAddOrReplaceReceipt & transactionID --- const isReportArchived = useReportIsArchived(report?.reportID); const isTransactionThreadView = isReportTransactionThread(report); const isExpensesReport = reportTransactions && reportTransactions.length > 1; - const [rawReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`, { - canEvict: false, - }); - + const [rawReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`, {canEvict: false}); const iouAction = rawReportActions ? Object.values(rawReportActions).find((action) => isMoneyRequestAction(action)) : null; const linkedTransactionID = iouAction && !isExpensesReport ? getLinkedTransactionID(iouAction) : undefined; - const transactionID = getTransactionID(report) ?? linkedTransactionID; const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transactionID)}`); - const isSingleTransactionView = !!transaction && !!reportTransactions && reportTransactions.length === 1; const effectiveParentReportAction = isSingleTransactionView ? iouAction : getReportAction(report?.parentReportID, report?.parentReportActionID); const canUserPerformWriteAction = !!canUserPerformWriteActionReportUtils(report, isReportArchived); @@ -205,255 +274,55 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { !transaction?.receipt?.isTestDriveReceipt; const shouldAddOrReplaceReceipt = (isTransactionThreadView || isSingleTransactionView) && canEditReceipt; - // Placeholder to display in the chat input. - const inputPlaceholder = includesConcierge && userBlockedFromConcierge ? translate('reportActionCompose.blockedFromConcierge') : translate('reportActionCompose.writeSomething'); - - const containerRef = useRef(null); - const measureContainer = (callback: MeasureInWindowOnSuccessCallback) => { - if (!containerRef.current) { - return; - } - containerRef.current.measureInWindow(callback); - }; - - const updateShouldShowSuggestionMenuToFalse = () => { - if (!suggestionsRef.current) { - return; - } - suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false); - }; - - // 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 {onBlur, onFocus, focus, onAddActionPressed, onItemSelected, onTriggerAttachmentPicker, isNextModalWillOpenRef} = useComposerFocus({ - composerRef, - suggestionsRef, - actionButtonRef, - setIsFocused, - }); - - const {submitForm, addAttachment, onAttachmentPreviewClose} = useComposerSubmit({ - report, - reportID, - effectiveTransactionThreadReportID, - composerRefShared, - updateShouldShowSuggestionMenuToFalse, - setIsAttachmentPreviewActive, - }); - - // Hide emoji picker on unmount or when switching reports - useEffect( - () => () => { - if (!isActiveEmojiPickerAction(report?.reportID)) { - return; - } - hideEmojiPicker(); - }, - [report?.reportID], - ); - - // When we invite someone to a room they don't have the policy object, but we still want them to be able to mention other reports they are members of, so we only check if the policyID in the report is from a workspace - const isGroupPolicyReport = !!report?.policyID && report.policyID !== CONST.POLICY.ID_FAKE; - const shouldUseFocusedColor = !isBlockedFromConcierge && isFocused; - - const isSendDisabled = isCommentEmpty || isBlockedFromConcierge || !!exceededMaxLength; - - const validateMaxLength = (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}); - }; - - const debouncedValidate = lodashDebounce(validateMaxLength, CONST.TIMING.COMMENT_LENGTH_DEBOUNCE_TIME, {leading: true}); - - const handleSendMessage = () => { - if (isSendDisabled || !debouncedValidate.flush()) { - return; - } - - composerRef.current?.resetHeight(); - if (isComposerFullSize) { - setIsComposerFullSize(reportID, false); - } - - 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?.(); - }); - }; - - const emojiShiftVertical = (() => { - const chatItemComposeSecondaryRowHeight = styles.chatItemComposeSecondaryRow.height + styles.chatItemComposeSecondaryRow.marginTop + styles.chatItemComposeSecondaryRow.marginBottom; - const reportActionComposeHeight = styles.chatItemComposeBox.minHeight + chatItemComposeSecondaryRowHeight; - const emojiOffsetWithComposeBox = (styles.chatItemComposeBox.minHeight - styles.chatItemEmojiButton.height) / 2; - return reportActionComposeHeight - emojiOffsetWithComposeBox - CONST.MENU_POSITION_REPORT_ACTION_COMPOSE_BOTTOM; - })(); - - const onValueChange = (value: string) => { - if (value.length === 0 && isComposerFullSize) { - setIsComposerFullSize(reportID, false); - } - debouncedValidate(value); - }; - - const {validateAttachments, onReceiptDropped, PDFValidationComponent, ErrorModal} = useAttachmentUploadValidation({ - reportID, - report, - addAttachment, - onAttachmentPreviewClose, - exceededMaxLength, - shouldAddOrReplaceReceipt, - transactionID, - isAttachmentPreviewActive, - setIsAttachmentPreviewActive, - }); - if (!report) { return null; } - const fsClass = FS.getChatFSClass(report); - return ( - - - + - + + - {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} - /> - { - composerRef.current = ref; - composerRefShared.set({ - clearWorklet: ref?.clearWorklet, - }); - }} - suggestionsRef={suggestionsRef} - isNextModalWillOpenRef={isNextModalWillOpenRef} - isScrollLikelyLayoutTriggered={isScrollLayoutTriggered} - raiseIsScrollLikelyLayoutTriggered={raiseIsScrollLayoutTriggered} + validateAttachments({files})} - onClear={submitForm} - disabled={isBlockedFromConcierge || isEmojiPickerVisible()} - setIsCommentEmpty={setIsCommentEmpty} - onEnterKeyPress={handleSendMessage} - shouldShowComposeInput={shouldShowComposeInput} - onFocus={onFocus} - onBlur={onBlur} - measureParentContainer={measureContainer} - onValueChange={onValueChange} - forwardedFSClass={fsClass} /> - validateAttachments({dragEvent})} - onReceiptDrop={onReceiptDropped} /> - {canUseTouchScreen() && isMediumScreenWidth ? null : ( - { - if (isNavigating) { - return; - } - const activeElementId = DomUtils.getActiveElement()?.id; - if (activeElementId === CONST.COMPOSER.NATIVE_ID || activeElementId === CONST.EMOJI_PICKER_BUTTON_NATIVE_ID) { - return; - } - focus(); - }} - onEmojiSelected={(...args) => composerRef.current?.replaceSelectionWithText(...args)} - emojiPickerID={report?.reportID} - shiftVertical={emojiShiftVertical} - /> - )} - - - {ErrorModal} - - - {!isSmallScreenWidth && ( - - - - )} - + + + {!isSmallScreenWidth && ( + + + + )} + + ); } -export default ReportActionCompose; +Composer.LocalTime = ComposerLocalTime; +Composer.Box = ComposerBox; +Composer.DropZone = ComposerDropZoneWrapper; +Composer.Footer = ComposerFooterWrapper; + +export default Composer; export type {SuggestionsRef, ComposerRef, ReportActionComposeProps}; diff --git a/src/pages/inbox/report/ReportActionCompose/Suggestions.tsx b/src/pages/inbox/report/ReportActionCompose/Suggestions.tsx index 8e2a4694fd847..af293549b7f2c 100644 --- a/src/pages/inbox/report/ReportActionCompose/Suggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/Suggestions.tsx @@ -6,7 +6,7 @@ import type {MeasureParentContainerAndCursorCallback} from '@components/AutoComp import type {TextSelection} from '@components/Composer/types'; import {useDragAndDropState} from '@components/DragAndDrop/Provider'; import usePrevious from '@hooks/usePrevious'; -import type {SuggestionsRef} from './ReportActionCompose'; +import type {SuggestionsRef} from './ComposerContext'; import SuggestionEmoji from './SuggestionEmoji'; import SuggestionMention from './SuggestionMention'; diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerFocus.ts b/src/pages/inbox/report/ReportActionCompose/useComposerFocus.ts index ac0d5059843a5..c6da5bd623268 100644 --- a/src/pages/inbox/report/ReportActionCompose/useComposerFocus.ts +++ b/src/pages/inbox/report/ReportActionCompose/useComposerFocus.ts @@ -2,8 +2,8 @@ import {useRef} from 'react'; 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 {SuggestionsRef} from './ReportActionCompose'; const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index 67ffa2fb4fda8..4289a4d5e3559 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -54,9 +54,9 @@ import type * as OnyxTypes from '@src/types/onyx'; import findNodeHandle from '@src/utils/findNodeHandle'; import KeyboardUtils from '@src/utils/keyboard'; import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu'; +import type {SuggestionsRef} from './ReportActionCompose/ComposerContext'; import getCursorPosition from './ReportActionCompose/getCursorPosition'; import getScrollPosition from './ReportActionCompose/getScrollPosition'; -import type {SuggestionsRef} from './ReportActionCompose/ReportActionCompose'; import Suggestions from './ReportActionCompose/Suggestions'; import shouldUseEmojiPickerSelection from './shouldUseEmojiPickerSelection'; From 3cf0100a6b4d80649186731eca3276c939c867fe Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 1 Apr 2026 13:04:18 +0200 Subject: [PATCH 13/57] Lift text value to ComposerProvider, derive isEmpty inline Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ReportActionCompose/ComposerContext.ts | 11 ++++-- .../ReportActionCompose/ComposerProvider.tsx | 34 +++++++++++-------- .../ComposerWithSuggestions.tsx | 27 ++++----------- .../ReportActionCompose.tsx | 3 +- 4 files changed, 35 insertions(+), 40 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts index 5a87220fbe497..869c74dec5524 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts +++ b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts @@ -35,7 +35,7 @@ type ComposerActions = { setIsFocused: (v: boolean) => void; setIsFullComposerAvailable: (v: boolean) => void; setMenuVisibility: (v: boolean) => void; - setIsCommentEmpty: (isEmpty: boolean) => void; + setValue: (v: string) => void; handleSendMessage: () => void; focus: () => void; onValueChange: (value: string) => void; @@ -95,7 +95,7 @@ const defaultActions: ComposerActions = { setIsFocused: noop, setIsFullComposerAvailable: noop, setMenuVisibility: noop, - setIsCommentEmpty: noop, + setValue: noop, handleSendMessage: noop, focus: noop, onValueChange: noop, @@ -103,12 +103,17 @@ const defaultActions: ComposerActions = { debouncedValidate: Object.assign(() => true as boolean | undefined, {cancel: noop, flush: () => true as boolean | undefined}), }; +const ComposerValueContext = createContext(''); const ComposerStateContext = createContext(defaultState); const ComposerSendStateContext = createContext(defaultSendState); const ComposerActionsContext = createContext(defaultActions); const ComposerInternalsDataContext = createContext(null); const ComposerInternalsActionsContext = createContext(null); +function useComposerValue() { + return useContext(ComposerValueContext); +} + function useComposerState() { return useContext(ComposerStateContext); } @@ -138,11 +143,13 @@ function useComposerInternalsActions() { } export { + ComposerValueContext, ComposerStateContext, ComposerSendStateContext, ComposerActionsContext, ComposerInternalsDataContext, ComposerInternalsActionsContext, + useComposerValue, useComposerState, useComposerSendState, useComposerActions, diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx index 367d625b3eea3..17dfd45f3350e 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx @@ -12,7 +12,7 @@ import {setIsComposerFullSize} from '@userActions/Report'; import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {ComposerActionsContext, ComposerInternalsActionsContext, ComposerInternalsDataContext, ComposerSendStateContext, ComposerStateContext} from './ComposerContext'; +import {ComposerActionsContext, ComposerInternalsActionsContext, ComposerInternalsDataContext, ComposerSendStateContext, ComposerStateContext, ComposerValueContext} from './ComposerContext'; import type {SuggestionsRef} from './ComposerContext'; import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; import useAttachmentUploadValidation from './useAttachmentUploadValidation'; @@ -47,10 +47,12 @@ function ComposerProvider({children, reportID, transactionThreadReportID, should const [isMenuVisible, setMenuVisibility] = useState(false); const [isAttachmentPreviewActive, setIsAttachmentPreviewActive] = useState(false); - const [isCommentEmpty, setIsCommentEmpty] = useState(() => { - return !draftComment || !!draftComment.match(CONST.REGEX.EMPTY_COMMENT); + const [value, setValue] = useState(() => { + return draftComment ?? ''; }); + const isEmpty = !value || !!value.match(CONST.REGEX.EMPTY_COMMENT); + const includesConcierge = chatIncludesConcierge({participants: report?.participants}); const userBlockedFromConcierge = isBlockedFromConciergeUserAction(blockedFromConcierge); const isBlockedFromConcierge = includesConcierge && userBlockedFromConcierge; @@ -68,7 +70,7 @@ function ComposerProvider({children, reportID, transactionThreadReportID, should return null; })(); - const isSendDisabled = isCommentEmpty || isBlockedFromConcierge || !!exceededMaxLength; + const isSendDisabled = isEmpty || isBlockedFromConcierge || !!exceededMaxLength; const validateMaxLength = (v: string) => { const taskCommentMatch = v?.match(CONST.REGEX.TASK_TITLE_WITH_OPTIONAL_SHORT_MENTION); @@ -167,7 +169,7 @@ function ComposerProvider({children, reportID, transactionThreadReportID, should }; const composerSendState = { - isEmpty: isCommentEmpty, + isEmpty, exceededMaxLength, isSendDisabled, isBlockedFromConcierge, @@ -178,7 +180,7 @@ function ComposerProvider({children, reportID, transactionThreadReportID, should setIsFocused, setIsFullComposerAvailable, setMenuVisibility, - setIsCommentEmpty, + setValue, handleSendMessage, focus, onValueChange, @@ -215,15 +217,17 @@ function ComposerProvider({children, reportID, transactionThreadReportID, should }; return ( - - - - - {children} - - - - + + + + + + {children} + + + + + ); } diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index ec9a3e1968e8f..fb1c63e91c153 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -36,7 +36,7 @@ import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import {forceClearInput} from '@libs/ComponentUtils'; import {canSkipTriggerHotkeys, findCommonSuffixLength, insertText, insertWhiteSpaceAtIndex} from '@libs/ComposerUtils'; import convertToLTRForComposer from '@libs/convertToLTRForComposer'; -import {containsOnlyEmojis, extractEmojis, getAddedEmojis, getTextVSCursorOffset, insertTextVSBetweenDigitAndEmoji, replaceAndExtractEmojis} from '@libs/EmojiUtils'; +import {containsOnlyEmojis, getAddedEmojis, getTextVSCursorOffset, insertTextVSBetweenDigitAndEmoji, replaceAndExtractEmojis} from '@libs/EmojiUtils'; import focusComposerWithDelay from '@libs/focusComposerWithDelay'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import getPlatform from '@libs/getPlatform'; @@ -48,6 +48,7 @@ import {isValidReportIDFromPath, shouldAutoFocusOnKeyPress} from '@libs/ReportUt import updateMultilineInputRange from '@libs/updateMultilineInputRange'; import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; import type {SuggestionsRef} from '@pages/inbox/report/ReportActionCompose/ComposerContext'; +import {useComposerActions, useComposerValue} from '@pages/inbox/report/ReportActionCompose/ComposerContext'; import getCursorPosition from '@pages/inbox/report/ReportActionCompose/getCursorPosition'; import getScrollPosition from '@pages/inbox/report/ReportActionCompose/getScrollPosition'; import SilentCommentUpdater from '@pages/inbox/report/ReportActionCompose/SilentCommentUpdater'; @@ -111,9 +112,6 @@ type ComposerWithSuggestionsProps = Partial & /** Whether the input is disabled, defaults to false */ disabled?: boolean; - /** Function to set whether the comment is empty */ - setIsCommentEmpty: (isCommentEmpty: boolean) => void; - /** Function to handle sending a message */ onEnterKeyPress: () => void; @@ -226,7 +224,6 @@ function ComposerWithSuggestions({ inputPlaceholder, onPasteFile, disabled, - setIsCommentEmpty, onEnterKeyPress, shouldShowComposeInput, measureParentContainer = () => {}, @@ -259,13 +256,8 @@ function ComposerWithSuggestions({ const mobileInputScrollPosition = useRef(0); const cursorPositionValue = useSharedValue({x: 0, y: 0}); const tag = useSharedValue(-1); - const [draftComment = ''] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`); - const [value, setValue] = useState(() => { - if (draftComment) { - emojisPresentBefore.current = extractEmojis(draftComment); - } - return draftComment; - }); + const value = useComposerValue(); + const {setValue} = useComposerActions(); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const commentRef = useRef(value); @@ -287,7 +279,7 @@ function ComposerWithSuggestions({ const {shouldUseNarrowLayout} = useResponsiveLayout(); const maxComposerLines = shouldUseNarrowLayout ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; - const shouldAutoFocus = (shouldFocusInputOnScreenFocus || !!draftComment) && shouldShowComposeInput && areAllModalsHidden() && isFocused; + const shouldAutoFocus = (shouldFocusInputOnScreenFocus || !!value) && shouldShowComposeInput && areAllModalsHidden() && isFocused; const delayedAutoFocusRouteKeyRef = useRef(null); const valueRef = useRef(value); @@ -449,13 +441,6 @@ function ComposerWithSuggestions({ } } const newCommentConverted = convertToLTRForComposer(newComment); - const isNewCommentEmpty = !!newCommentConverted.match(/^(\s)*$/); - const isPrevCommentEmpty = !!commentRef.current.match(/^(\s)*$/); - - /** Only update isCommentEmpty state if it's different from previous one */ - if (isNewCommentEmpty !== isPrevCommentEmpty) { - setIsCommentEmpty(isNewCommentEmpty); - } emojisPresentBefore.current = emojis; setValue(newCommentConverted); @@ -491,7 +476,7 @@ function ComposerWithSuggestions({ preferredLocale, preferredSkinTone, reportID, - setIsCommentEmpty, + setValue, suggestionsRef, raiseIsScrollLikelyLayoutTriggered, debouncedSaveReportComment, diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index e31f7f5894536..b2f88d4fad88f 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -80,7 +80,7 @@ function ComposerBoxContent({reportID, lastReportAction}: ComposerBoxContentProp const {isComposerFullSize, isFullComposerAvailable, isMenuVisible} = useComposerState(); const {isBlockedFromConcierge, isSendDisabled, exceededMaxLength} = useComposerSendState(); - const {setMenuVisibility, setIsFullComposerAvailable, setIsCommentEmpty, handleSendMessage, focus, onValueChange} = useComposerActions(); + const {setMenuVisibility, setIsFullComposerAvailable, handleSendMessage, focus, onValueChange} = useComposerActions(); const {composerRef, suggestionsRef, actionButtonRef, isNextModalWillOpenRef, shouldFocusComposerOnScreenFocus, shouldShowComposeInput, userBlockedFromConcierge} = useComposerInternalsData(); const {onBlur, onFocus, onAddActionPressed, onItemSelected, onTriggerAttachmentPicker, submitForm, validateAttachments, setComposerRef} = useComposerInternalsActions(); @@ -150,7 +150,6 @@ function ComposerBoxContent({reportID, lastReportAction}: ComposerBoxContentProp onPasteFile={(files) => validateAttachments({files})} onClear={submitForm} disabled={isBlockedFromConcierge || isEmojiPickerVisible()} - setIsCommentEmpty={setIsCommentEmpty} onEnterKeyPress={handleSendMessage} shouldShowComposeInput={shouldShowComposeInput} onFocus={onFocus} From f790a574cdc6fdec1f9d9139d3f0328b898f3774 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 1 Apr 2026 14:34:55 +0200 Subject: [PATCH 14/57] ComposerFooter: read from context directly, remove wrapper Co-Authored-By: Claude Opus 4.6 (1M context) --- .../report/ReportActionCompose/ComposerFooter.tsx | 9 +++++---- .../ReportActionCompose/ReportActionCompose.tsx | 15 +-------------- 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerFooter.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerFooter.tsx index 250517b59e6ab..52fb4c2e33f31 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerFooter.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerFooter.tsx @@ -2,21 +2,22 @@ import React from 'react'; import {View} from 'react-native'; import ExceededCommentLength from '@components/ExceededCommentLength'; import OfflineIndicator from '@components/OfflineIndicator'; +import useNetwork from '@hooks/useNetwork'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import AgentZeroAwareTypingIndicator from './AgentZeroAwareTypingIndicator'; +import {useComposerSendState} from './ComposerContext'; type ComposerFooterProps = { reportID: string; - exceededMaxLength: number | null; - hasExceededMaxTaskTitleLength: boolean; - isOffline: boolean; }; -function ComposerFooter({reportID, exceededMaxLength, hasExceededMaxTaskTitleLength, isOffline}: ComposerFooterProps) { +function ComposerFooter({reportID}: ComposerFooterProps) { const styles = useThemeStyles(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); + const {isOffline} = useNetwork(); + const {exceededMaxLength, hasExceededMaxTaskTitleLength} = useComposerSendState(); return ( - ); -} - // --------------------------------------------------------------------------- // Orchestrator — layout + report-level data resolution. // --------------------------------------------------------------------------- @@ -321,7 +308,7 @@ function Composer({reportID}: ReportActionComposeProps) { Composer.LocalTime = ComposerLocalTime; Composer.Box = ComposerBox; Composer.DropZone = ComposerDropZoneWrapper; -Composer.Footer = ComposerFooterWrapper; +Composer.Footer = ComposerFooter; export default Composer; export type {SuggestionsRef, ComposerRef, ReportActionComposeProps}; From 747c13fa4968f25c0980e43e56f53eab595f745d Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 1 Apr 2026 14:39:23 +0200 Subject: [PATCH 15/57] Extract useLastEditableAction: isolate arrow-up-to-edit subscriptions Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ComposerWithSuggestions.tsx | 7 +-- .../ReportActionCompose.tsx | 27 +--------- .../useLastEditableAction.ts | 49 +++++++++++++++++++ 3 files changed, 53 insertions(+), 30 deletions(-) create mode 100644 src/pages/inbox/report/ReportActionCompose/useLastEditableAction.ts diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index fb1c63e91c153..5621b67c5ce8e 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -53,6 +53,7 @@ import getCursorPosition from '@pages/inbox/report/ReportActionCompose/getCursor import getScrollPosition from '@pages/inbox/report/ReportActionCompose/getScrollPosition'; import SilentCommentUpdater from '@pages/inbox/report/ReportActionCompose/SilentCommentUpdater'; import Suggestions from '@pages/inbox/report/ReportActionCompose/Suggestions'; +import useLastEditableAction from '@pages/inbox/report/ReportActionCompose/useLastEditableAction'; import {isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; import type {OnEmojiSelected} from '@userActions/EmojiPickerAction'; import {inputFocusChange} from '@userActions/InputFocus'; @@ -61,7 +62,6 @@ import {broadcastUserIsTyping, saveReportActionDraft, saveReportDraftComment} fr import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; -import type * as OnyxTypes from '@src/types/onyx'; import type {FileObject} from '@src/types/utils/Attachment'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; // eslint-disable-next-line no-restricted-imports @@ -133,9 +133,6 @@ type ComposerWithSuggestionsProps = Partial & /** The ref to the next modal will open */ isNextModalWillOpenRef: RefObject; - /** The last report action */ - lastReportAction?: OnyxEntry; - /** Whether to include chronos */ includeChronos?: boolean; @@ -208,7 +205,6 @@ function ComposerWithSuggestions({ // Props: Report reportID, includeChronos, - lastReportAction, isGroupPolicyReport, policyID, @@ -243,6 +239,7 @@ function ComposerWithSuggestions({ // Fullstory forwardedFSClass, }: ComposerWithSuggestionsProps) { + const lastReportAction = useLastEditableAction(reportID); const route = useRoute(); const {isKeyboardShown} = useKeyboardState(); const theme = useTheme(); diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index dc42d0873c980..551a327031c44 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -1,7 +1,5 @@ -import {useRoute} from '@react-navigation/native'; import React from 'react'; import {View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; import ImportedStateIndicator from '@components/ImportedStateIndicator'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -10,7 +8,6 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; -import useParentReportAction from '@hooks/useParentReportAction'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -21,7 +18,6 @@ import FS from '@libs/Fullstory'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; import { - getCombinedReportActions, getFilteredReportActionsForReportView, getLinkedTransactionID, getOneTransactionThreadReportID, @@ -31,7 +27,6 @@ import { } from '@libs/ReportActionsUtils'; import { canEditFieldOfMoneyRequest, - canEditReportAction, canUserPerformWriteAction as canUserPerformWriteActionReportUtils, chatIncludesChronos, chatIncludesConcierge, @@ -42,8 +37,6 @@ import {getTransactionID} from '@libs/TransactionUtils'; import {isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import SCREENS from '@src/SCREENS'; -import type * as OnyxTypes from '@src/types/onyx'; import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; import ComposerBox, {useComposerBox} from './ComposerBox'; import type {SuggestionsRef} from './ComposerContext'; @@ -68,10 +61,9 @@ type ReportActionComposeProps = { type ComposerBoxContentProps = { reportID: string; - lastReportAction: OnyxEntry; }; -function ComposerBoxContent({reportID, lastReportAction}: ComposerBoxContentProps) { +function ComposerBoxContent({reportID}: ComposerBoxContentProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth @@ -142,7 +134,6 @@ function ComposerBoxContent({reportID, lastReportAction}: ComposerBoxContentProp policyID={report?.policyID} includeChronos={chatIncludesChronos(report)} isGroupPolicyReport={isGroupPolicyReport} - lastReportAction={lastReportAction} isMenuVisible={isMenuVisible} inputPlaceholder={inputPlaceholder} isComposerFullSize={isComposerFullSize} @@ -229,17 +220,6 @@ function Composer({reportID}: ReportActionComposeProps) { const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, filteredReportActions, isOffline, reportTransactionIDs); const effectiveTransactionThreadReportID = isSentMoneyReport ? undefined : transactionThreadReportID; - // --- lastReportAction (for up-arrow-to-edit) --- - const parentReportAction = useParentReportAction(report); - const [transactionThreadReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${effectiveTransactionThreadReportID}`); - const transactionThreadReportActionsArray = transactionThreadReportActions ? Object.values(transactionThreadReportActions) : []; - const combinedReportActions = getCombinedReportActions(filteredReportActions, effectiveTransactionThreadReportID ?? null, transactionThreadReportActionsArray); - - const route = useRoute(); - const isOnSearchMoneyRequestReport = route.name === SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT || route.name === SCREENS.RIGHT_MODAL.EXPENSE_REPORT; - const actionsForLastEditable = isOnSearchMoneyRequestReport ? filteredReportActions : combinedReportActions; - const lastReportAction = [...actionsForLastEditable, parentReportAction].find((action) => !isMoneyRequestAction(action) && canEditReportAction(action, undefined)); - // --- shouldAddOrReplaceReceipt & transactionID --- const isReportArchived = useReportIsArchived(report?.reportID); const isTransactionThreadView = isReportTransactionThread(report); @@ -283,10 +263,7 @@ function Composer({reportID}: ReportActionComposeProps) { isComposerFullSize={isComposerFullSize} pendingAction={pendingAction} > - + { + const {isOffline} = useNetwork(); + + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`); + + const {reportActions: unfilteredReportActions} = usePaginatedReportActions(report?.reportID); + const filteredReportActions = getFilteredReportActionsForReportView(unfilteredReportActions); + + const allReportTransactions = useReportTransactionsCollection(reportID); + const reportTransactions = getAllNonDeletedTransactions(allReportTransactions, filteredReportActions, isOffline, true); + const visibleTransactions = reportTransactions?.filter((transaction) => isOffline || transaction.pendingAction !== 'delete'); + const reportTransactionIDs = visibleTransactions?.map((t) => t.transactionID); + + const isSentMoneyReport = filteredReportActions.some((action) => isSentMoneyReportAction(action)); + const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, filteredReportActions, isOffline, reportTransactionIDs); + const effectiveTransactionThreadReportID = isSentMoneyReport ? undefined : transactionThreadReportID; + + const parentReportAction = useParentReportAction(report); + const [transactionThreadReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${effectiveTransactionThreadReportID}`); + const transactionThreadReportActionsArray = transactionThreadReportActions ? Object.values(transactionThreadReportActions) : []; + const combinedReportActions = getCombinedReportActions(filteredReportActions, effectiveTransactionThreadReportID ?? null, transactionThreadReportActionsArray); + + const route = useRoute(); + const isOnSearchMoneyRequestReport = route.name === SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT || route.name === SCREENS.RIGHT_MODAL.EXPENSE_REPORT; + const actionsForLastEditable = isOnSearchMoneyRequestReport ? filteredReportActions : combinedReportActions; + + return [...actionsForLastEditable, parentReportAction].find((action) => !isMoneyRequestAction(action) && canEditReportAction(action, undefined)); +} + +export default useLastEditableAction; From 9a35791a58b5dd055f1b71caeeaec81baed42e6f Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 1 Apr 2026 14:44:26 +0200 Subject: [PATCH 16/57] DropZone wraps Box, remove shouldAddOrReplaceReceipt prop drilling Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ReportActionCompose/ComposerDropZone.tsx | 95 +++++++++++-------- .../ReportActionCompose/ComposerProvider.tsx | 8 +- .../ReportActionCompose.tsx | 83 +++------------- .../useShouldAddOrReplaceReceipt.ts | 49 ++++++++++ 4 files changed, 120 insertions(+), 115 deletions(-) create mode 100644 src/pages/inbox/report/ReportActionCompose/useShouldAddOrReplaceReceipt.ts diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx index d989b39120081..1ff1bc41898e2 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx @@ -5,6 +5,7 @@ import DualDropZone from '@components/DropZone/DualDropZone'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePreferredPolicy from '@hooks/usePreferredPolicy'; import useReportIsArchived from '@hooks/useReportIsArchived'; @@ -14,22 +15,15 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getParentReport, isChatRoom, isGroupChat, isInvoiceReport, isReportApproved, isSettled, temporary_getMoneyRequestOptions} from '@libs/ReportUtils'; import {hasReceipt as hasReceiptTransactionUtils} from '@libs/TransactionUtils'; import ONYXKEYS from '@src/ONYXKEYS'; +import {useComposerInternalsActions} from './ComposerContext'; +import useShouldAddOrReplaceReceipt from './useShouldAddOrReplaceReceipt'; type ComposerDropZoneProps = { /** The ID of the report */ reportID: string; - /** Whether the current view allows adding or replacing a receipt */ - shouldAddOrReplaceReceipt: boolean; - - /** The transaction ID relevant to this report, if any */ - transactionID: string | undefined; - - /** Callback when an attachment file is dropped */ - onAttachmentDrop: (dragEvent: DragEvent) => void; - - /** Callback when a receipt file is dropped */ - onReceiptDrop: (dragEvent: DragEvent) => void; + /** Content to wrap with the drop zone */ + children: React.ReactNode; }; type RichDropZoneProps = { @@ -47,28 +41,34 @@ type RichDropZoneProps = { /** Callback when a receipt file is dropped */ onReceiptDrop: (dragEvent: DragEvent) => void; + + /** Content to wrap with the drop zone */ + children: React.ReactNode; }; -function SimpleDropZone({onAttachmentDrop}: {onAttachmentDrop: (dragEvent: DragEvent) => void}) { +function SimpleDropZone({onAttachmentDrop, children}: {onAttachmentDrop: (dragEvent: DragEvent) => void; children: React.ReactNode}) { const styles = useThemeStyles(); const theme = useTheme(); const {translate} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['MessageInABottle']); return ( - - - + <> + {children} + + + + ); } -function RichDropZone({reportID, shouldAddOrReplaceReceipt, transactionID, onAttachmentDrop, onReceiptDrop}: RichDropZoneProps) { +function RichDropZone({reportID, shouldAddOrReplaceReceipt, transactionID, onAttachmentDrop, onReceiptDrop, children}: RichDropZoneProps) { const styles = useThemeStyles(); const theme = useTheme(); const {translate} = useLocalize(); @@ -98,35 +98,46 @@ function RichDropZone({reportID, shouldAddOrReplaceReceipt, transactionID, onAtt if (shouldDisplayDualDropZone) { return ( - + <> + {children} + + ); } return ( - - - + <> + {children} + + + + ); } -function ComposerDropZone({reportID, shouldAddOrReplaceReceipt, transactionID, onAttachmentDrop, onReceiptDrop}: ComposerDropZoneProps) { +function ComposerDropZone({reportID, children}: ComposerDropZoneProps) { const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const {isOffline} = useNetwork(); + const {shouldAddOrReplaceReceipt, transactionID} = useShouldAddOrReplaceReceipt(reportID, isOffline); + const {validateAttachments, onReceiptDropped} = useComposerInternalsActions(); + + const onAttachmentDrop = (dragEvent: DragEvent) => validateAttachments({dragEvent}); // Cheap gate: rooms, groups, and invoices never show the dual drop zone. // ~60% of chats hit this path with zero extra subscriptions. if (isChatRoom(report) || isGroupChat(report) || isInvoiceReport(report)) { - return ; + return {children}; } return ( @@ -135,8 +146,10 @@ function ComposerDropZone({reportID, shouldAddOrReplaceReceipt, transactionID, o shouldAddOrReplaceReceipt={shouldAddOrReplaceReceipt} transactionID={transactionID} onAttachmentDrop={onAttachmentDrop} - onReceiptDrop={onReceiptDrop} - /> + onReceiptDrop={onReceiptDropped} + > + {children} + ); } diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx index 17dfd45f3350e..66da7c4ce9fa9 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx @@ -5,6 +5,7 @@ import {useSharedValue} from 'react-native-reanimated'; import {scheduleOnUI} from 'react-native-worklets'; import useHandleExceedMaxCommentLength from '@hooks/useHandleExceedMaxCommentLength'; import useHandleExceedMaxTaskTitleLength from '@hooks/useHandleExceedMaxTaskTitleLength'; +import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import {chatIncludesConcierge} from '@libs/ReportUtils'; @@ -18,18 +19,19 @@ import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestion import useAttachmentUploadValidation from './useAttachmentUploadValidation'; import useComposerFocus from './useComposerFocus'; import useComposerSubmit from './useComposerSubmit'; +import useShouldAddOrReplaceReceipt from './useShouldAddOrReplaceReceipt'; const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); type ComposerProviderProps = { reportID: string; transactionThreadReportID?: string; - shouldAddOrReplaceReceipt: boolean; - transactionID: string | undefined; children: React.ReactNode; }; -function ComposerProvider({children, reportID, transactionThreadReportID, shouldAddOrReplaceReceipt, transactionID}: ComposerProviderProps) { +function ComposerProvider({children, reportID, transactionThreadReportID}: ComposerProviderProps) { + const {isOffline} = useNetwork(); + const {shouldAddOrReplaceReceipt, transactionID} = useShouldAddOrReplaceReceipt(reportID, isOffline); const [blockedFromConcierge] = useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); const [shouldShowComposeInput = true] = useOnyx(ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT); const [initialModalState] = useOnyx(ONYXKEYS.MODAL); diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 551a327031c44..c72fa4a36c4bd 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -8,32 +8,15 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; -import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import DomUtils from '@libs/DomUtils'; import FS from '@libs/Fullstory'; -import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; -import { - getFilteredReportActionsForReportView, - getLinkedTransactionID, - getOneTransactionThreadReportID, - getReportAction, - isMoneyRequestAction, - isSentMoneyReportAction, -} from '@libs/ReportActionsUtils'; -import { - canEditFieldOfMoneyRequest, - canUserPerformWriteAction as canUserPerformWriteActionReportUtils, - chatIncludesChronos, - chatIncludesConcierge, - getReportOfflinePendingActionAndErrors, - isReportTransactionThread, -} from '@libs/ReportUtils'; -import {getTransactionID} from '@libs/TransactionUtils'; +import {getFilteredReportActionsForReportView, getOneTransactionThreadReportID, isSentMoneyReportAction} from '@libs/ReportActionsUtils'; +import {chatIncludesChronos, chatIncludesConcierge, getReportOfflinePendingActionAndErrors} from '@libs/ReportUtils'; import {isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -175,23 +158,6 @@ function ComposerBoxContent({reportID}: ComposerBoxContentProps) { ); } -// --------------------------------------------------------------------------- -// Thin wrappers that bridge context → existing child components. -// --------------------------------------------------------------------------- - -function ComposerDropZoneWrapper({reportID, shouldAddOrReplaceReceipt, transactionID}: {reportID: string; shouldAddOrReplaceReceipt: boolean; transactionID: string | undefined}) { - const {validateAttachments, onReceiptDropped} = useComposerInternalsActions(); - return ( - validateAttachments({dragEvent})} - onReceiptDrop={onReceiptDropped} - /> - ); -} - // --------------------------------------------------------------------------- // Orchestrator — layout + report-level data resolution. // --------------------------------------------------------------------------- @@ -207,7 +173,7 @@ function Composer({reportID}: ReportActionComposeProps) { const {reportPendingAction: pendingAction} = getReportOfflinePendingActionAndErrors(report); - // --- Report actions & transaction resolution --- + // --- Report actions & transaction resolution (for effectiveTransactionThreadReportID) --- const {reportActions: unfilteredReportActions} = usePaginatedReportActions(report?.reportID); const filteredReportActions = getFilteredReportActionsForReportView(unfilteredReportActions); @@ -220,26 +186,6 @@ function Composer({reportID}: ReportActionComposeProps) { const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, filteredReportActions, isOffline, reportTransactionIDs); const effectiveTransactionThreadReportID = isSentMoneyReport ? undefined : transactionThreadReportID; - // --- shouldAddOrReplaceReceipt & transactionID --- - const isReportArchived = useReportIsArchived(report?.reportID); - const isTransactionThreadView = isReportTransactionThread(report); - const isExpensesReport = reportTransactions && reportTransactions.length > 1; - - const [rawReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`, {canEvict: false}); - const iouAction = rawReportActions ? Object.values(rawReportActions).find((action) => isMoneyRequestAction(action)) : null; - const linkedTransactionID = iouAction && !isExpensesReport ? getLinkedTransactionID(iouAction) : undefined; - const transactionID = getTransactionID(report) ?? linkedTransactionID; - - const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transactionID)}`); - const isSingleTransactionView = !!transaction && !!reportTransactions && reportTransactions.length === 1; - const effectiveParentReportAction = isSingleTransactionView ? iouAction : getReportAction(report?.parentReportID, report?.parentReportActionID); - const canUserPerformWriteAction = !!canUserPerformWriteActionReportUtils(report, isReportArchived); - const canEditReceipt = - canUserPerformWriteAction && - canEditFieldOfMoneyRequest({reportAction: effectiveParentReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, transaction}) && - !transaction?.receipt?.isTestDriveReceipt; - const shouldAddOrReplaceReceipt = (isTransactionThreadView || isSingleTransactionView) && canEditReceipt; - if (!report) { return null; } @@ -249,8 +195,6 @@ function Composer({reportID}: ReportActionComposeProps) { - - - + - + isComposerFullSize={isComposerFullSize} + pendingAction={pendingAction} + > + + + {!isSmallScreenWidth && ( @@ -284,7 +225,7 @@ function Composer({reportID}: ReportActionComposeProps) { Composer.LocalTime = ComposerLocalTime; Composer.Box = ComposerBox; -Composer.DropZone = ComposerDropZoneWrapper; +Composer.DropZone = ComposerDropZone; Composer.Footer = ComposerFooter; export default Composer; diff --git a/src/pages/inbox/report/ReportActionCompose/useShouldAddOrReplaceReceipt.ts b/src/pages/inbox/report/ReportActionCompose/useShouldAddOrReplaceReceipt.ts new file mode 100644 index 0000000000000..28b3b83947fbb --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/useShouldAddOrReplaceReceipt.ts @@ -0,0 +1,49 @@ +import useOnyx from '@hooks/useOnyx'; +import useReportIsArchived from '@hooks/useReportIsArchived'; +import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; +import {getFilteredReportActionsForReportView, getLinkedTransactionID, getReportAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {canEditFieldOfMoneyRequest, canUserPerformWriteAction as canUserPerformWriteActionReportUtils, isReportTransactionThread} from '@libs/ReportUtils'; +import {getTransactionID} from '@libs/TransactionUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportAction} from '@src/types/onyx'; + +/** + * Determines whether the current report context allows adding or replacing a receipt, + * and resolves the relevant transactionID. + * + * Used by ComposerProvider (for upload validation) and ComposerDropZone (for drop zone layout). + */ +function useShouldAddOrReplaceReceipt(reportID: string, isOffline: boolean) { + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const isReportArchived = useReportIsArchived(report?.reportID); + const allReportTransactions = useReportTransactionsCollection(reportID); + const [rawReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`, {canEvict: false}); + + const isTransactionThreadView = isReportTransactionThread(report); + + // We need the filtered actions to count visible transactions + const filteredReportActions = getFilteredReportActionsForReportView(rawReportActions ? Object.values(rawReportActions) : []); + const reportTransactions = getAllNonDeletedTransactions(allReportTransactions, filteredReportActions, isOffline, true); + const isExpensesReport = reportTransactions && reportTransactions.length > 1; + + const iouAction = rawReportActions ? (Object.values(rawReportActions).find((action) => isMoneyRequestAction(action)) as ReportAction | undefined) : undefined; + const linkedTransactionID = iouAction && !isExpensesReport ? getLinkedTransactionID(iouAction) : undefined; + const transactionID = getTransactionID(report) ?? linkedTransactionID; + + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transactionID)}`); + const isSingleTransactionView = !!transaction && !!reportTransactions && reportTransactions.length === 1; + const effectiveParentReportAction = isSingleTransactionView ? iouAction : getReportAction(report?.parentReportID, report?.parentReportActionID); + const canUserPerformWriteAction = !!canUserPerformWriteActionReportUtils(report, isReportArchived); + const canEditReceipt = + canUserPerformWriteAction && + canEditFieldOfMoneyRequest({reportAction: effectiveParentReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, transaction}) && + !transaction?.receipt?.isTestDriveReceipt; + const shouldAddOrReplaceReceipt = (isTransactionThreadView || isSingleTransactionView) && canEditReceipt; + + return {shouldAddOrReplaceReceipt, transactionID}; +} + +export default useShouldAddOrReplaceReceipt; From 967a0dd338ab2915307d5650ba1ef53fa78f9acf Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 1 Apr 2026 15:01:29 +0200 Subject: [PATCH 17/57] =?UTF-8?q?Skip=20filter=20iteration=20when=20offlin?= =?UTF-8?q?e=20=E2=80=94=20condition=20is=20always=20true?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../inbox/report/ReportActionCompose/ReportActionCompose.tsx | 2 +- .../inbox/report/ReportActionCompose/useLastEditableAction.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index c72fa4a36c4bd..e3f40bea7b0bb 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -180,7 +180,7 @@ function Composer({reportID}: ReportActionComposeProps) { const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`); const allReportTransactions = useReportTransactionsCollection(reportID); const reportTransactions = getAllNonDeletedTransactions(allReportTransactions, filteredReportActions, isOffline, true); - const visibleTransactions = reportTransactions?.filter((transaction) => isOffline || transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + const visibleTransactions = isOffline ? reportTransactions : reportTransactions?.filter((transaction) => transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); const reportTransactionIDs = visibleTransactions?.map((t) => t.transactionID); const isSentMoneyReport = filteredReportActions.some((action) => isSentMoneyReportAction(action)); const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, filteredReportActions, isOffline, reportTransactionIDs); diff --git a/src/pages/inbox/report/ReportActionCompose/useLastEditableAction.ts b/src/pages/inbox/report/ReportActionCompose/useLastEditableAction.ts index 3b23564b49b46..268600910e403 100644 --- a/src/pages/inbox/report/ReportActionCompose/useLastEditableAction.ts +++ b/src/pages/inbox/report/ReportActionCompose/useLastEditableAction.ts @@ -27,7 +27,7 @@ function useLastEditableAction(reportID: string): OnyxEntry { const allReportTransactions = useReportTransactionsCollection(reportID); const reportTransactions = getAllNonDeletedTransactions(allReportTransactions, filteredReportActions, isOffline, true); - const visibleTransactions = reportTransactions?.filter((transaction) => isOffline || transaction.pendingAction !== 'delete'); + const visibleTransactions = isOffline ? reportTransactions : reportTransactions?.filter((transaction) => transaction.pendingAction !== 'delete'); const reportTransactionIDs = visibleTransactions?.map((t) => t.transactionID); const isSentMoneyReport = filteredReportActions.some((action) => isSentMoneyReportAction(action)); From 350d00d3b6950ed407a8947c981c7a740d65a697 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 1 Apr 2026 15:18:25 +0200 Subject: [PATCH 18/57] useComposerSubmit: self-compute effectiveTransactionThreadReportID --- .../ReportActionCompose/ComposerProvider.tsx | 4 +-- .../ReportActionCompose.tsx | 24 +---------------- .../ReportActionCompose/useComposerSubmit.ts | 27 ++++++++++++------- 3 files changed, 20 insertions(+), 35 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx index 66da7c4ce9fa9..6e65616cdb2b4 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx @@ -25,11 +25,10 @@ const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); type ComposerProviderProps = { reportID: string; - transactionThreadReportID?: string; children: React.ReactNode; }; -function ComposerProvider({children, reportID, transactionThreadReportID}: ComposerProviderProps) { +function ComposerProvider({children, reportID}: ComposerProviderProps) { const {isOffline} = useNetwork(); const {shouldAddOrReplaceReceipt, transactionID} = useShouldAddOrReplaceReceipt(reportID, isOffline); const [blockedFromConcierge] = useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); @@ -110,7 +109,6 @@ function ComposerProvider({children, reportID, transactionThreadReportID}: Compo const {submitForm, addAttachment, onAttachmentPreviewClose} = useComposerSubmit({ report, reportID, - effectiveTransactionThreadReportID: transactionThreadReportID, composerRefShared, updateShouldShowSuggestionMenuToFalse, setIsAttachmentPreviewActive, diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index e3f40bea7b0bb..a74db5638062e 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -5,17 +5,12 @@ import ImportedStateIndicator from '@components/ImportedStateIndicator'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTriggered'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; -import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import DomUtils from '@libs/DomUtils'; import FS from '@libs/Fullstory'; -import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; -import {getFilteredReportActionsForReportView, getOneTransactionThreadReportID, isSentMoneyReportAction} from '@libs/ReportActionsUtils'; import {chatIncludesChronos, chatIncludesConcierge, getReportOfflinePendingActionAndErrors} from '@libs/ReportUtils'; import {isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; import CONST from '@src/CONST'; @@ -166,36 +161,19 @@ function Composer({reportID}: ReportActionComposeProps) { const styles = useThemeStyles(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); - const {isOffline} = useNetwork(); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); const {reportPendingAction: pendingAction} = getReportOfflinePendingActionAndErrors(report); - // --- Report actions & transaction resolution (for effectiveTransactionThreadReportID) --- - const {reportActions: unfilteredReportActions} = usePaginatedReportActions(report?.reportID); - const filteredReportActions = getFilteredReportActionsForReportView(unfilteredReportActions); - - const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`); - const allReportTransactions = useReportTransactionsCollection(reportID); - const reportTransactions = getAllNonDeletedTransactions(allReportTransactions, filteredReportActions, isOffline, true); - const visibleTransactions = isOffline ? reportTransactions : reportTransactions?.filter((transaction) => transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); - const reportTransactionIDs = visibleTransactions?.map((t) => t.transactionID); - const isSentMoneyReport = filteredReportActions.some((action) => isSentMoneyReportAction(action)); - const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, filteredReportActions, isOffline, reportTransactionIDs); - const effectiveTransactionThreadReportID = isSentMoneyReport ? undefined : transactionThreadReportID; - if (!report) { return null; } return ( - + ; reportID: string; - effectiveTransactionThreadReportID?: string; composerRefShared: {get: () => Partial}; updateShouldShowSuggestionMenuToFalse: () => void; setIsAttachmentPreviewActive: (value: boolean) => void; }; -function useComposerSubmit({ - report, - reportID, - effectiveTransactionThreadReportID, - composerRefShared, - updateShouldShowSuggestionMenuToFalse, - setIsAttachmentPreviewActive, -}: UseComposerSubmitParams) { +function useComposerSubmit({report, reportID, composerRefShared, updateShouldShowSuggestionMenuToFalse, setIsAttachmentPreviewActive}: UseComposerSubmitParams) { const isInSidePanel = useIsInSidePanel(); const {kickoffWaitingIndicator} = useAgentZeroStatusActions(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); @@ -49,6 +46,18 @@ function useComposerSubmit({ const {availableLoginsList} = useShortMentionsList(); const {scrollOffsetRef} = useContext(ActionListContext); + const {isOffline} = useNetwork(); + const {reportActions: unfilteredReportActions} = usePaginatedReportActions(report?.reportID); + const filteredReportActions = getFilteredReportActionsForReportView(unfilteredReportActions); + const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`); + const allReportTransactions = useReportTransactionsCollection(reportID); + const reportTransactions = getAllNonDeletedTransactions(allReportTransactions, filteredReportActions, isOffline, true); + const visibleTransactions = isOffline ? reportTransactions : reportTransactions?.filter((t) => t.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + const reportTransactionIDs = visibleTransactions?.map((t) => t.transactionID); + const isSentMoneyReport = filteredReportActions.some((action) => isSentMoneyReportAction(action)); + const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, filteredReportActions, isOffline, reportTransactionIDs); + const effectiveTransactionThreadReportID = isSentMoneyReport ? undefined : transactionThreadReportID; + const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); const [targetReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${effectiveTransactionThreadReportID ?? reportID}`); From e773bcd07e36500a36d8b5383aba6f2a5563b0e2 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 1 Apr 2026 15:30:23 +0200 Subject: [PATCH 19/57] Simplify ComposerLocalTime: remove unnecessary guard pattern Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ReportActionCompose/ComposerLocalTime.tsx | 37 ++----------------- 1 file changed, 3 insertions(+), 34 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx index 29aff73d0e0bc..88c924b5dd157 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import type {OnyxEntry} from 'react-native-onyx'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -11,37 +10,23 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -// Outer guard: cheap selector — only participant count and chatType. -// Returns true only for likely 1:1 DMs (no chatType, at most 2 participants). -function useLooksLikeDM(reportID: string): boolean { - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, { - selector: (r: OnyxEntry<{participants?: Record; chatType?: string}>) => ({ - participantCount: Object.keys(r?.participants ?? {}).length, - chatType: r?.chatType, - }), - }); - return (report?.participantCount ?? 0) <= 2 && !report?.chatType; -} - type ComposerLocalTimeProps = { reportID: string; pendingAction: PendingAction | undefined; isComposerFullSize: boolean; }; -function ComposerLocalTimeInner({reportID, pendingAction, isComposerFullSize}: ComposerLocalTimeProps) { +function ComposerLocalTime({reportID, pendingAction, isComposerFullSize}: ComposerLocalTimeProps) { const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const personalDetails = usePersonalDetails(); const {isOffline} = useNetwork(); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - const shouldShowReportRecipientLocalTime = canShowReportRecipientLocalTime(personalDetails, report, currentUserPersonalDetails.accountID) && !isComposerFullSize; - + const shouldShow = canShowReportRecipientLocalTime(personalDetails, report, currentUserPersonalDetails.accountID) && !isComposerFullSize; const reportRecipientAccountIDs = getReportRecipientAccountIDs(report, currentUserPersonalDetails.accountID); const reportRecipient = personalDetails?.[reportRecipientAccountIDs[0]]; - const hasReportRecipient = !isEmptyObject(reportRecipient); - if (!shouldShowReportRecipientLocalTime || !hasReportRecipient || isOffline) { + if (!shouldShow || isEmptyObject(reportRecipient) || isOffline) { return null; } @@ -52,20 +37,4 @@ function ComposerLocalTimeInner({reportID, pendingAction, isComposerFullSize}: C ); } -function ComposerLocalTime({reportID, pendingAction, isComposerFullSize}: ComposerLocalTimeProps) { - const looksLikeDM = useLooksLikeDM(reportID); - - if (!looksLikeDM) { - return null; - } - - return ( - - ); -} - export default ComposerLocalTime; From 4b5d8269d0f444001acb3b7aacf1535ae8d53397 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 1 Apr 2026 15:33:00 +0200 Subject: [PATCH 20/57] =?UTF-8?q?Rename=20ComposerInternals*=20=E2=86=92?= =?UTF-8?q?=20ComposerData/ComposerDataActions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ReportActionCompose/ComposerBox.tsx | 4 +-- .../ReportActionCompose/ComposerContext.ts | 30 +++++++++---------- .../ReportActionCompose/ComposerDropZone.tsx | 4 +-- .../ReportActionCompose/ComposerProvider.tsx | 8 ++--- .../ReportActionCompose.tsx | 7 ++--- 5 files changed, 26 insertions(+), 27 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx index d89ae6f1145cf..00154a95d86df 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx @@ -5,7 +5,7 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useThemeStyles from '@hooks/useThemeStyles'; import {hideEmojiPicker, isActive as isActiveEmojiPickerAction} from '@userActions/EmojiPickerAction'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; -import {useComposerInternalsData, useComposerSendState, useComposerState} from './ComposerContext'; +import {useComposerData, useComposerSendState, useComposerState} from './ComposerContext'; type ComposerBoxContextValue = { measureContainer: (callback: MeasureInWindowOnSuccessCallback) => void; @@ -30,7 +30,7 @@ function ComposerBox({reportID, isComposerFullSize, pendingAction, children}: Co const styles = useThemeStyles(); const {isFocused} = useComposerState(); const {exceededMaxLength, isBlockedFromConcierge} = useComposerSendState(); - const {PDFValidationComponent, ErrorModal} = useComposerInternalsData(); + const {PDFValidationComponent, ErrorModal} = useComposerData(); const shouldUseFocusedColor = !isBlockedFromConcierge && isFocused; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts index 869c74dec5524..fbbcd15e82de1 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts +++ b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts @@ -47,7 +47,7 @@ type ComposerActions = { }; }; -type ComposerInternalsData = { +type ComposerData = { composerRef: RefObject; suggestionsRef: RefObject; actionButtonRef: RefObject; @@ -60,7 +60,7 @@ type ComposerInternalsData = { ErrorModal: ReactNode; }; -type ComposerInternalsActions = { +type ComposerDataActions = { setComposerRef: (ref: ComposerRef | null) => void; onBlur: (event: BlurEvent) => void; onFocus: () => void; @@ -107,8 +107,8 @@ const ComposerValueContext = createContext(''); const ComposerStateContext = createContext(defaultState); const ComposerSendStateContext = createContext(defaultSendState); const ComposerActionsContext = createContext(defaultActions); -const ComposerInternalsDataContext = createContext(null); -const ComposerInternalsActionsContext = createContext(null); +const ComposerDataContext = createContext(null); +const ComposerDataActionsContext = createContext(null); function useComposerValue() { return useContext(ComposerValueContext); @@ -126,18 +126,18 @@ function useComposerActions() { return useContext(ComposerActionsContext); } -function useComposerInternalsData() { - const ctx = useContext(ComposerInternalsDataContext); +function useComposerData() { + const ctx = useContext(ComposerDataContext); if (!ctx) { - throw new Error('useComposerInternalsData must be used inside ComposerProvider'); + throw new Error('useComposerData must be used inside ComposerProvider'); } return ctx; } -function useComposerInternalsActions() { - const ctx = useContext(ComposerInternalsActionsContext); +function useComposerDataActions() { + const ctx = useContext(ComposerDataActionsContext); if (!ctx) { - throw new Error('useComposerInternalsActions must be used inside ComposerProvider'); + throw new Error('useComposerDataActions must be used inside ComposerProvider'); } return ctx; } @@ -147,13 +147,13 @@ export { ComposerStateContext, ComposerSendStateContext, ComposerActionsContext, - ComposerInternalsDataContext, - ComposerInternalsActionsContext, + ComposerDataContext, + ComposerDataActionsContext, useComposerValue, useComposerState, useComposerSendState, useComposerActions, - useComposerInternalsData, - useComposerInternalsActions, + useComposerData, + useComposerDataActions, }; -export type {SuggestionsRef, ComposerState, ComposerSendState, ComposerActions, ComposerInternalsData, ComposerInternalsActions}; +export type {SuggestionsRef, ComposerState, ComposerSendState, ComposerActions, ComposerData, ComposerDataActions}; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx index 1ff1bc41898e2..3d464146dc4b7 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx @@ -15,7 +15,7 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getParentReport, isChatRoom, isGroupChat, isInvoiceReport, isReportApproved, isSettled, temporary_getMoneyRequestOptions} from '@libs/ReportUtils'; import {hasReceipt as hasReceiptTransactionUtils} from '@libs/TransactionUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import {useComposerInternalsActions} from './ComposerContext'; +import {useComposerDataActions} from './ComposerContext'; import useShouldAddOrReplaceReceipt from './useShouldAddOrReplaceReceipt'; type ComposerDropZoneProps = { @@ -130,7 +130,7 @@ function ComposerDropZone({reportID, children}: ComposerDropZoneProps) { const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const {isOffline} = useNetwork(); const {shouldAddOrReplaceReceipt, transactionID} = useShouldAddOrReplaceReceipt(reportID, isOffline); - const {validateAttachments, onReceiptDropped} = useComposerInternalsActions(); + const {validateAttachments, onReceiptDropped} = useComposerDataActions(); const onAttachmentDrop = (dragEvent: DragEvent) => validateAttachments({dragEvent}); diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx index 6e65616cdb2b4..6ee319fb2e289 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx @@ -13,7 +13,7 @@ import {setIsComposerFullSize} from '@userActions/Report'; import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {ComposerActionsContext, ComposerInternalsActionsContext, ComposerInternalsDataContext, ComposerSendStateContext, ComposerStateContext, ComposerValueContext} from './ComposerContext'; +import {ComposerActionsContext, ComposerDataActionsContext, ComposerDataContext, ComposerSendStateContext, ComposerStateContext, ComposerValueContext} from './ComposerContext'; import type {SuggestionsRef} from './ComposerContext'; import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; import useAttachmentUploadValidation from './useAttachmentUploadValidation'; @@ -221,9 +221,9 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { - - {children} - + + {children} + diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index a74db5638062e..a282bb4323132 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -18,7 +18,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; import ComposerBox, {useComposerBox} from './ComposerBox'; import type {SuggestionsRef} from './ComposerContext'; -import {useComposerActions, useComposerInternalsActions, useComposerInternalsData, useComposerSendState, useComposerState} from './ComposerContext'; +import {useComposerActions, useComposerData, useComposerDataActions, useComposerSendState, useComposerState} from './ComposerContext'; import ComposerDropZone from './ComposerDropZone'; import ComposerFooter from './ComposerFooter'; import ComposerLocalTime from './ComposerLocalTime'; @@ -51,9 +51,8 @@ function ComposerBoxContent({reportID}: ComposerBoxContentProps) { const {isComposerFullSize, isFullComposerAvailable, isMenuVisible} = useComposerState(); const {isBlockedFromConcierge, isSendDisabled, exceededMaxLength} = useComposerSendState(); const {setMenuVisibility, setIsFullComposerAvailable, handleSendMessage, focus, onValueChange} = useComposerActions(); - const {composerRef, suggestionsRef, actionButtonRef, isNextModalWillOpenRef, shouldFocusComposerOnScreenFocus, shouldShowComposeInput, userBlockedFromConcierge} = - useComposerInternalsData(); - const {onBlur, onFocus, onAddActionPressed, onItemSelected, onTriggerAttachmentPicker, submitForm, validateAttachments, setComposerRef} = useComposerInternalsActions(); + const {composerRef, suggestionsRef, actionButtonRef, isNextModalWillOpenRef, shouldFocusComposerOnScreenFocus, shouldShowComposeInput, userBlockedFromConcierge} = useComposerData(); + const {onBlur, onFocus, onAddActionPressed, onItemSelected, onTriggerAttachmentPicker, submitForm, validateAttachments, setComposerRef} = useComposerDataActions(); const {measureContainer} = useComposerBox(); const {isScrollLayoutTriggered, raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); From fa383a2c59e804bbf01798aacea732a2264a9f9a Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 1 Apr 2026 15:49:10 +0200 Subject: [PATCH 21/57] Move emoji picker cleanup to ComposerBoxContent, co-located with trigger Co-Authored-By: Claude Opus 4.6 (1M context) --- .../report/ReportActionCompose/ComposerBox.tsx | 17 ++--------------- .../ReportActionCompose/ReportActionCompose.tsx | 16 +++++++++++++--- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx index 00154a95d86df..9df3eb7c804cd 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx @@ -1,9 +1,8 @@ -import React, {createContext, useContext, useEffect, useRef} from 'react'; +import React, {createContext, useContext, useRef} from 'react'; import type {MeasureInWindowOnSuccessCallback} from 'react-native'; import {View} from 'react-native'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useThemeStyles from '@hooks/useThemeStyles'; -import {hideEmojiPicker, isActive as isActiveEmojiPickerAction} from '@userActions/EmojiPickerAction'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import {useComposerData, useComposerSendState, useComposerState} from './ComposerContext'; @@ -20,13 +19,12 @@ function useComposerBox() { } type ComposerBoxProps = { - reportID: string; isComposerFullSize: boolean; pendingAction: PendingAction | undefined; children: React.ReactNode; }; -function ComposerBox({reportID, isComposerFullSize, pendingAction, children}: ComposerBoxProps) { +function ComposerBox({isComposerFullSize, pendingAction, children}: ComposerBoxProps) { const styles = useThemeStyles(); const {isFocused} = useComposerState(); const {exceededMaxLength, isBlockedFromConcierge} = useComposerSendState(); @@ -42,17 +40,6 @@ function ComposerBox({reportID, isComposerFullSize, pendingAction, children}: Co containerRef.current.measureInWindow(callback); }; - // Hide emoji picker on unmount or when switching reports - useEffect( - () => () => { - if (!isActiveEmojiPickerAction(reportID)) { - return; - } - hideEmojiPicker(); - }, - [reportID], - ); - const contextValue = {measureContainer}; return ( diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index a282bb4323132..8fdb13196a3b3 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; import ImportedStateIndicator from '@components/ImportedStateIndicator'; @@ -12,7 +12,7 @@ import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import DomUtils from '@libs/DomUtils'; import FS from '@libs/Fullstory'; import {chatIncludesChronos, chatIncludesConcierge, getReportOfflinePendingActionAndErrors} from '@libs/ReportUtils'; -import {isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; +import {hideEmojiPicker, isActive as isActiveEmojiPickerAction, isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; @@ -75,6 +75,17 @@ function ComposerBoxContent({reportID}: ComposerBoxContentProps) { return reportActionComposeHeight - emojiOffsetWithComposeBox - CONST.MENU_POSITION_REPORT_ACTION_COMPOSE_BOTTOM; })(); + // Hide emoji picker on unmount or when switching reports — co-located with EmojiPickerButton trigger + useEffect( + () => () => { + if (!isActiveEmojiPickerAction(report?.reportID)) { + return; + } + hideEmojiPicker(); + }, + [report?.reportID], + ); + return ( <> From a44b7cc54268afc12d80ddc85c05987d217f2a18 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 1 Apr 2026 15:54:22 +0200 Subject: [PATCH 22/57] pendingAction: derive in consumers, stop drilling from orchestrator Co-Authored-By: Claude Opus 4.6 (1M context) --- .../report/ReportActionCompose/ComposerBox.tsx | 13 ++++++++----- .../ReportActionCompose/ComposerLocalTime.tsx | 7 +++---- .../ReportActionCompose/ReportActionCompose.tsx | 10 ++-------- 3 files changed, 13 insertions(+), 17 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx index 9df3eb7c804cd..d3651cc4f3b83 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx @@ -2,8 +2,10 @@ import React, {createContext, useContext, useRef} from 'react'; import type {MeasureInWindowOnSuccessCallback} from 'react-native'; import {View} from 'react-native'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import type {PendingAction} from '@src/types/onyx/OnyxCommon'; +import {getReportOfflinePendingActionAndErrors} from '@libs/ReportUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; import {useComposerData, useComposerSendState, useComposerState} from './ComposerContext'; type ComposerBoxContextValue = { @@ -19,17 +21,18 @@ function useComposerBox() { } type ComposerBoxProps = { - isComposerFullSize: boolean; - pendingAction: PendingAction | undefined; + reportID: string; children: React.ReactNode; }; -function ComposerBox({isComposerFullSize, pendingAction, children}: ComposerBoxProps) { +function ComposerBox({reportID, children}: ComposerBoxProps) { const styles = useThemeStyles(); - const {isFocused} = useComposerState(); + const {isFocused, isComposerFullSize} = useComposerState(); const {exceededMaxLength, isBlockedFromConcierge} = useComposerSendState(); const {PDFValidationComponent, ErrorModal} = useComposerData(); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const {reportPendingAction: pendingAction} = getReportOfflinePendingActionAndErrors(report); const shouldUseFocusedColor = !isBlockedFromConcierge && isFocused; const containerRef = useRef(null); diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx index 88c924b5dd157..d6816d3e8bf7e 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx @@ -4,23 +4,22 @@ import {usePersonalDetails} from '@components/OnyxListItemProvider'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import {canShowReportRecipientLocalTime, getReportRecipientAccountIDs} from '@libs/ReportUtils'; +import {canShowReportRecipientLocalTime, getReportOfflinePendingActionAndErrors, getReportRecipientAccountIDs} from '@libs/ReportUtils'; import ParticipantLocalTime from '@pages/inbox/report/ParticipantLocalTime'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; type ComposerLocalTimeProps = { reportID: string; - pendingAction: PendingAction | undefined; isComposerFullSize: boolean; }; -function ComposerLocalTime({reportID, pendingAction, isComposerFullSize}: ComposerLocalTimeProps) { +function ComposerLocalTime({reportID, isComposerFullSize}: ComposerLocalTimeProps) { const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const personalDetails = usePersonalDetails(); const {isOffline} = useNetwork(); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const {reportPendingAction: pendingAction} = getReportOfflinePendingActionAndErrors(report); const shouldShow = canShowReportRecipientLocalTime(personalDetails, report, currentUserPersonalDetails.accountID) && !isComposerFullSize; const reportRecipientAccountIDs = getReportRecipientAccountIDs(report, currentUserPersonalDetails.accountID); diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 8fdb13196a3b3..2adee5cb6e780 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -11,7 +11,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import DomUtils from '@libs/DomUtils'; import FS from '@libs/Fullstory'; -import {chatIncludesChronos, chatIncludesConcierge, getReportOfflinePendingActionAndErrors} from '@libs/ReportUtils'; +import {chatIncludesChronos, chatIncludesConcierge} from '@libs/ReportUtils'; import {hideEmojiPicker, isActive as isActiveEmojiPickerAction, isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -175,8 +175,6 @@ function Composer({reportID}: ReportActionComposeProps) { const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); - const {reportPendingAction: pendingAction} = getReportOfflinePendingActionAndErrors(report); - if (!report) { return null; } @@ -186,15 +184,11 @@ function Composer({reportID}: ReportActionComposeProps) { - + From 71e25f621df9b1b1543915cefc9a7b71485967ae Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 1 Apr 2026 15:58:27 +0200 Subject: [PATCH 23/57] ComposerLocalTime: read isComposerFullSize from context Co-Authored-By: Claude Opus 4.6 (1M context) --- .../inbox/report/ReportActionCompose/ComposerLocalTime.tsx | 5 +++-- .../inbox/report/ReportActionCompose/ReportActionCompose.tsx | 5 +---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx index d6816d3e8bf7e..a41cf6a33aa4d 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx @@ -8,13 +8,14 @@ import {canShowReportRecipientLocalTime, getReportOfflinePendingActionAndErrors, import ParticipantLocalTime from '@pages/inbox/report/ParticipantLocalTime'; import ONYXKEYS from '@src/ONYXKEYS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import {useComposerState} from './ComposerContext'; type ComposerLocalTimeProps = { reportID: string; - isComposerFullSize: boolean; }; -function ComposerLocalTime({reportID, isComposerFullSize}: ComposerLocalTimeProps) { +function ComposerLocalTime({reportID}: ComposerLocalTimeProps) { + const {isComposerFullSize} = useComposerState(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const personalDetails = usePersonalDetails(); const {isOffline} = useNetwork(); diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 2adee5cb6e780..81c884733f0b9 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -182,10 +182,7 @@ function Composer({reportID}: ReportActionComposeProps) { return ( - + From 1d3f809ec1cb35e2f788b3abfc36511160e207d2 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 1 Apr 2026 16:01:31 +0200 Subject: [PATCH 24/57] Replace IIFEs with inline definitions Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ReportActionCompose/ComposerDropZone.tsx | 12 +++++------- .../ReportActionCompose/ComposerProvider.tsx | 15 ++++++--------- .../ReportActionCompose/ReportActionCompose.tsx | 10 ++++------ 3 files changed, 15 insertions(+), 22 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx index 3d464146dc4b7..f02f9b4e1af8a 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx @@ -88,13 +88,11 @@ function RichDropZone({reportID, shouldAddOrReplaceReceipt, transactionID, onAtt const hasReceipt = hasReceiptTransactionUtils(transaction); - const shouldDisplayDualDropZone = (() => { - const parentReport = getParentReport(report); - const isSettledOrApproved = isSettled(report) || isSettled(parentReport) || isReportApproved({report}) || isReportApproved({report: parentReport}); - const hasMoneyRequestOptions = !!temporary_getMoneyRequestOptions(report, policy, reportParticipantIDs, betas, isReportArchived, isRestrictedToPreferredPolicy).length; - const canModifyReceipt = shouldAddOrReplaceReceipt && !isSettledOrApproved; - return canModifyReceipt || hasMoneyRequestOptions; - })(); + const parentReport = getParentReport(report); + const isSettledOrApproved = isSettled(report) || isSettled(parentReport) || isReportApproved({report}) || isReportApproved({report: parentReport}); + const hasMoneyRequestOptions = !!temporary_getMoneyRequestOptions(report, policy, reportParticipantIDs, betas, isReportArchived, isRestrictedToPreferredPolicy).length; + const canModifyReceipt = shouldAddOrReplaceReceipt && !isSettledOrApproved; + const shouldDisplayDualDropZone = canModifyReceipt || hasMoneyRequestOptions; if (shouldDisplayDualDropZone) { return ( diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx index 6ee319fb2e289..a3fb202535b8f 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx @@ -61,15 +61,12 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { const {hasExceededMaxCommentLength, validateCommentMaxLength, setHasExceededMaxCommentLength} = useHandleExceedMaxCommentLength(); const {hasExceededMaxTaskTitleLength, validateTaskTitleMaxLength, setHasExceededMaxTitleLength} = useHandleExceedMaxTaskTitleLength(); - const exceededMaxLength = (() => { - if (hasExceededMaxTaskTitleLength) { - return CONST.TITLE_CHARACTER_LIMIT; - } - if (hasExceededMaxCommentLength) { - return CONST.MAX_COMMENT_LENGTH; - } - return null; - })(); + let exceededMaxLength: number | null = null; + if (hasExceededMaxTaskTitleLength) { + exceededMaxLength = CONST.TITLE_CHARACTER_LIMIT; + } else if (hasExceededMaxCommentLength) { + exceededMaxLength = CONST.MAX_COMMENT_LENGTH; + } const isSendDisabled = isEmpty || isBlockedFromConcierge || !!exceededMaxLength; diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 81c884733f0b9..b80cbc3f3255b 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -68,12 +68,10 @@ function ComposerBoxContent({reportID}: ComposerBoxContentProps) { const inputPlaceholder = includesConcierge && userBlockedFromConcierge ? translate('reportActionCompose.blockedFromConcierge') : translate('reportActionCompose.writeSomething'); const fsClass = report ? FS.getChatFSClass(report) : undefined; - const emojiShiftVertical = (() => { - const chatItemComposeSecondaryRowHeight = styles.chatItemComposeSecondaryRow.height + styles.chatItemComposeSecondaryRow.marginTop + styles.chatItemComposeSecondaryRow.marginBottom; - const reportActionComposeHeight = styles.chatItemComposeBox.minHeight + chatItemComposeSecondaryRowHeight; - const emojiOffsetWithComposeBox = (styles.chatItemComposeBox.minHeight - styles.chatItemEmojiButton.height) / 2; - return reportActionComposeHeight - emojiOffsetWithComposeBox - CONST.MENU_POSITION_REPORT_ACTION_COMPOSE_BOTTOM; - })(); + const chatItemComposeSecondaryRowHeight = styles.chatItemComposeSecondaryRow.height + styles.chatItemComposeSecondaryRow.marginTop + styles.chatItemComposeSecondaryRow.marginBottom; + const reportActionComposeHeight = styles.chatItemComposeBox.minHeight + chatItemComposeSecondaryRowHeight; + const emojiOffsetWithComposeBox = (styles.chatItemComposeBox.minHeight - styles.chatItemEmojiButton.height) / 2; + const emojiShiftVertical = reportActionComposeHeight - emojiOffsetWithComposeBox - CONST.MENU_POSITION_REPORT_ACTION_COMPOSE_BOTTOM; // Hide emoji picker on unmount or when switching reports — co-located with EmojiPickerButton trigger useEffect( From 23811f550dd7d120bc0bb2a9b1d08e0f08346745 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 1 Apr 2026 16:25:24 +0200 Subject: [PATCH 25/57] Extract ComposerBoxContent to own file, orchestrator is 60 LOC Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ComposerBoxContent.tsx | 146 +++++++++++++++++ .../ReportActionCompose.tsx | 154 +----------------- 2 files changed, 149 insertions(+), 151 deletions(-) create mode 100644 src/pages/inbox/report/ReportActionCompose/ComposerBoxContent.tsx diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerBoxContent.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerBoxContent.tsx new file mode 100644 index 0000000000000..9399ad7bf0f06 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ComposerBoxContent.tsx @@ -0,0 +1,146 @@ +import React, {useEffect} from 'react'; +import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTriggered'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import DomUtils from '@libs/DomUtils'; +import FS from '@libs/Fullstory'; +import {chatIncludesChronos, chatIncludesConcierge} from '@libs/ReportUtils'; +import {hideEmojiPicker, isActive as isActiveEmojiPickerAction, isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; +import {useComposerBox} from './ComposerBox'; +import {useComposerActions, useComposerData, useComposerDataActions, useComposerSendState, useComposerState} from './ComposerContext'; +import ComposerWithSuggestions from './ComposerWithSuggestions'; +import SendButton from './SendButton'; + +type ComposerBoxContentProps = { + reportID: string; +}; + +function ComposerBoxContent({reportID}: ComposerBoxContentProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isMediumScreenWidth} = useResponsiveLayout(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + + const {isComposerFullSize, isFullComposerAvailable, isMenuVisible} = useComposerState(); + const {isBlockedFromConcierge, isSendDisabled, exceededMaxLength} = useComposerSendState(); + const {setMenuVisibility, setIsFullComposerAvailable, handleSendMessage, focus, onValueChange} = useComposerActions(); + const {composerRef, suggestionsRef, actionButtonRef, isNextModalWillOpenRef, shouldFocusComposerOnScreenFocus, shouldShowComposeInput, userBlockedFromConcierge} = useComposerData(); + const {onBlur, onFocus, onAddActionPressed, onItemSelected, onTriggerAttachmentPicker, submitForm, validateAttachments, setComposerRef} = useComposerDataActions(); + const {measureContainer} = useComposerBox(); + + const {isScrollLayoutTriggered, raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); + + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + + const reportParticipantIDs = Object.keys(report?.participants ?? {}) + .map(Number) + .filter((accountID) => accountID !== currentUserPersonalDetails.accountID); + + const includesConcierge = chatIncludesConcierge({participants: report?.participants}); + const isGroupPolicyReport = !!report?.policyID && report.policyID !== CONST.POLICY.ID_FAKE; + const inputPlaceholder = includesConcierge && userBlockedFromConcierge ? translate('reportActionCompose.blockedFromConcierge') : translate('reportActionCompose.writeSomething'); + const fsClass = report ? FS.getChatFSClass(report) : undefined; + + const chatItemComposeSecondaryRowHeight = styles.chatItemComposeSecondaryRow.height + styles.chatItemComposeSecondaryRow.marginTop + styles.chatItemComposeSecondaryRow.marginBottom; + const reportActionComposeHeight = styles.chatItemComposeBox.minHeight + chatItemComposeSecondaryRowHeight; + const emojiOffsetWithComposeBox = (styles.chatItemComposeBox.minHeight - styles.chatItemEmojiButton.height) / 2; + const emojiShiftVertical = reportActionComposeHeight - emojiOffsetWithComposeBox - CONST.MENU_POSITION_REPORT_ACTION_COMPOSE_BOTTOM; + + // Hide emoji picker on unmount or when switching reports — co-located with EmojiPickerButton trigger + useEffect( + () => () => { + if (!isActiveEmojiPickerAction(report?.reportID)) { + return; + } + hideEmojiPicker(); + }, + [report?.reportID], + ); + + return ( + <> + 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} + /> + validateAttachments({files})} + onClear={submitForm} + disabled={isBlockedFromConcierge || isEmojiPickerVisible()} + onEnterKeyPress={handleSendMessage} + shouldShowComposeInput={shouldShowComposeInput} + onFocus={onFocus} + onBlur={onBlur} + measureParentContainer={measureContainer} + onValueChange={onValueChange} + forwardedFSClass={fsClass} + /> + {canUseTouchScreen() && isMediumScreenWidth ? null : ( + { + if (isNavigating) { + return; + } + const activeElementId = DomUtils.getActiveElement()?.id; + if (activeElementId === CONST.COMPOSER.NATIVE_ID || activeElementId === CONST.EMOJI_PICKER_BUTTON_NATIVE_ID) { + return; + } + focus(); + }} + onEmojiSelected={(...args) => composerRef.current?.replaceSelectionWithText(...args)} + emojiPickerID={report?.reportID} + shiftVertical={emojiShiftVertical} + /> + )} + + + ); +} + +export default ComposerBoxContent; diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index b80cbc3f3255b..857ee32c84dc7 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -1,175 +1,27 @@ -import React, {useEffect} from 'react'; +import React from 'react'; import {View} from 'react-native'; -import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; import ImportedStateIndicator from '@components/ImportedStateIndicator'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTriggered'; -import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; -import {canUseTouchScreen} from '@libs/DeviceCapabilities'; -import DomUtils from '@libs/DomUtils'; -import FS from '@libs/Fullstory'; -import {chatIncludesChronos, chatIncludesConcierge} from '@libs/ReportUtils'; -import {hideEmojiPicker, isActive as isActiveEmojiPickerAction, isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; -import ComposerBox, {useComposerBox} from './ComposerBox'; +import ComposerBox from './ComposerBox'; +import ComposerBoxContent from './ComposerBoxContent'; import type {SuggestionsRef} from './ComposerContext'; -import {useComposerActions, useComposerData, useComposerDataActions, useComposerSendState, useComposerState} from './ComposerContext'; import ComposerDropZone from './ComposerDropZone'; import ComposerFooter from './ComposerFooter'; import ComposerLocalTime from './ComposerLocalTime'; import ComposerProvider from './ComposerProvider'; -import ComposerWithSuggestions from './ComposerWithSuggestions'; import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; -import SendButton from './SendButton'; type ReportActionComposeProps = { - /** The ID of the report this composer is for */ reportID: string; }; -// --------------------------------------------------------------------------- -// ComposerBoxContent — reads from all contexts, passes props to children. -// Transitional: shrinks as children self-subscribe to context. -// --------------------------------------------------------------------------- - -type ComposerBoxContentProps = { - reportID: string; -}; - -function ComposerBoxContent({reportID}: ComposerBoxContentProps) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {isMediumScreenWidth} = useResponsiveLayout(); - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - - const {isComposerFullSize, isFullComposerAvailable, isMenuVisible} = useComposerState(); - const {isBlockedFromConcierge, isSendDisabled, exceededMaxLength} = useComposerSendState(); - const {setMenuVisibility, setIsFullComposerAvailable, handleSendMessage, focus, onValueChange} = useComposerActions(); - const {composerRef, suggestionsRef, actionButtonRef, isNextModalWillOpenRef, shouldFocusComposerOnScreenFocus, shouldShowComposeInput, userBlockedFromConcierge} = useComposerData(); - const {onBlur, onFocus, onAddActionPressed, onItemSelected, onTriggerAttachmentPicker, submitForm, validateAttachments, setComposerRef} = useComposerDataActions(); - const {measureContainer} = useComposerBox(); - - const {isScrollLayoutTriggered, raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); - - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - - const reportParticipantIDs = Object.keys(report?.participants ?? {}) - .map(Number) - .filter((accountID) => accountID !== currentUserPersonalDetails.accountID); - - const includesConcierge = chatIncludesConcierge({participants: report?.participants}); - const isGroupPolicyReport = !!report?.policyID && report.policyID !== CONST.POLICY.ID_FAKE; - const inputPlaceholder = includesConcierge && userBlockedFromConcierge ? translate('reportActionCompose.blockedFromConcierge') : translate('reportActionCompose.writeSomething'); - const fsClass = report ? FS.getChatFSClass(report) : undefined; - - const chatItemComposeSecondaryRowHeight = styles.chatItemComposeSecondaryRow.height + styles.chatItemComposeSecondaryRow.marginTop + styles.chatItemComposeSecondaryRow.marginBottom; - const reportActionComposeHeight = styles.chatItemComposeBox.minHeight + chatItemComposeSecondaryRowHeight; - const emojiOffsetWithComposeBox = (styles.chatItemComposeBox.minHeight - styles.chatItemEmojiButton.height) / 2; - const emojiShiftVertical = reportActionComposeHeight - emojiOffsetWithComposeBox - CONST.MENU_POSITION_REPORT_ACTION_COMPOSE_BOTTOM; - - // Hide emoji picker on unmount or when switching reports — co-located with EmojiPickerButton trigger - useEffect( - () => () => { - if (!isActiveEmojiPickerAction(report?.reportID)) { - return; - } - hideEmojiPicker(); - }, - [report?.reportID], - ); - - return ( - <> - 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} - /> - validateAttachments({files})} - onClear={submitForm} - disabled={isBlockedFromConcierge || isEmojiPickerVisible()} - onEnterKeyPress={handleSendMessage} - shouldShowComposeInput={shouldShowComposeInput} - onFocus={onFocus} - onBlur={onBlur} - measureParentContainer={measureContainer} - onValueChange={onValueChange} - forwardedFSClass={fsClass} - /> - {canUseTouchScreen() && isMediumScreenWidth ? null : ( - { - if (isNavigating) { - return; - } - const activeElementId = DomUtils.getActiveElement()?.id; - if (activeElementId === CONST.COMPOSER.NATIVE_ID || activeElementId === CONST.EMOJI_PICKER_BUTTON_NATIVE_ID) { - return; - } - focus(); - }} - onEmojiSelected={(...args) => composerRef.current?.replaceSelectionWithText(...args)} - emojiPickerID={report?.reportID} - shiftVertical={emojiShiftVertical} - /> - )} - - - ); -} - -// --------------------------------------------------------------------------- -// Orchestrator — layout + report-level data resolution. -// --------------------------------------------------------------------------- - function Composer({reportID}: ReportActionComposeProps) { const styles = useThemeStyles(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); From be7f8488e93b7024bc5fd8ab91b01330ce1202df Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 1 Apr 2026 16:27:23 +0200 Subject: [PATCH 26/57] Remove redundant report guard and subscription from orchestrator Co-Authored-By: Claude Opus 4.6 (1M context) --- .../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 857ee32c84dc7..b7f6153cabe6d 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -22,13 +22,8 @@ function Composer({reportID}: ReportActionComposeProps) { const styles = useThemeStyles(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); - if (!report) { - return null; - } - return ( From 3bad8ade5e1354bf6ba0699f37139eadacccd355 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 1 Apr 2026 16:35:37 +0200 Subject: [PATCH 27/57] Extract Composer.SendButton + Composer.EmojiPicker as compound components Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ComposerBoxContent.tsx | 55 ++-------------- .../ComposerEmojiPicker.tsx | 66 +++++++++++++++++++ .../ComposerSendButton.tsx | 17 +++++ .../ReportActionCompose.tsx | 6 ++ 4 files changed, 93 insertions(+), 51 deletions(-) create mode 100644 src/pages/inbox/report/ReportActionCompose/ComposerEmojiPicker.tsx create mode 100644 src/pages/inbox/report/ReportActionCompose/ComposerSendButton.tsx diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerBoxContent.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerBoxContent.tsx index 9399ad7bf0f06..43619383dc5c0 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerBoxContent.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerBoxContent.tsx @@ -1,39 +1,30 @@ -import React, {useEffect} from 'react'; -import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; +import React from 'react'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTriggered'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useThemeStyles from '@hooks/useThemeStyles'; -import {canUseTouchScreen} from '@libs/DeviceCapabilities'; -import DomUtils from '@libs/DomUtils'; import FS from '@libs/Fullstory'; import {chatIncludesChronos, chatIncludesConcierge} from '@libs/ReportUtils'; -import {hideEmojiPicker, isActive as isActiveEmojiPickerAction, isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; +import {isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; import {useComposerBox} from './ComposerBox'; import {useComposerActions, useComposerData, useComposerDataActions, useComposerSendState, useComposerState} from './ComposerContext'; import ComposerWithSuggestions from './ComposerWithSuggestions'; -import SendButton from './SendButton'; type ComposerBoxContentProps = { reportID: string; }; function ComposerBoxContent({reportID}: ComposerBoxContentProps) { - const styles = useThemeStyles(); const {translate} = useLocalize(); - // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {isMediumScreenWidth} = useResponsiveLayout(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const {isComposerFullSize, isFullComposerAvailable, isMenuVisible} = useComposerState(); - const {isBlockedFromConcierge, isSendDisabled, exceededMaxLength} = useComposerSendState(); + const {isBlockedFromConcierge, exceededMaxLength} = useComposerSendState(); const {setMenuVisibility, setIsFullComposerAvailable, handleSendMessage, focus, onValueChange} = useComposerActions(); - const {composerRef, suggestionsRef, actionButtonRef, isNextModalWillOpenRef, shouldFocusComposerOnScreenFocus, shouldShowComposeInput, userBlockedFromConcierge} = useComposerData(); + const {suggestionsRef, actionButtonRef, isNextModalWillOpenRef, shouldFocusComposerOnScreenFocus, shouldShowComposeInput, userBlockedFromConcierge} = useComposerData(); const {onBlur, onFocus, onAddActionPressed, onItemSelected, onTriggerAttachmentPicker, submitForm, validateAttachments, setComposerRef} = useComposerDataActions(); const {measureContainer} = useComposerBox(); @@ -50,22 +41,6 @@ function ComposerBoxContent({reportID}: ComposerBoxContentProps) { const inputPlaceholder = includesConcierge && userBlockedFromConcierge ? translate('reportActionCompose.blockedFromConcierge') : translate('reportActionCompose.writeSomething'); const fsClass = report ? FS.getChatFSClass(report) : undefined; - const chatItemComposeSecondaryRowHeight = styles.chatItemComposeSecondaryRow.height + styles.chatItemComposeSecondaryRow.marginTop + styles.chatItemComposeSecondaryRow.marginBottom; - const reportActionComposeHeight = styles.chatItemComposeBox.minHeight + chatItemComposeSecondaryRowHeight; - const emojiOffsetWithComposeBox = (styles.chatItemComposeBox.minHeight - styles.chatItemEmojiButton.height) / 2; - const emojiShiftVertical = reportActionComposeHeight - emojiOffsetWithComposeBox - CONST.MENU_POSITION_REPORT_ACTION_COMPOSE_BOTTOM; - - // Hide emoji picker on unmount or when switching reports — co-located with EmojiPickerButton trigger - useEffect( - () => () => { - if (!isActiveEmojiPickerAction(report?.reportID)) { - return; - } - hideEmojiPicker(); - }, - [report?.reportID], - ); - return ( <> - {canUseTouchScreen() && isMediumScreenWidth ? null : ( - { - if (isNavigating) { - return; - } - const activeElementId = DomUtils.getActiveElement()?.id; - if (activeElementId === CONST.COMPOSER.NATIVE_ID || activeElementId === CONST.EMOJI_PICKER_BUTTON_NATIVE_ID) { - return; - } - focus(); - }} - onEmojiSelected={(...args) => composerRef.current?.replaceSelectionWithText(...args)} - emojiPickerID={report?.reportID} - shiftVertical={emojiShiftVertical} - /> - )} - ); } diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerEmojiPicker.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerEmojiPicker.tsx new file mode 100644 index 0000000000000..d565337b6b014 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ComposerEmojiPicker.tsx @@ -0,0 +1,66 @@ +import React, {useEffect} from 'react'; +import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; +import useOnyx from '@hooks/useOnyx'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import DomUtils from '@libs/DomUtils'; +import {hideEmojiPicker, isActive as isActiveEmojiPickerAction} from '@userActions/EmojiPickerAction'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {useComposerActions, useComposerData, useComposerSendState} from './ComposerContext'; + +type ComposerEmojiPickerProps = { + reportID: string; +}; + +function ComposerEmojiPicker({reportID}: ComposerEmojiPickerProps) { + const styles = useThemeStyles(); + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isMediumScreenWidth} = useResponsiveLayout(); + const {isBlockedFromConcierge} = useComposerSendState(); + const {focus} = useComposerActions(); + const {composerRef} = useComposerData(); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + + const chatItemComposeSecondaryRowHeight = styles.chatItemComposeSecondaryRow.height + styles.chatItemComposeSecondaryRow.marginTop + styles.chatItemComposeSecondaryRow.marginBottom; + const reportActionComposeHeight = styles.chatItemComposeBox.minHeight + chatItemComposeSecondaryRowHeight; + const emojiOffsetWithComposeBox = (styles.chatItemComposeBox.minHeight - styles.chatItemEmojiButton.height) / 2; + const emojiShiftVertical = reportActionComposeHeight - emojiOffsetWithComposeBox - CONST.MENU_POSITION_REPORT_ACTION_COMPOSE_BOTTOM; + + // Hide emoji picker on unmount or when switching reports + useEffect( + () => () => { + if (!isActiveEmojiPickerAction(report?.reportID)) { + return; + } + hideEmojiPicker(); + }, + [report?.reportID], + ); + + if (canUseTouchScreen() && isMediumScreenWidth) { + return null; + } + + return ( + { + if (isNavigating) { + return; + } + const activeElementId = DomUtils.getActiveElement()?.id; + if (activeElementId === CONST.COMPOSER.NATIVE_ID || activeElementId === CONST.EMOJI_PICKER_BUTTON_NATIVE_ID) { + return; + } + focus(); + }} + onEmojiSelected={(...args) => composerRef.current?.replaceSelectionWithText(...args)} + emojiPickerID={report?.reportID} + shiftVertical={emojiShiftVertical} + /> + ); +} + +export default ComposerEmojiPicker; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerSendButton.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerSendButton.tsx new file mode 100644 index 0000000000000..fe6fb38c76d66 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ComposerSendButton.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import {useComposerActions, useComposerSendState} from './ComposerContext'; +import SendButton from './SendButton'; + +function ComposerSendButton() { + const {isSendDisabled} = useComposerSendState(); + const {handleSendMessage} = useComposerActions(); + + return ( + + ); +} + +export default ComposerSendButton; diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index b7f6153cabe6d..62ef79dcb8b76 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -9,9 +9,11 @@ import ComposerBox from './ComposerBox'; import ComposerBoxContent from './ComposerBoxContent'; import type {SuggestionsRef} from './ComposerContext'; import ComposerDropZone from './ComposerDropZone'; +import ComposerEmojiPicker from './ComposerEmojiPicker'; import ComposerFooter from './ComposerFooter'; import ComposerLocalTime from './ComposerLocalTime'; import ComposerProvider from './ComposerProvider'; +import ComposerSendButton from './ComposerSendButton'; import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; type ReportActionComposeProps = { @@ -32,6 +34,8 @@ function Composer({reportID}: ReportActionComposeProps) { + + @@ -49,6 +53,8 @@ function Composer({reportID}: ReportActionComposeProps) { Composer.LocalTime = ComposerLocalTime; Composer.Box = ComposerBox; Composer.DropZone = ComposerDropZone; +Composer.EmojiPicker = ComposerEmojiPicker; +Composer.SendButton = ComposerSendButton; Composer.Footer = ComposerFooter; export default Composer; From 969e889f3000770762fa5cd155612101828cac3b Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 1 Apr 2026 16:37:07 +0200 Subject: [PATCH 28/57] ComposerEmojiPicker: use reportID prop directly, drop report subscription Co-Authored-By: Claude Opus 4.6 (1M context) --- .../report/ReportActionCompose/ComposerEmojiPicker.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerEmojiPicker.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerEmojiPicker.tsx index d565337b6b014..2e32f229918c4 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerEmojiPicker.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerEmojiPicker.tsx @@ -1,13 +1,11 @@ import React, {useEffect} from 'react'; import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; -import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import DomUtils from '@libs/DomUtils'; import {hideEmojiPicker, isActive as isActiveEmojiPickerAction} from '@userActions/EmojiPickerAction'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; import {useComposerActions, useComposerData, useComposerSendState} from './ComposerContext'; type ComposerEmojiPickerProps = { @@ -21,7 +19,6 @@ function ComposerEmojiPicker({reportID}: ComposerEmojiPickerProps) { const {isBlockedFromConcierge} = useComposerSendState(); const {focus} = useComposerActions(); const {composerRef} = useComposerData(); - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const chatItemComposeSecondaryRowHeight = styles.chatItemComposeSecondaryRow.height + styles.chatItemComposeSecondaryRow.marginTop + styles.chatItemComposeSecondaryRow.marginBottom; const reportActionComposeHeight = styles.chatItemComposeBox.minHeight + chatItemComposeSecondaryRowHeight; @@ -31,12 +28,12 @@ function ComposerEmojiPicker({reportID}: ComposerEmojiPickerProps) { // Hide emoji picker on unmount or when switching reports useEffect( () => () => { - if (!isActiveEmojiPickerAction(report?.reportID)) { + if (!isActiveEmojiPickerAction(reportID)) { return; } hideEmojiPicker(); }, - [report?.reportID], + [reportID], ); if (canUseTouchScreen() && isMediumScreenWidth) { @@ -57,7 +54,7 @@ function ComposerEmojiPicker({reportID}: ComposerEmojiPickerProps) { focus(); }} onEmojiSelected={(...args) => composerRef.current?.replaceSelectionWithText(...args)} - emojiPickerID={report?.reportID} + emojiPickerID={reportID} shiftVertical={emojiShiftVertical} /> ); From eb8462792fb13c2b2da565c1419aee84db705751 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 1 Apr 2026 16:46:52 +0200 Subject: [PATCH 29/57] Split ComposerBoxContent into Composer.ActionMenu + Composer.Input Co-Authored-By: Claude Opus 4.6 (1M context) --- ...-30-report-action-compose-decomposition.md | 981 ++++++++++++++++++ .../ComposerActionMenu.tsx | 58 ++ .../ComposerBoxContent.tsx | 99 -- .../ComposerInputWrapper.tsx | 65 ++ .../ReportActionCompose.tsx | 8 +- 5 files changed, 1110 insertions(+), 101 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-30-report-action-compose-decomposition.md create mode 100644 src/pages/inbox/report/ReportActionCompose/ComposerActionMenu.tsx delete mode 100644 src/pages/inbox/report/ReportActionCompose/ComposerBoxContent.tsx create mode 100644 src/pages/inbox/report/ReportActionCompose/ComposerInputWrapper.tsx diff --git a/docs/superpowers/plans/2026-03-30-report-action-compose-decomposition.md b/docs/superpowers/plans/2026-03-30-report-action-compose-decomposition.md new file mode 100644 index 0000000000000..daed1b357bb83 --- /dev/null +++ b/docs/superpowers/plans/2026-03-30-report-action-compose-decomposition.md @@ -0,0 +1,981 @@ +# ReportActionCompose Decomposition Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Decompose the 730-line ReportActionCompose monolith into focused components with correct data ownership, eliminating duplicate subscriptions and enabling React Compiler compliance. + +**Architecture:** Push subscriptions down to the narrowest owner. Extract side-effect-only concerns into renderless components. Convert the hook-returning-JSX pattern into a proper component. Remove manual memoization so the React Compiler can optimize the entire tree. + +**Tech Stack:** React, TypeScript, React Native Onyx (useOnyx), React Compiler (babel-plugin-react-compiler) + +--- + +## File Map + +| File | Action | Responsibility | +|------|--------|----------------| +| `src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx` | Modify | Slim orchestrator: local UI state + minimal subscriptions | +| `src/pages/inbox/report/ReportActionCompose/EmojiPickerCleanupHandler.tsx` | Create | Renderless component: hides emoji picker on unmount | +| `src/pages/inbox/report/ReportActionCompose/DropZoneArea.tsx` | Create | Self-subscribing component: transaction resolution + drop zone rendering | +| `src/pages/inbox/report/ReportActionCompose/AttachmentUploadHandler.tsx` | Create | Self-subscribing component replacing useAttachmentUploadValidation hook | +| `src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts` | Delete | Replaced by AttachmentUploadHandler component | +| `src/pages/inbox/report/ReportActionCompose/SendButton.tsx` | Modify | Remove memo() wrapper | + +--- + +## Coding Standards + +Every task must comply with all rules from `/coding-standards`. Key rules for this work: + +- **CLEAN-REACT-PATTERNS-0**: No manual memoization (useCallback, useMemo, React.memo). React Compiler handles it. +- **CLEAN-REACT-PATTERNS-2**: Components own their behavior. Don't pass data the child can get itself. +- **CLEAN-REACT-PATTERNS-4**: No side-effect spaghetti. Extract focused concerns. +- **CLEAN-REACT-PATTERNS-5**: Keep state narrow. Each component subscribes to exactly what it needs. +- **PERF-11**: Optimize data selection. Use selectors on useOnyx when extracting scalar values from objects. +- **CONSISTENCY-5**: Justify eslint-disable or remove it. + +--- + +### Task 1: Remove dead `onSubmitAction` export and duplicate `useCurrentUserPersonalDetails` + +**Files:** +- Modify: `src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx` + +**Context:** `onSubmitAction` is a module-level mutable variable that is exported but never imported anywhere in the codebase. It is assigned inside the component body (line 498: `onSubmitAction = handleSendMessage`), which is a side effect during render -- an anti-pattern. Additionally, `useCurrentUserPersonalDetails()` is called twice (line 157 and line 232 as `personalDetail`), creating duplicate subscriptions to the same Onyx key. + +- [ ] **Step 1: Verify `onSubmitAction` is unused** + +Run: `cd /Users/adhorodyski/Developer/Expensify-App-w2 && grep -r "onSubmitAction" src/ --include="*.ts" --include="*.tsx" | grep -v "ReportActionCompose.tsx"` + +Expected: No matches (confirming it is dead code). + +- [ ] **Step 2: Remove `onSubmitAction`** + +Remove these pieces from `ReportActionCompose.tsx`: +1. The `import noop from 'lodash/noop';` (line 2) -- only if no other usage exists in the file. +2. The `let onSubmitAction = noop;` (line 133) and the `// eslint-disable-next-line import/no-mutable-exports` comment above it (line 132). +3. The `onSubmitAction = handleSendMessage;` assignment (line 498). +4. The `export {onSubmitAction};` from the export line (line 728). Keep the other exports on that line. + +- [ ] **Step 3: Remove duplicate `useCurrentUserPersonalDetails` call** + +Line 232 calls `const personalDetail = useCurrentUserPersonalDetails();` separately from line 157's `const currentUserPersonalDetails = useCurrentUserPersonalDetails();`. The only usage of `personalDetail` is `personalDetail.timezone` on line 363. + +Replace `personalDetail.timezone` with `currentUserPersonalDetails.timezone` and remove the `const personalDetail = useCurrentUserPersonalDetails();` line entirely. + +- [ ] **Step 4: Remove `memo()` wrapper from ReportActionCompose** + +Line 727: Change `export default memo(ReportActionCompose);` to `export default ReportActionCompose;`. + +Remove `memo` from the React import on line 3. Keep other imports from react that are still used. + +- [ ] **Step 5: Remove `memo()` wrapper from SendButton** + +In `src/pages/inbox/report/ReportActionCompose/SendButton.tsx`: +Line 82: Change `export default memo(SendButton);` to `export default SendButton;`. + +Remove `memo` from the React import on line 1. `React` itself may still be needed if JSX transform requires it -- check if other imports from react remain. + +- [ ] **Step 6: Verify** + +Run: +```bash +cd /Users/adhorodyski/Developer/Expensify-App-w2 +npx prettier --write src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx src/pages/inbox/report/ReportActionCompose/SendButton.tsx +npx eslint src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx src/pages/inbox/report/ReportActionCompose/SendButton.tsx --max-warnings=0 +npm run typecheck-tsgo +``` + +- [ ] **Step 7: Commit** + +```bash +git add src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx src/pages/inbox/report/ReportActionCompose/SendButton.tsx +git commit -m "Remove dead onSubmitAction, duplicate useCurrentUserPersonalDetails, memo wrappers" +``` + +--- + +### Task 2: Extract EmojiPickerCleanupHandler (fix eslint-disable that blocks React Compiler) + +**Files:** +- Create: `src/pages/inbox/report/ReportActionCompose/EmojiPickerCleanupHandler.tsx` +- Modify: `src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx` + +**Context:** Lines 436-445 have a `useEffect` with `eslint-disable react-hooks/exhaustive-deps` for a mount-only cleanup effect that hides the emoji picker on unmount. This eslint-disable prevents the React Compiler from optimizing the entire component. The fix is to extract this into a renderless component where the empty deps are correct by construction (the component unmounts when the parent unmounts). + +- [ ] **Step 1: Create EmojiPickerCleanupHandler** + +Create `src/pages/inbox/report/ReportActionCompose/EmojiPickerCleanupHandler.tsx`: + +```tsx +import {useEffect} from 'react'; +import {hideEmojiPicker, isActive as isActiveEmojiPickerAction} from '@userActions/EmojiPickerAction'; + +type EmojiPickerCleanupHandlerProps = { + reportID: string | undefined; +}; + +/** + * Renderless component that hides the emoji picker when the composer unmounts, + * but only if the picker is active for this specific report. + */ +function EmojiPickerCleanupHandler({reportID}: EmojiPickerCleanupHandlerProps) { + useEffect(() => { + return () => { + if (!isActiveEmojiPickerAction(reportID)) { + return; + } + hideEmojiPicker(); + }; + }, [reportID]); + + return null; +} + +export default EmojiPickerCleanupHandler; +``` + +- [ ] **Step 2: Replace the inline effect in ReportActionCompose** + +In `ReportActionCompose.tsx`, remove lines 435-445 (the useEffect with eslint-disable): + +```tsx +// REMOVE THIS ENTIRE BLOCK: + // We are returning a callback here as we want to invoke the method on unmount only + useEffect( + () => () => { + if (!isActiveEmojiPickerAction(report?.reportID)) { + return; + } + hideEmojiPicker(); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); +``` + +Add the renderless component in the JSX return, right before the closing `` of the outermost wrapper (before line 724). Place it outside the visible UI tree: + +```tsx + + + ); +``` + +Add the import at the top of the file: +```tsx +import EmojiPickerCleanupHandler from './EmojiPickerCleanupHandler'; +``` + +Clean up unused imports: if `isActive as isActiveEmojiPickerAction` and `hideEmojiPicker` are no longer used in ReportActionCompose.tsx (they were only used in the removed effect), remove them from the import. + +- [ ] **Step 3: Fix the measureContainer eslint-disable** + +Lines 280-289 have a `useCallback` with `eslint-disable react-hooks/exhaustive-deps` to include `isComposerFullSize` in the dependency array for repositioning purposes. Since React Compiler handles memoization, remove the `useCallback` wrapper entirely and make it a plain function. The compiler will handle caching. + +Replace: +```tsx + const measureContainer = useCallback( + (callback: MeasureInWindowOnSuccessCallback) => { + if (!containerRef.current) { + return; + } + containerRef.current.measureInWindow(callback); + }, + // We added isComposerFullSize in dependencies so that when this value changes, we recalculate the position of the popup + // eslint-disable-next-line react-hooks/exhaustive-deps + [isComposerFullSize], + ); +``` + +With: +```tsx + const measureContainer = (callback: MeasureInWindowOnSuccessCallback) => { + if (!containerRef.current) { + return; + } + containerRef.current.measureInWindow(callback); + }; +``` + +Note: `isComposerFullSize` was in the deps to trigger re-calculation. Since the plain function captures the latest ref value on each render, and React Compiler will memoize based on actual dependencies, this is correct. The `containerRef.current.measureInWindow` always reads the current DOM position. + +- [ ] **Step 4: Remove all remaining manual memoization from ReportActionCompose** + +Remove ALL `useCallback` and `useMemo` wrappers in ReportActionCompose.tsx. Convert each to a plain function or expression. The React Compiler handles all memoization. + +For each `useCallback((args) => { ... }, [deps])`, replace with `(args) => { ... }`. +For each `useMemo(() => expr, [deps])`, replace with just `expr` (the expression itself). + +The following need conversion: +- `onAddActionPressed` (line 292) -> plain arrow function +- `onItemSelected` (line 299) -> plain arrow function +- `updateShouldShowSuggestionMenuToFalse` (line 303) -> plain arrow function +- `addAttachment` (line 314) -> plain arrow function +- `onAttachmentPreviewClose` (line 332) -> plain arrow function +- `submitForm` (line 348) -> plain arrow function +- `onTriggerAttachmentPicker` (line 400) -> plain arrow function +- `onBlur` (line 405) -> plain arrow function +- `onFocus` (line 420) -> plain arrow function +- `validateMaxLength` (line 457) -> plain arrow function +- `handleSendMessage` (line 477) -> plain arrow function +- `onValueChange` (line 530) -> plain arrow function +- `reportParticipantIDs` (line 208) -> plain expression +- `shouldShowReportRecipientLocalTime` (line 216) -> plain expression +- `includesConcierge` (line 221) -> plain expression +- `userBlockedFromConcierge` (line 222) -> plain expression +- `isBlockedFromConcierge` (line 223) -> plain expression +- `isTransactionThreadView` (line 225) -> plain expression +- `isExpensesReport` (line 226) -> plain expression +- `transactionID` (line 237) -> plain expression +- `isSingleTransactionView` (line 241) -> plain expression +- `shouldDisplayDualDropZone` (line 252) -> plain expression +- `inputPlaceholder` (line 262) -> plain expression +- `debouncedValidate` (line 471) -> needs careful handling (see below) +- `isGroupPolicyReport` (line 448) -> plain expression +- `emojiPositionValues` (line 500) -> plain expression +- `emojiShiftVertical` (line 517) -> plain expression + +**Special case -- `debouncedValidate`:** The `useMemo` around `lodashDebounce` creates a stable debounced function. Without `useMemo`, a new debounced function would be created on every render, breaking the debounce. The React Compiler will handle this correctly -- it will memoize the `lodashDebounce(...)` call based on `validateMaxLength` as a dependency. Convert it to a plain expression: + +```tsx +const debouncedValidate = lodashDebounce(validateMaxLength, CONST.TIMING.COMMENT_LENGTH_DEBOUNCE_TIME, {leading: true}); +``` + +The compiler will cache this value and only recreate it when `validateMaxLength` changes. + +After all conversions, remove `useCallback`, `useMemo` from the React import line. Keep `useContext`, `useEffect`, `useRef`, `useState`. + +- [ ] **Step 5: Verify** + +Run: +```bash +cd /Users/adhorodyski/Developer/Expensify-App-w2 +npx prettier --write src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx src/pages/inbox/report/ReportActionCompose/EmojiPickerCleanupHandler.tsx +npx eslint src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx src/pages/inbox/report/ReportActionCompose/EmojiPickerCleanupHandler.tsx --max-warnings=0 +npm run typecheck-tsgo +``` + +Verify no eslint-disable for react-hooks remains in ReportActionCompose.tsx: +```bash +grep -n "eslint-disable.*react-hooks" src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +``` +Expected: Only the `rulesdir/prefer-shouldUseNarrowLayout` disable on the responsive layout line (this is a different rule, not react-hooks). + +- [ ] **Step 6: Commit** + +```bash +git add src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx src/pages/inbox/report/ReportActionCompose/EmojiPickerCleanupHandler.tsx +git commit -m "Extract EmojiPickerCleanupHandler, remove manual memoization for React Compiler" +``` + +--- + +### Task 3: Extract DropZoneArea (isolate transaction resolution + drop zone subscriptions) + +**Files:** +- Create: `src/pages/inbox/report/ReportActionCompose/DropZoneArea.tsx` +- Modify: `src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx` + +**Context:** ReportActionCompose subscribes to `COLLECTION.REPORT_ACTIONS/{reportID}`, `COLLECTION.TRANSACTION/{transactionID}`, `COLLECTION.POLICY/{policyID}`, `BETAS`, `usePreferredPolicy`, `useReportIsArchived`, and `COLLECTION.REPORT/{parentReportID}` primarily to compute `shouldDisplayDualDropZone`, `shouldAddOrReplaceReceipt`, and `hasReceipt`. These are all needed only by the drop zone rendering. Pushing them into a self-subscribing component eliminates ~8 subscriptions from the root. + +- [ ] **Step 1: Create DropZoneArea component** + +Create `src/pages/inbox/report/ReportActionCompose/DropZoneArea.tsx`: + +```tsx +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import DragAndDropConsumer from '@components/DragAndDrop/Consumer'; +import DropZoneUI from '@components/DropZone/DropZoneUI'; +import DualDropZone from '@components/DropZone/DualDropZone'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePreferredPolicy from '@hooks/usePreferredPolicy'; +import useReportIsArchived from '@hooks/useReportIsArchived'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {getLinkedTransactionID, getReportAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import { + canEditFieldOfMoneyRequest, + canUserPerformWriteAction as canUserPerformWriteActionReportUtils, + getParentReport, + isChatRoom, + isGroupChat, + isInvoiceReport, + isReportApproved, + isReportTransactionThread, + isSettled, + temporary_getMoneyRequestOptions, +} from '@libs/ReportUtils'; +import {getTransactionID, hasReceipt as hasReceiptTransactionUtils} from '@libs/TransactionUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; + +type DropZoneAreaProps = { + reportID: string; + report: OnyxEntry; + reportTransactions: OnyxEntry; + transactionThreadReportID: string | undefined; + onAttachmentDrop: (event: DragEvent) => void; + currentUserAccountID: number; +}; + +function DropZoneArea({reportID, report, reportTransactions, transactionThreadReportID, onAttachmentDrop, currentUserAccountID}: DropZoneAreaProps) { + const styles = useThemeStyles(); + const theme = useTheme(); + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['MessageInABottle']); + const {isRestrictedToPreferredPolicy} = usePreferredPolicy(); + const isReportArchived = useReportIsArchived(report?.reportID); + + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`); + const [betas] = useOnyx(ONYXKEYS.BETAS); + const [newParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`); + const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`, {canEvict: false}); + + const isTransactionThreadView = isReportTransactionThread(report); + const isExpensesReport = reportTransactions && reportTransactions.length > 1; + + const iouAction = reportActions ? Object.values(reportActions).find((action) => isMoneyRequestAction(action)) : null; + const linkedTransactionID = iouAction && !isExpensesReport ? getLinkedTransactionID(iouAction) : undefined; + const transactionID = getTransactionID(report) ?? linkedTransactionID; + + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transactionID)}`); + + const isSingleTransactionView = !!transaction && !!reportTransactions && reportTransactions.length === 1; + const parentReportAction = isSingleTransactionView ? iouAction : getReportAction(report?.parentReportID, report?.parentReportActionID); + const canUserPerformWriteAction = !!canUserPerformWriteActionReportUtils(report, isReportArchived); + const canEditReceipt = + canUserPerformWriteAction && + canEditFieldOfMoneyRequest({reportAction: parentReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, transaction}) && + !transaction?.receipt?.isTestDriveReceipt; + const shouldAddOrReplaceReceipt = (isTransactionThreadView || isSingleTransactionView) && canEditReceipt; + const hasReceipt = hasReceiptTransactionUtils(transaction); + + const reportParticipantIDs = Object.keys(report?.participants ?? {}) + .map(Number) + .filter((accountID) => accountID !== currentUserAccountID); + + const shouldDisplayDualDropZone = (() => { + const parentReport = getParentReport(report); + const isSettledOrApproved = isSettled(report) || isSettled(parentReport) || isReportApproved({report}) || isReportApproved({report: parentReport}); + const hasMoneyRequestOptions = !!temporary_getMoneyRequestOptions(report, policy, reportParticipantIDs, betas, isReportArchived, isRestrictedToPreferredPolicy).length; + const canModifyReceipt = shouldAddOrReplaceReceipt && !isSettledOrApproved; + const isRoomOrGroupChat = isChatRoom(report) || isGroupChat(report); + return !isRoomOrGroupChat && (canModifyReceipt || hasMoneyRequestOptions) && !isInvoiceReport(report); + })(); + + if (shouldDisplayDualDropZone) { + return ( + + ); + } + + return ( + + + + ); +} + +export default DropZoneArea; +``` + +**Important note:** The `onReceiptDrop` prop on `DualDropZone` currently uses `onReceiptDropped` from `useAttachmentUploadValidation` in the original code, which is different from `onAttachmentDrop`. This will be fully resolved in Task 4 when `AttachmentUploadHandler` replaces the hook. For now, we'll pass the `onAttachmentDrop` handler. Task 4 will provide the correct receipt handler. + +Actually -- let me reconsider. The DropZoneArea needs the receipt drop handler from the attachment upload validation logic. We need to think about this dependency more carefully. The DropZoneArea needs two handlers: +1. `onAttachmentDrop` -- for the standard attachment drop zone +2. `onReceiptDrop` -- from `useAttachmentUploadValidation`'s `onReceiptDropped` + +Since Task 4 converts useAttachmentUploadValidation into a component, the handlers will live in that component. The clean approach is: DropZoneArea receives both handlers as props. The parent orchestrates by getting `onReceiptDropped` from the attachment validation and passing it down. + +Update the props type: + +```tsx +type DropZoneAreaProps = { + reportID: string; + report: OnyxEntry; + reportTransactions: OnyxEntry; + transactionThreadReportID: string | undefined; + onAttachmentDrop: (event: DragEvent) => void; + onReceiptDrop: (event: DragEvent) => void; + currentUserAccountID: number; +}; +``` + +And update the DualDropZone usage: +```tsx + +``` + +- [ ] **Step 2: Update ReportActionCompose to use DropZoneArea** + +In `ReportActionCompose.tsx`: + +1. Remove these subscriptions/hooks/computations that are now in DropZoneArea: + - `const [policy] = useOnyx(...)` (COLLECTION.POLICY) + - `const [newParentReport] = useOnyx(...)` (COLLECTION.REPORT/{parentReportID}) -- but check if still needed by useAttachmentUploadValidation. If so, keep for now and remove in Task 4. + - `const [reportActions] = useOnyx(...)` (COLLECTION.REPORT_ACTIONS/{reportID}) + - `const [transaction] = useOnyx(...)` (COLLECTION.TRANSACTION/{transactionID}) + - `const [betas] = useOnyx(...)` (ONYXKEYS.BETAS) + - `const {isRestrictedToPreferredPolicy} = usePreferredPolicy()` + - `const isReportArchived = useReportIsArchived(...)` + - `const isTransactionThreadView = ...` + - `const isExpensesReport = ...` + - `const iouAction = ...` + - `const linkedTransactionID = ...` + - `const transactionID = ...` + - `const isSingleTransactionView = ...` + - `const parentReportAction = ...` + - `const canUserPerformWriteAction = ...` + - `const canEditReceipt = ...` + - `const shouldAddOrReplaceReceipt = ...` + - `const hasReceipt = ...` + - `const shouldDisplayDualDropZone = ...` + - `const reportParticipantIDs = ...` -- but check if still needed by AttachmentPickerWithMenuItems prop. If so, keep. + + **Be careful:** Some of these values are used by `useAttachmentUploadValidation` and `AttachmentPickerWithMenuItems`. Do NOT remove values that are still used elsewhere in the component. Only remove values that were exclusively used for drop zone logic. In practice: + - `policy` is passed to `useAttachmentUploadValidation` and `AttachmentPickerWithMenuItems` -- keep for now (Task 4 will handle) + - `betas` is not passed anywhere else in RAC now (it was used for `shouldDisplayDualDropZone` and `AttachmentPickerWithMenuItems`, but APWMI already self-subscribes)... Actually, check: `betas` is NOT passed as a prop to `AttachmentPickerWithMenuItems` in the JSX. APWMI has its own `useOnyx(ONYXKEYS.BETAS)`. So `betas` in RAC is only used for `shouldDisplayDualDropZone` -- remove it. + - `reportActions` is only used for `iouAction` which feeds into transaction resolution -- remove it. + - `transaction` is only used for drop zone and receipt logic -- remove it (but `transactionID` feeds into `useAttachmentUploadValidation` -- keep `transactionID` computation for now, or see if AUUV can derive it itself in Task 4) + - `isReportArchived` is passed to no child as prop in JSX -- remove (APWMI self-subscribes) + - `isRestrictedToPreferredPolicy` same -- remove + - `newParentReport` is passed to `useAttachmentUploadValidation` -- keep for now + - `reportParticipantIDs` is passed to `AttachmentPickerWithMenuItems` -- keep for now + + **Net removals from root for this task:** + - `[betas]` subscription + - `[reportActions]` subscription + - `[transaction]` subscription + - `useReportIsArchived` + - `usePreferredPolicy` (the `isRestrictedToPreferredPolicy` part) + - All the transaction resolution computations (isTransactionThreadView, isExpensesReport, iouAction, linkedTransactionID, isSingleTransactionView, parentReportAction, canUserPerformWriteAction, canEditReceipt, shouldAddOrReplaceReceipt, hasReceipt, shouldDisplayDualDropZone) + +2. Replace the drop zone JSX block (the `{shouldDisplayDualDropZone && (...)}` and `{!shouldDisplayDualDropZone && (...)}` blocks) with: + +```tsx + +``` + +3. Add the import: +```tsx +import DropZoneArea from './DropZoneArea'; +``` + +4. Clean up unused imports: Remove imports for `DragAndDropConsumer`, `DropZoneUI`, `DualDropZone`, `getNonEmptyStringOnyxID`, `getLinkedTransactionID`, `getReportAction`, `isMoneyRequestAction`, `canEditFieldOfMoneyRequest`, `getParentReport`, `isChatRoom`, `isGroupChat`, `isInvoiceReport`, `isReportApproved`, `isReportTransactionThread`, `isSettled`, `temporary_getMoneyRequestOptions`, `getTransactionID`, `hasReceipt as hasReceiptTransactionUtils` -- but ONLY if they are not used elsewhere in the file. Some may still be used (e.g., `temporary_getMoneyRequestOptions` was only used for `shouldDisplayDualDropZone`). + +- [ ] **Step 3: Verify** + +Run: +```bash +cd /Users/adhorodyski/Developer/Expensify-App-w2 +npx prettier --write src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx src/pages/inbox/report/ReportActionCompose/DropZoneArea.tsx +npx eslint src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx src/pages/inbox/report/ReportActionCompose/DropZoneArea.tsx --max-warnings=0 +npm run typecheck-tsgo +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx src/pages/inbox/report/ReportActionCompose/DropZoneArea.tsx +git commit -m "Extract DropZoneArea: isolate transaction resolution and drop zone subscriptions" +``` + +--- + +### Task 4: Convert useAttachmentUploadValidation to AttachmentUploadHandler component + +**Files:** +- Create: `src/pages/inbox/report/ReportActionCompose/AttachmentUploadHandler.tsx` +- Modify: `src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx` +- Delete (or empty): `src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts` + +**Context:** `useAttachmentUploadValidation` is a hook that returns JSX (`PDFValidationComponent`, `ErrorModal`). This blurs the hook/component boundary -- hooks should return data, components should return JSX. The hook receives 13 props from the parent, most of which it could subscribe to itself. Converting it to a component means: +1. It self-subscribes to what it needs (policy, policyCategories, ownerBillingGraceEndPeriod, etc.) +2. The parent passes only IDs and handlers +3. The JSX it returns renders naturally at its mount point + +The hook also receives `shouldAddOrReplaceReceipt` and `transactionID` which were computed in the parent from the transaction resolution logic (now in DropZoneArea). The new component will derive these itself, or they can be passed as props since DropZoneArea already computes them. + +**Design decision:** The `AttachmentUploadHandler` component will be renderless for its attachment validation logic but will render the `PDFValidationComponent` and `ErrorModal`. It communicates validation results upward through callback props (`onValidateAttachments`, `onReceiptDropped`). + +However, this is a complex refactoring. The simpler approach: make `AttachmentUploadHandler` a component that: +- Self-subscribes to all its data needs +- Exposes `validateAttachments` and `onReceiptDropped` via an imperative handle (ref) +- Renders `PDFValidationComponent` and `ErrorModal` inline + +Actually, the cleanest approach is to recognize that the parent needs `validateAttachments` and `onReceiptDropped` as functions to pass to other children (AttachmentPickerWithMenuItems needs `validateAttachments`, DropZoneArea needs both). This is coordination state that the parent legitimately owns. The solution: + +1. Keep the validation logic as a hook (it correctly returns functions) +2. BUT make the hook self-subscribing (stop passing 13 props from the parent) +3. Move the JSX rendering into a separate component + +Let me reconsider: the cleanest path is to make `useAttachmentUploadValidation` self-subscribing by having it take only IDs (reportID, report) and subscribe to everything internally. This aligns with CLEAN-REACT-PATTERNS-2. + +- [ ] **Step 1: Refactor useAttachmentUploadValidation to be self-subscribing** + +Modify `src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts` to accept only the minimal props it cannot derive itself: + +New signature: +```tsx +type AttachmentUploadValidationProps = { + reportID: string; + report: OnyxEntry; + addAttachment: (file: FileObject | FileObject[]) => void; + onAttachmentPreviewClose: () => void; + exceededMaxLength: boolean | number | null; + isAttachmentPreviewActive: boolean; + setIsAttachmentPreviewActive: (isActive: boolean) => void; +}; +``` + +The hook will internally subscribe to: +- `COLLECTION.POLICY/{report.policyID}` (was passed as `policy`) +- `COLLECTION.REPORT/{report.parentReportID}` (was passed as `newParentReport`) +- `CURRENT_DATE` (was passed as `currentDate`) +- `useCurrentUserPersonalDetails()` (was passed as `currentUserPersonalDetails`) + +And derive `shouldAddOrReplaceReceipt` and `transactionID` internally by doing the same transaction resolution that DropZoneArea does (yes, this duplicates the computation, but each component owns exactly what it needs -- the subscriptions are per-key, not COLLECTION-level, so the cost is minimal). + +Full implementation of the refactored hook: + +```tsx +import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; +import {useCallback, useContext, useRef} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useFilesValidation from '@hooks/useFilesValidation'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePersonalPolicy from '@hooks/usePersonalPolicy'; +import useReportIsArchived from '@hooks/useReportIsArchived'; +import {cleanFileObject, cleanFileObjectName, getFilesFromClipboardEvent} from '@libs/fileDownload/FileUtils'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {hasOnlyPersonalPolicies as hasOnlyPersonalPoliciesUtil} from '@libs/PolicyUtils'; +import {getLinkedTransactionID, getReportAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import { + canEditFieldOfMoneyRequest, + canUserPerformWriteAction as canUserPerformWriteActionReportUtils, + isReportTransactionThread, + isSelfDM, +} from '@libs/ReportUtils'; +import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; +import {getTransactionID, hasReceipt as hasReceiptTransactionUtils} from '@libs/TransactionUtils'; +import Navigation from '@navigation/Navigation'; +import AttachmentModalContext from '@pages/media/AttachmentModalScreen/AttachmentModalContext'; +import {initMoneyRequest, replaceReceipt, setMoneyRequestParticipantsFromReport, setMoneyRequestReceipt} from '@userActions/IOU'; +import {buildOptimisticTransactionAndCreateDraft} from '@userActions/TransactionEdit'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type * as OnyxTypes from '@src/types/onyx'; +import type {FileObject} from '@src/types/utils/Attachment'; + +type AttachmentUploadValidationProps = { + reportID: string; + report: OnyxEntry; + addAttachment: (file: FileObject | FileObject[]) => void; + onAttachmentPreviewClose: () => void; + exceededMaxLength: boolean | number | null; + isAttachmentPreviewActive: boolean; + setIsAttachmentPreviewActive: (isActive: boolean) => void; +}; + +function useAttachmentUploadValidation({ + reportID, + report, + addAttachment, + onAttachmentPreviewClose, + exceededMaxLength, + isAttachmentPreviewActive, + setIsAttachmentPreviewActive, +}: AttachmentUploadValidationProps) { + const {translate} = useLocalize(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const isReportArchived = useReportIsArchived(report?.reportID); + + // Self-subscribe to data previously passed as props + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`); + const [newParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`); + const [currentDate] = useOnyx(ONYXKEYS.CURRENT_DATE); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy?.id}`); + const [ownerBillingGraceEndPeriod] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); + const personalPolicy = usePersonalPolicy(); + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [userBillingGraceEndPeriods] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); + const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); + + // Derive transaction resolution internally + const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`, {canEvict: false}); + const [reportTransactionsRaw] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_TRANSACTIONS}${report?.reportID}`); + + const isTransactionThreadView = isReportTransactionThread(report); + const iouAction = reportActions ? Object.values(reportActions).find((action) => isMoneyRequestAction(action)) : null; + const isExpensesReport = reportTransactionsRaw && reportTransactionsRaw.length > 1; + const linkedTransactionID = iouAction && !isExpensesReport ? getLinkedTransactionID(iouAction) : undefined; + const transactionID = getTransactionID(report) ?? linkedTransactionID; + + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transactionID)}`); + + const isSingleTransactionView = !!transaction && !!reportTransactionsRaw && reportTransactionsRaw.length === 1; + const parentReportAction = isSingleTransactionView ? iouAction : getReportAction(report?.parentReportID, report?.parentReportActionID); + const canUserPerformWriteAction = !!canUserPerformWriteActionReportUtils(report, isReportArchived); + const canEditReceipt = + canUserPerformWriteAction && + canEditFieldOfMoneyRequest({reportAction: parentReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, transaction}) && + !transaction?.receipt?.isTestDriveReceipt; + const shouldAddOrReplaceReceipt = (isTransactionThreadView || isSingleTransactionView) && canEditReceipt; + + const hasOnlyPersonalPolicies = hasOnlyPersonalPoliciesUtil(allPolicies); + + const reportAttachmentsContext = useContext(AttachmentModalContext); + const showAttachmentModalScreen = (file: FileObject | FileObject[], dataTransferItems?: DataTransferItem[]) => { + reportAttachmentsContext.setCurrentAttachment({ + reportID, + file, + dataTransferItems, + headerTitle: translate('reportActionCompose.sendAttachment'), + onConfirm: addAttachment, + onShow: () => setIsAttachmentPreviewActive(true), + onClose: onAttachmentPreviewClose, + shouldDisableSendButton: !!exceededMaxLength, + }); + Navigation.navigate(ROUTES.REPORT_ADD_ATTACHMENT.getRoute(reportID)); + }; + + const attachmentUploadType = useRef<'receipt' | 'attachment'>(undefined); + const onFilesValidated = (files: FileObject[], dataTransferItems: DataTransferItem[]) => { + if (files.length === 0) { + return; + } + + if (attachmentUploadType.current === 'attachment') { + showAttachmentModalScreen(files, dataTransferItems); + return; + } + + if (shouldAddOrReplaceReceipt && transactionID) { + const source = URL.createObjectURL(files.at(0) as Blob); + replaceReceipt({transactionID, file: files.at(0) as File, source, transactionPolicy: policy, transactionPolicyCategories: policyCategories}); + return; + } + + const initialTransaction = initMoneyRequest({ + reportID, + personalPolicy, + newIouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + report, + parentReport: newParentReport, + currentDate, + currentUserPersonalDetails, + hasOnlyPersonalPolicies, + draftTransactionIDs, + }); + + for (const [index, file] of files.entries()) { + const source = URL.createObjectURL(file as Blob); + const newTransaction = + index === 0 + ? (initialTransaction as Partial) + : buildOptimisticTransactionAndCreateDraft({ + initialTransaction: initialTransaction as Partial, + currentUserPersonalDetails, + reportID, + }); + const newTransactionID = newTransaction?.transactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID; + setMoneyRequestReceipt(newTransactionID, source, file.name ?? '', true, file.type); + setMoneyRequestParticipantsFromReport(newTransactionID, report, currentUserPersonalDetails.accountID); + } + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute( + CONST.IOU.ACTION.CREATE, + isSelfDM(report) ? CONST.IOU.TYPE.TRACK : CONST.IOU.TYPE.SUBMIT, + CONST.IOU.OPTIMISTIC_TRANSACTION_ID, + reportID, + ), + ); + }; + + const {validateFiles, PDFValidationComponent, ErrorModal} = useFilesValidation(onFilesValidated); + + const validateAttachments = ({dragEvent, files}: {dragEvent?: DragEvent; files?: FileObject | FileObject[]}) => { + if (isAttachmentPreviewActive) { + return; + } + + let extractedFiles: FileObject[] = []; + + if (files) { + extractedFiles = Array.isArray(files) ? files : [files]; + } else { + if (!dragEvent) { + return; + } + extractedFiles = getFilesFromClipboardEvent(dragEvent); + } + + const dataTransferItems = Array.from(dragEvent?.dataTransfer?.items ?? []); + if (extractedFiles.length === 0) { + return; + } + + const validIndices: number[] = []; + const fileObjects = extractedFiles + .map((item, index) => { + const fileObject = cleanFileObject(item); + const cleanedFileObject = cleanFileObjectName(fileObject); + if (cleanedFileObject !== null) { + validIndices.push(index); + } + return cleanedFileObject; + }) + .filter((fileObject) => fileObject !== null); + + if (!fileObjects.length) { + return; + } + + const filteredItems = dataTransferItems && validIndices.length > 0 ? validIndices.map((index) => dataTransferItems.at(index) ?? ({} as DataTransferItem)) : undefined; + + attachmentUploadType.current = 'attachment'; + validateFiles(fileObjects, filteredItems, {isValidatingReceipts: false}); + }; + + const onReceiptDropped = (e: DragEvent) => { + if (policy && shouldRestrictUserBillableActions(policy.id, ownerBillingGraceEndPeriod, userBillingGraceEndPeriods)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); + return; + } + + const files = getFilesFromClipboardEvent(e); + const items = Array.from(e.dataTransfer?.items ?? []); + + if (shouldAddOrReplaceReceipt && transactionID) { + const file = files.at(0); + if (!file) { + return; + } + attachmentUploadType.current = 'receipt'; + validateFiles([file], items); + } + + attachmentUploadType.current = 'receipt'; + validateFiles(files, items, {isValidatingReceipts: true}); + }; + + return { + validateAttachments, + onReceiptDropped, + PDFValidationComponent, + ErrorModal, + }; +} + +export default useAttachmentUploadValidation; +``` + +- [ ] **Step 2: Update ReportActionCompose to pass minimal props to refactored hook** + +In `ReportActionCompose.tsx`, update the `useAttachmentUploadValidation` call to pass only the minimal props: + +```tsx +const {validateAttachments, onReceiptDropped, PDFValidationComponent, ErrorModal} = useAttachmentUploadValidation({ + reportID, + report, + addAttachment, + onAttachmentPreviewClose, + exceededMaxLength, + isAttachmentPreviewActive, + setIsAttachmentPreviewActive, +}); +``` + +Remove from ReportActionCompose the subscriptions/values that are now internal to the hook: +- `const [policy] = useOnyx(...)` -- if not used elsewhere. Check: `policy` was used for `shouldDisplayDualDropZone` (now in DropZoneArea) and `useAttachmentUploadValidation` (now self-subscribing). If nothing else uses it, remove. +- `const [newParentReport] = useOnyx(...)` -- only used by useAttachmentUploadValidation. Remove. +- `const [currentDate] = useOnyx(...)` -- only used by useAttachmentUploadValidation. Remove. +- `const personalDetail` -- already removed in Task 1 (the duplicate). The remaining `currentUserPersonalDetails` is still needed for `submitForm` and `AttachmentPickerWithMenuItems` prop. + +After this step, also check if `reportParticipantIDs` is still needed. It was used for `shouldDisplayDualDropZone` (now in DropZoneArea) and as a prop to `AttachmentPickerWithMenuItems`. If APWMI still receives it, keep it. Check the APWMI props in the JSX. + +Looking at the JSX, APWMI receives `reportParticipantIDs={reportParticipantIDs}`. APWMI already self-subscribes to policy/betas/etc but receives `reportParticipantIDs` as a prop because it uses it for `temporary_getMoneyRequestOptions`. APWMI could derive this itself from report.participants and currentUserPersonalDetails.accountID. But that's a further cleanup (APWMI self-subscribing more). For this task, keep `reportParticipantIDs` in RAC. + +- [ ] **Step 3: Update DropZoneArea to receive onReceiptDrop from parent** + +Verify that DropZoneArea (created in Task 3) correctly receives `onReceiptDrop` as a prop and the parent passes `onReceiptDropped` from the hook. + +In the parent JSX: +```tsx + +``` + +- [ ] **Step 4: Verify** + +Run: +```bash +cd /Users/adhorodyski/Developer/Expensify-App-w2 +npx prettier --write src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts src/pages/inbox/report/ReportActionCompose/DropZoneArea.tsx +npx eslint src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts src/pages/inbox/report/ReportActionCompose/DropZoneArea.tsx --max-warnings=0 +npm run typecheck-tsgo +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts src/pages/inbox/report/ReportActionCompose/DropZoneArea.tsx +git commit -m "Make useAttachmentUploadValidation self-subscribing, remove proxy props from parent" +``` + +--- + +### Task 5: Remove remaining duplicate subscriptions from root + +**Files:** +- Modify: `src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx` + +**Context:** After Tasks 1-4, review what subscriptions remain at the ReportActionCompose root level and eliminate any that are not directly consumed by the orchestrator's own logic. + +- [ ] **Step 1: Audit remaining subscriptions** + +Run this to see what useOnyx/useHook calls remain: +```bash +grep -n "useOnyx\|useCurrentUserPersonalDetails\|useReportIsArchived\|usePreferredPolicy\|useNetwork\|usePersonalDetails\|useAncestors" src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +``` + +Expected remaining subscriptions after Tasks 1-4: +- `useCurrentUserPersonalDetails` -- needed for submitForm (accountID, timezone) and APWMI prop +- `usePersonalDetails` -- needed for `shouldShowReportRecipientLocalTime` and `reportRecipient` +- `useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE)` -- needed for isBlockedFromConcierge +- `useOnyx(ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT)` -- needed for shouldFocusComposerOnScreenFocus +- `useOnyx(ONYXKEYS.MODAL)` -- needed for initial focus state only (useState initializer) +- `useOnyx(COLLECTION.REPORT_DRAFT_COMMENT/{reportID})` -- needed for shouldFocusComposerOnScreenFocus and isCommentEmpty +- `useOnyx(COLLECTION.REPORT/{transactionThreadReportID})` -- needed for submitForm +- `useAncestors` -- needed for submitForm +- `useNetwork` -- needed for offline indicator styling +- Various UI hooks (useTheme, useThemeStyles, useLocalize, etc.) + +- [ ] **Step 2: Address MODAL subscription** + +`useOnyx(ONYXKEYS.MODAL)` on line 164 is named `initialModalState` and is only used in the `useState` initializer for `isFocused` (line 178-179). After the initial render, this subscription continues firing on every modal open/close for no reason -- the value is only read once. + +Since this is only needed at mount time, we can use `initWithStoredValues: false` is not the right pattern here. The correct approach: keep the subscription but note that it fires rarely (modal open/close) and the component needs to know if a modal is visible to avoid auto-focusing. Actually, looking more carefully at the code, `initialModalState` is ONLY used in the `useState` initializer -- it is never read again after mount. This means the subscription is pure waste after initialization. + +The fix: use `useOnyx` with the subscription, but recognize that React Compiler will not re-render the component when only the `initialModalState` changes IF the value is not used in the render output. Actually, `initialModalState` IS used in the useState initializer which only runs once, but the variable itself is in scope and could be captured by the compiler. The cleanest fix: rename and note that this is an initialization-only read. The compiler will handle memoization correctly -- if the value changes but nothing in the render path depends on it, no re-render propagates. + +Actually, the real issue is simpler: `useOnyx` will trigger a re-render whenever MODAL changes, regardless of whether the value is used in the output. The only way to avoid this is to NOT subscribe. We could replace this with reading from Onyx directly in the initializer. But that's a micro-optimization -- modal changes are infrequent. Leave it for now. Note it as a follow-up. + +- [ ] **Step 3: Clean up unused imports** + +After all removals, scan the import block for anything no longer used: +```bash +cd /Users/adhorodyski/Developer/Expensify-App-w2 +npx eslint src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx --max-warnings=0 +``` + +ESLint's `no-unused-vars` and `no-unused-imports` rules will flag unused imports. Fix them. + +- [ ] **Step 4: Verify the final state** + +Run: +```bash +cd /Users/adhorodyski/Developer/Expensify-App-w2 +npx prettier --write src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +npx eslint src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx --max-warnings=0 +npm run typecheck-tsgo +``` + +- [ ] **Step 5: Run React Compiler compliance check** + +```bash +cd /Users/adhorodyski/Developer/Expensify-App-w2 +check-compiler.sh src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +``` + +Expected: The file should now compile successfully with React Compiler (no eslint-disable react-hooks, no manual memoization). + +Also check the new files: +```bash +check-compiler.sh src/pages/inbox/report/ReportActionCompose/EmojiPickerCleanupHandler.tsx +check-compiler.sh src/pages/inbox/report/ReportActionCompose/DropZoneArea.tsx +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +git commit -m "Clean up remaining imports and verify compiler compliance" +``` + +--- + +## Expected Outcome + +### Before (ReportActionCompose root) + +| Metric | Value | +|--------|-------| +| Lines | ~730 | +| useOnyx subscriptions at root | ~14 direct | +| Hook-based subscriptions at root | ~7 | +| Total subscriptions at root | ~21 | +| COLLECTION-level subscriptions | 3 (via useAncestors) | +| Duplicate subscriptions (parent + child) | 7 | +| React Compiler errors | 2 (eslint-disable) | +| Manual memoization instances | ~20 (useCallback/useMemo/memo) | + +### After + +| Metric | Value | +|--------|-------| +| Lines | ~400-450 (estimated) | +| useOnyx subscriptions at root | ~5 (BLOCKED_FROM_CONCIERGE, SHOULD_SHOW_COMPOSE_INPUT, MODAL, DRAFT_COMMENT, transactionThreadReport) | +| Hook-based subscriptions at root | ~5 (currentUserPersonalDetails, personalDetails, useNetwork, useAncestors, useIsInSidePanel) | +| Total subscriptions at root | ~10 | +| COLLECTION-level subscriptions | 3 (useAncestors -- unchanged, follow-up) | +| Duplicate subscriptions eliminated | policy, betas, preferredPolicy, reportIsArchived, currentDate, newParentReport, currentUserPersonalDetails(x1) | +| React Compiler errors | 0 | +| Manual memoization instances | 0 | + +### Follow-up work (not in this PR) + +1. **Move `useAncestors` to action layer** -- The 3 COLLECTION subscriptions are the heaviest remaining cost. They should be resolved at action-time using `Onyx.connect` inside the Report action, not in the render tree. This affects 14 call sites and is a separate cross-cutting PR. +2. **Make AttachmentPickerWithMenuItems fully self-subscribing** -- It still receives `currentUserPersonalDetails` and `reportParticipantIDs` as props that it could derive itself. +3. **Push `submitForm` into a dedicated handler** -- The submit logic (attachment path vs text path, telemetry, scrollOffset check) could be a focused hook, further slimming the orchestrator. diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerActionMenu.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerActionMenu.tsx new file mode 100644 index 0000000000000..5a9fe55e862da --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ComposerActionMenu.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTriggered'; +import useOnyx from '@hooks/useOnyx'; +import {chatIncludesConcierge} from '@libs/ReportUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; +import {useComposerActions, useComposerData, useComposerDataActions, useComposerSendState, useComposerState} from './ComposerContext'; + +type ComposerActionMenuProps = { + reportID: string; +}; + +function ComposerActionMenu({reportID}: ComposerActionMenuProps) { + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const {isComposerFullSize, isFullComposerAvailable, isMenuVisible} = useComposerState(); + const {isBlockedFromConcierge, exceededMaxLength} = useComposerSendState(); + const {setMenuVisibility, focus} = useComposerActions(); + const {actionButtonRef, shouldFocusComposerOnScreenFocus} = useComposerData(); + const {onAddActionPressed, onItemSelected, onTriggerAttachmentPicker, validateAttachments} = useComposerDataActions(); + + const {raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); + + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + + const reportParticipantIDs = Object.keys(report?.participants ?? {}) + .map(Number) + .filter((accountID) => accountID !== currentUserPersonalDetails.accountID); + + return ( + 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} + /> + ); +} + +export default ComposerActionMenu; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerBoxContent.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerBoxContent.tsx deleted file mode 100644 index 43619383dc5c0..0000000000000 --- a/src/pages/inbox/report/ReportActionCompose/ComposerBoxContent.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import React from 'react'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTriggered'; -import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; -import FS from '@libs/Fullstory'; -import {chatIncludesChronos, chatIncludesConcierge} from '@libs/ReportUtils'; -import {isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; -import {useComposerBox} from './ComposerBox'; -import {useComposerActions, useComposerData, useComposerDataActions, useComposerSendState, useComposerState} from './ComposerContext'; -import ComposerWithSuggestions from './ComposerWithSuggestions'; - -type ComposerBoxContentProps = { - reportID: string; -}; - -function ComposerBoxContent({reportID}: ComposerBoxContentProps) { - const {translate} = useLocalize(); - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - - const {isComposerFullSize, isFullComposerAvailable, isMenuVisible} = useComposerState(); - const {isBlockedFromConcierge, exceededMaxLength} = useComposerSendState(); - const {setMenuVisibility, setIsFullComposerAvailable, handleSendMessage, focus, onValueChange} = useComposerActions(); - const {suggestionsRef, actionButtonRef, isNextModalWillOpenRef, shouldFocusComposerOnScreenFocus, shouldShowComposeInput, userBlockedFromConcierge} = useComposerData(); - const {onBlur, onFocus, onAddActionPressed, onItemSelected, onTriggerAttachmentPicker, submitForm, validateAttachments, setComposerRef} = useComposerDataActions(); - const {measureContainer} = useComposerBox(); - - const {isScrollLayoutTriggered, raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); - - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - - const reportParticipantIDs = Object.keys(report?.participants ?? {}) - .map(Number) - .filter((accountID) => accountID !== currentUserPersonalDetails.accountID); - - const includesConcierge = chatIncludesConcierge({participants: report?.participants}); - const isGroupPolicyReport = !!report?.policyID && report.policyID !== CONST.POLICY.ID_FAKE; - const inputPlaceholder = includesConcierge && userBlockedFromConcierge ? translate('reportActionCompose.blockedFromConcierge') : translate('reportActionCompose.writeSomething'); - const fsClass = report ? FS.getChatFSClass(report) : undefined; - - return ( - <> - 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} - /> - validateAttachments({files})} - onClear={submitForm} - disabled={isBlockedFromConcierge || isEmojiPickerVisible()} - onEnterKeyPress={handleSendMessage} - shouldShowComposeInput={shouldShowComposeInput} - onFocus={onFocus} - onBlur={onBlur} - measureParentContainer={measureContainer} - onValueChange={onValueChange} - forwardedFSClass={fsClass} - /> - - ); -} - -export default ComposerBoxContent; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerInputWrapper.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerInputWrapper.tsx new file mode 100644 index 0000000000000..a8e14198a9b83 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ComposerInputWrapper.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTriggered'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import FS from '@libs/Fullstory'; +import {chatIncludesChronos, chatIncludesConcierge} from '@libs/ReportUtils'; +import {isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {useComposerBox} from './ComposerBox'; +import {useComposerActions, useComposerData, useComposerDataActions, useComposerSendState, useComposerState} from './ComposerContext'; +import ComposerWithSuggestions from './ComposerWithSuggestions'; + +type ComposerInputWrapperProps = { + reportID: string; +}; + +function ComposerInputWrapper({reportID}: ComposerInputWrapperProps) { + const {translate} = useLocalize(); + const {isComposerFullSize, isMenuVisible} = useComposerState(); + const {isBlockedFromConcierge} = useComposerSendState(); + const {setIsFullComposerAvailable, handleSendMessage, onValueChange} = useComposerActions(); + const {suggestionsRef, isNextModalWillOpenRef, shouldShowComposeInput, userBlockedFromConcierge} = useComposerData(); + const {onBlur, onFocus, submitForm, validateAttachments, setComposerRef} = useComposerDataActions(); + const {measureContainer} = useComposerBox(); + + const {isScrollLayoutTriggered, raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); + + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + + const includesConcierge = chatIncludesConcierge({participants: report?.participants}); + const isGroupPolicyReport = !!report?.policyID && report.policyID !== CONST.POLICY.ID_FAKE; + const inputPlaceholder = includesConcierge && userBlockedFromConcierge ? translate('reportActionCompose.blockedFromConcierge') : translate('reportActionCompose.writeSomething'); + const fsClass = report ? FS.getChatFSClass(report) : undefined; + + return ( + validateAttachments({files})} + onClear={submitForm} + disabled={isBlockedFromConcierge || isEmojiPickerVisible()} + onEnterKeyPress={handleSendMessage} + shouldShowComposeInput={shouldShowComposeInput} + onFocus={onFocus} + onBlur={onBlur} + measureParentContainer={measureContainer} + onValueChange={onValueChange} + forwardedFSClass={fsClass} + /> + ); +} + +export default ComposerInputWrapper; diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 62ef79dcb8b76..6b74771ef4014 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -5,12 +5,13 @@ import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import ONYXKEYS from '@src/ONYXKEYS'; +import ComposerActionMenu from './ComposerActionMenu'; import ComposerBox from './ComposerBox'; -import ComposerBoxContent from './ComposerBoxContent'; import type {SuggestionsRef} from './ComposerContext'; import ComposerDropZone from './ComposerDropZone'; import ComposerEmojiPicker from './ComposerEmojiPicker'; import ComposerFooter from './ComposerFooter'; +import ComposerInputWrapper from './ComposerInputWrapper'; import ComposerLocalTime from './ComposerLocalTime'; import ComposerProvider from './ComposerProvider'; import ComposerSendButton from './ComposerSendButton'; @@ -33,7 +34,8 @@ function Composer({reportID}: ReportActionComposeProps) { - + + @@ -53,6 +55,8 @@ function Composer({reportID}: ReportActionComposeProps) { Composer.LocalTime = ComposerLocalTime; Composer.Box = ComposerBox; Composer.DropZone = ComposerDropZone; +Composer.ActionMenu = ComposerActionMenu; +Composer.Input = ComposerInputWrapper; Composer.EmojiPicker = ComposerEmojiPicker; Composer.SendButton = ComposerSendButton; Composer.Footer = ComposerFooter; From e6eaefb6ffa3e387d30570e43611b8fb17e75a12 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 1 Apr 2026 16:52:30 +0200 Subject: [PATCH 30/57] =?UTF-8?q?Rename=20ComposerData=20=E2=86=92=20Compo?= =?UTF-8?q?serMeta=20across=20contexts=20and=20consumers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ComposerActionMenu.tsx | 6 ++-- .../ReportActionCompose/ComposerBox.tsx | 4 +-- .../ReportActionCompose/ComposerContext.ts | 30 +++++++++---------- .../ReportActionCompose/ComposerDropZone.tsx | 4 +-- .../ComposerEmojiPicker.tsx | 4 +-- .../ComposerInputWrapper.tsx | 6 ++-- .../ReportActionCompose/ComposerProvider.tsx | 12 ++++---- 7 files changed, 33 insertions(+), 33 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerActionMenu.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerActionMenu.tsx index 5a9fe55e862da..e2c50d7983946 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerActionMenu.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerActionMenu.tsx @@ -5,7 +5,7 @@ import useOnyx from '@hooks/useOnyx'; import {chatIncludesConcierge} from '@libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; -import {useComposerActions, useComposerData, useComposerDataActions, useComposerSendState, useComposerState} from './ComposerContext'; +import {useComposerActions, useComposerMeta, useComposerMetaActions, useComposerSendState, useComposerState} from './ComposerContext'; type ComposerActionMenuProps = { reportID: string; @@ -16,8 +16,8 @@ function ComposerActionMenu({reportID}: ComposerActionMenuProps) { const {isComposerFullSize, isFullComposerAvailable, isMenuVisible} = useComposerState(); const {isBlockedFromConcierge, exceededMaxLength} = useComposerSendState(); const {setMenuVisibility, focus} = useComposerActions(); - const {actionButtonRef, shouldFocusComposerOnScreenFocus} = useComposerData(); - const {onAddActionPressed, onItemSelected, onTriggerAttachmentPicker, validateAttachments} = useComposerDataActions(); + const {actionButtonRef, shouldFocusComposerOnScreenFocus} = useComposerMeta(); + const {onAddActionPressed, onItemSelected, onTriggerAttachmentPicker, validateAttachments} = useComposerMetaActions(); const {raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx index d3651cc4f3b83..d30889c0f72e7 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx @@ -6,7 +6,7 @@ import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {getReportOfflinePendingActionAndErrors} from '@libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import {useComposerData, useComposerSendState, useComposerState} from './ComposerContext'; +import {useComposerMeta, useComposerSendState, useComposerState} from './ComposerContext'; type ComposerBoxContextValue = { measureContainer: (callback: MeasureInWindowOnSuccessCallback) => void; @@ -29,7 +29,7 @@ function ComposerBox({reportID, children}: ComposerBoxProps) { const styles = useThemeStyles(); const {isFocused, isComposerFullSize} = useComposerState(); const {exceededMaxLength, isBlockedFromConcierge} = useComposerSendState(); - const {PDFValidationComponent, ErrorModal} = useComposerData(); + const {PDFValidationComponent, ErrorModal} = useComposerMeta(); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const {reportPendingAction: pendingAction} = getReportOfflinePendingActionAndErrors(report); diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts index fbbcd15e82de1..36a8fc03c073c 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts +++ b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts @@ -47,7 +47,7 @@ type ComposerActions = { }; }; -type ComposerData = { +type ComposerMeta = { composerRef: RefObject; suggestionsRef: RefObject; actionButtonRef: RefObject; @@ -60,7 +60,7 @@ type ComposerData = { ErrorModal: ReactNode; }; -type ComposerDataActions = { +type ComposerMetaActions = { setComposerRef: (ref: ComposerRef | null) => void; onBlur: (event: BlurEvent) => void; onFocus: () => void; @@ -107,8 +107,8 @@ const ComposerValueContext = createContext(''); const ComposerStateContext = createContext(defaultState); const ComposerSendStateContext = createContext(defaultSendState); const ComposerActionsContext = createContext(defaultActions); -const ComposerDataContext = createContext(null); -const ComposerDataActionsContext = createContext(null); +const ComposerMetaContext = createContext(null); +const ComposerMetaActionsContext = createContext(null); function useComposerValue() { return useContext(ComposerValueContext); @@ -126,18 +126,18 @@ function useComposerActions() { return useContext(ComposerActionsContext); } -function useComposerData() { - const ctx = useContext(ComposerDataContext); +function useComposerMeta() { + const ctx = useContext(ComposerMetaContext); if (!ctx) { - throw new Error('useComposerData must be used inside ComposerProvider'); + throw new Error('useComposerMeta must be used inside ComposerProvider'); } return ctx; } -function useComposerDataActions() { - const ctx = useContext(ComposerDataActionsContext); +function useComposerMetaActions() { + const ctx = useContext(ComposerMetaActionsContext); if (!ctx) { - throw new Error('useComposerDataActions must be used inside ComposerProvider'); + throw new Error('useComposerMetaActions must be used inside ComposerProvider'); } return ctx; } @@ -147,13 +147,13 @@ export { ComposerStateContext, ComposerSendStateContext, ComposerActionsContext, - ComposerDataContext, - ComposerDataActionsContext, + ComposerMetaContext, + ComposerMetaActionsContext, useComposerValue, useComposerState, useComposerSendState, useComposerActions, - useComposerData, - useComposerDataActions, + useComposerMeta, + useComposerMetaActions, }; -export type {SuggestionsRef, ComposerState, ComposerSendState, ComposerActions, ComposerData, ComposerDataActions}; +export type {SuggestionsRef, ComposerState, ComposerSendState, ComposerActions, ComposerMeta, ComposerMetaActions}; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx index f02f9b4e1af8a..14b9d1163808b 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx @@ -15,7 +15,7 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getParentReport, isChatRoom, isGroupChat, isInvoiceReport, isReportApproved, isSettled, temporary_getMoneyRequestOptions} from '@libs/ReportUtils'; import {hasReceipt as hasReceiptTransactionUtils} from '@libs/TransactionUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import {useComposerDataActions} from './ComposerContext'; +import {useComposerMetaActions} from './ComposerContext'; import useShouldAddOrReplaceReceipt from './useShouldAddOrReplaceReceipt'; type ComposerDropZoneProps = { @@ -128,7 +128,7 @@ function ComposerDropZone({reportID, children}: ComposerDropZoneProps) { const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const {isOffline} = useNetwork(); const {shouldAddOrReplaceReceipt, transactionID} = useShouldAddOrReplaceReceipt(reportID, isOffline); - const {validateAttachments, onReceiptDropped} = useComposerDataActions(); + const {validateAttachments, onReceiptDropped} = useComposerMetaActions(); const onAttachmentDrop = (dragEvent: DragEvent) => validateAttachments({dragEvent}); diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerEmojiPicker.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerEmojiPicker.tsx index 2e32f229918c4..5a472a52574cf 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerEmojiPicker.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerEmojiPicker.tsx @@ -6,7 +6,7 @@ import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import DomUtils from '@libs/DomUtils'; import {hideEmojiPicker, isActive as isActiveEmojiPickerAction} from '@userActions/EmojiPickerAction'; import CONST from '@src/CONST'; -import {useComposerActions, useComposerData, useComposerSendState} from './ComposerContext'; +import {useComposerActions, useComposerMeta, useComposerSendState} from './ComposerContext'; type ComposerEmojiPickerProps = { reportID: string; @@ -18,7 +18,7 @@ function ComposerEmojiPicker({reportID}: ComposerEmojiPickerProps) { const {isMediumScreenWidth} = useResponsiveLayout(); const {isBlockedFromConcierge} = useComposerSendState(); const {focus} = useComposerActions(); - const {composerRef} = useComposerData(); + const {composerRef} = useComposerMeta(); const chatItemComposeSecondaryRowHeight = styles.chatItemComposeSecondaryRow.height + styles.chatItemComposeSecondaryRow.marginTop + styles.chatItemComposeSecondaryRow.marginBottom; const reportActionComposeHeight = styles.chatItemComposeBox.minHeight + chatItemComposeSecondaryRowHeight; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerInputWrapper.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerInputWrapper.tsx index a8e14198a9b83..b39d021105a1a 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerInputWrapper.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerInputWrapper.tsx @@ -8,7 +8,7 @@ import {isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {useComposerBox} from './ComposerBox'; -import {useComposerActions, useComposerData, useComposerDataActions, useComposerSendState, useComposerState} from './ComposerContext'; +import {useComposerActions, useComposerMeta, useComposerMetaActions, useComposerSendState, useComposerState} from './ComposerContext'; import ComposerWithSuggestions from './ComposerWithSuggestions'; type ComposerInputWrapperProps = { @@ -20,8 +20,8 @@ function ComposerInputWrapper({reportID}: ComposerInputWrapperProps) { const {isComposerFullSize, isMenuVisible} = useComposerState(); const {isBlockedFromConcierge} = useComposerSendState(); const {setIsFullComposerAvailable, handleSendMessage, onValueChange} = useComposerActions(); - const {suggestionsRef, isNextModalWillOpenRef, shouldShowComposeInput, userBlockedFromConcierge} = useComposerData(); - const {onBlur, onFocus, submitForm, validateAttachments, setComposerRef} = useComposerDataActions(); + const {suggestionsRef, isNextModalWillOpenRef, shouldShowComposeInput, userBlockedFromConcierge} = useComposerMeta(); + const {onBlur, onFocus, submitForm, validateAttachments, setComposerRef} = useComposerMetaActions(); const {measureContainer} = useComposerBox(); const {isScrollLayoutTriggered, raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx index a3fb202535b8f..43b4ffef72863 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx @@ -13,7 +13,7 @@ import {setIsComposerFullSize} from '@userActions/Report'; import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {ComposerActionsContext, ComposerDataActionsContext, ComposerDataContext, ComposerSendStateContext, ComposerStateContext, ComposerValueContext} from './ComposerContext'; +import {ComposerActionsContext, ComposerMetaActionsContext, ComposerMetaContext, ComposerSendStateContext, ComposerStateContext, ComposerValueContext} from './ComposerContext'; import type {SuggestionsRef} from './ComposerContext'; import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; import useAttachmentUploadValidation from './useAttachmentUploadValidation'; @@ -185,7 +185,7 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { debouncedValidate, }; - const composerInternalsData = { + const composerMetaState = { composerRef, suggestionsRef, actionButtonRef, @@ -198,7 +198,7 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { ErrorModal, }; - const composerInternalsActions = { + const composerMetaActions = { setComposerRef, onBlur, onFocus, @@ -218,9 +218,9 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { - - {children} - + + {children} + From 0e893c2de12715a69b363cf672f4f827cc58d3e0 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 1 Apr 2026 17:32:37 +0200 Subject: [PATCH 31/57] Move containerRef to ComposerMeta, delete ComposerBoxContext Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ComposerActionMenu.tsx | 4 +- .../ReportActionCompose/ComposerBox.tsx | 73 ++++++------------- .../ReportActionCompose/ComposerContext.ts | 5 +- .../ComposerEmojiPicker.tsx | 4 +- .../ComposerInputWrapper.tsx | 11 ++- .../ReportActionCompose/ComposerProvider.tsx | 2 + 6 files changed, 39 insertions(+), 60 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerActionMenu.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerActionMenu.tsx index e2c50d7983946..577b73863505f 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerActionMenu.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerActionMenu.tsx @@ -5,7 +5,7 @@ import useOnyx from '@hooks/useOnyx'; import {chatIncludesConcierge} from '@libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; -import {useComposerActions, useComposerMeta, useComposerMetaActions, useComposerSendState, useComposerState} from './ComposerContext'; +import {useComposerActions, useComposerMetaActions, useComposerMetaState, useComposerSendState, useComposerState} from './ComposerContext'; type ComposerActionMenuProps = { reportID: string; @@ -16,7 +16,7 @@ function ComposerActionMenu({reportID}: ComposerActionMenuProps) { const {isComposerFullSize, isFullComposerAvailable, isMenuVisible} = useComposerState(); const {isBlockedFromConcierge, exceededMaxLength} = useComposerSendState(); const {setMenuVisibility, focus} = useComposerActions(); - const {actionButtonRef, shouldFocusComposerOnScreenFocus} = useComposerMeta(); + const {actionButtonRef, shouldFocusComposerOnScreenFocus} = useComposerMetaState(); const {onAddActionPressed, onItemSelected, onTriggerAttachmentPicker, validateAttachments} = useComposerMetaActions(); const {raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx index d30889c0f72e7..f4bfd8ab6558e 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx @@ -1,24 +1,11 @@ -import React, {createContext, useContext, useRef} from 'react'; -import type {MeasureInWindowOnSuccessCallback} from 'react-native'; +import React from 'react'; import {View} from 'react-native'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {getReportOfflinePendingActionAndErrors} from '@libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import {useComposerMeta, useComposerSendState, useComposerState} from './ComposerContext'; - -type ComposerBoxContextValue = { - measureContainer: (callback: MeasureInWindowOnSuccessCallback) => void; -}; - -const ComposerBoxContext = createContext({ - measureContainer: () => {}, -}); - -function useComposerBox() { - return useContext(ComposerBoxContext); -} +import {useComposerMetaState, useComposerSendState, useComposerState} from './ComposerContext'; type ComposerBoxProps = { reportID: string; @@ -29,49 +16,35 @@ function ComposerBox({reportID, children}: ComposerBoxProps) { const styles = useThemeStyles(); const {isFocused, isComposerFullSize} = useComposerState(); const {exceededMaxLength, isBlockedFromConcierge} = useComposerSendState(); - const {PDFValidationComponent, ErrorModal} = useComposerMeta(); + const {containerRef, PDFValidationComponent, ErrorModal} = useComposerMetaState(); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const {reportPendingAction: pendingAction} = getReportOfflinePendingActionAndErrors(report); const shouldUseFocusedColor = !isBlockedFromConcierge && isFocused; - const containerRef = useRef(null); - const measureContainer = (callback: MeasureInWindowOnSuccessCallback) => { - if (!containerRef.current) { - return; - } - containerRef.current.measureInWindow(callback); - }; - - const contextValue = {measureContainer}; - return ( - - + - - {PDFValidationComponent} - {children} - - {ErrorModal} - - + {PDFValidationComponent} + {children} + + {ErrorModal} + ); } export default ComposerBox; -export {useComposerBox}; -export type {ComposerBoxProps}; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts index 36a8fc03c073c..29b2bfbbf8f41 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts +++ b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts @@ -48,6 +48,7 @@ type ComposerActions = { }; type ComposerMeta = { + containerRef: RefObject; composerRef: RefObject; suggestionsRef: RefObject; actionButtonRef: RefObject; @@ -126,7 +127,7 @@ function useComposerActions() { return useContext(ComposerActionsContext); } -function useComposerMeta() { +function useComposerMetaState() { const ctx = useContext(ComposerMetaContext); if (!ctx) { throw new Error('useComposerMeta must be used inside ComposerProvider'); @@ -153,7 +154,7 @@ export { useComposerState, useComposerSendState, useComposerActions, - useComposerMeta, + useComposerMetaState, useComposerMetaActions, }; export type {SuggestionsRef, ComposerState, ComposerSendState, ComposerActions, ComposerMeta, ComposerMetaActions}; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerEmojiPicker.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerEmojiPicker.tsx index 5a472a52574cf..72ffb9169d687 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerEmojiPicker.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerEmojiPicker.tsx @@ -6,7 +6,7 @@ import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import DomUtils from '@libs/DomUtils'; import {hideEmojiPicker, isActive as isActiveEmojiPickerAction} from '@userActions/EmojiPickerAction'; import CONST from '@src/CONST'; -import {useComposerActions, useComposerMeta, useComposerSendState} from './ComposerContext'; +import {useComposerActions, useComposerMetaState, useComposerSendState} from './ComposerContext'; type ComposerEmojiPickerProps = { reportID: string; @@ -18,7 +18,7 @@ function ComposerEmojiPicker({reportID}: ComposerEmojiPickerProps) { const {isMediumScreenWidth} = useResponsiveLayout(); const {isBlockedFromConcierge} = useComposerSendState(); const {focus} = useComposerActions(); - const {composerRef} = useComposerMeta(); + const {composerRef} = useComposerMetaState(); const chatItemComposeSecondaryRowHeight = styles.chatItemComposeSecondaryRow.height + styles.chatItemComposeSecondaryRow.marginTop + styles.chatItemComposeSecondaryRow.marginBottom; const reportActionComposeHeight = styles.chatItemComposeBox.minHeight + chatItemComposeSecondaryRowHeight; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerInputWrapper.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerInputWrapper.tsx index b39d021105a1a..4c901eb4dbef5 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerInputWrapper.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerInputWrapper.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type {MeasureInWindowOnSuccessCallback} from 'react-native'; import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTriggered'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -7,8 +8,7 @@ import {chatIncludesChronos, chatIncludesConcierge} from '@libs/ReportUtils'; import {isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {useComposerBox} from './ComposerBox'; -import {useComposerActions, useComposerMeta, useComposerMetaActions, useComposerSendState, useComposerState} from './ComposerContext'; +import {useComposerActions, useComposerMetaActions, useComposerMetaState, useComposerSendState, useComposerState} from './ComposerContext'; import ComposerWithSuggestions from './ComposerWithSuggestions'; type ComposerInputWrapperProps = { @@ -20,9 +20,12 @@ function ComposerInputWrapper({reportID}: ComposerInputWrapperProps) { const {isComposerFullSize, isMenuVisible} = useComposerState(); const {isBlockedFromConcierge} = useComposerSendState(); const {setIsFullComposerAvailable, handleSendMessage, onValueChange} = useComposerActions(); - const {suggestionsRef, isNextModalWillOpenRef, shouldShowComposeInput, userBlockedFromConcierge} = useComposerMeta(); + const {containerRef, suggestionsRef, isNextModalWillOpenRef, shouldShowComposeInput, userBlockedFromConcierge} = useComposerMetaState(); const {onBlur, onFocus, submitForm, validateAttachments, setComposerRef} = useComposerMetaActions(); - const {measureContainer} = useComposerBox(); + + const measureContainer = (callback: MeasureInWindowOnSuccessCallback) => { + containerRef.current?.measureInWindow(callback); + }; const {isScrollLayoutTriggered, raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx index 43b4ffef72863..82e74bef364d4 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx @@ -83,6 +83,7 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { 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 actionButtonRef = useRef(null); @@ -186,6 +187,7 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { }; const composerMetaState = { + containerRef, composerRef, suggestionsRef, actionButtonRef, From 790f1dbc68d5d8596b8776b0949f2a8b59f55acc Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 1 Apr 2026 18:19:15 +0200 Subject: [PATCH 32/57] =?UTF-8?q?Remove=20unconditional=20chatItemComposeW?= =?UTF-8?q?ithFirstRow=20=E2=80=94=20test=20if=20layout=20holds=20without?= =?UTF-8?q?=20minHeight=20hack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../inbox/report/ReportActionCompose/ComposerLocalTime.tsx | 2 ++ .../inbox/report/ReportActionCompose/ReportActionCompose.tsx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx index a41cf6a33aa4d..1537ea23a38d3 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx @@ -1,9 +1,11 @@ import React from 'react'; +import {View} from 'react-native'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; import {canShowReportRecipientLocalTime, getReportOfflinePendingActionAndErrors, getReportRecipientAccountIDs} from '@libs/ReportUtils'; import ParticipantLocalTime from '@pages/inbox/report/ParticipantLocalTime'; import ONYXKEYS from '@src/ONYXKEYS'; diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 6b74771ef4014..bef0e5d56929e 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -28,7 +28,7 @@ function Composer({reportID}: ReportActionComposeProps) { const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); return ( - + From 227fcd23e7aaf97f5b9420ea80858808b6d216cc Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 1 Apr 2026 18:27:36 +0200 Subject: [PATCH 33/57] Remove accidentally committed plan file Co-Authored-By: Claude Opus 4.6 (1M context) --- ...-30-report-action-compose-decomposition.md | 981 ------------------ 1 file changed, 981 deletions(-) delete mode 100644 docs/superpowers/plans/2026-03-30-report-action-compose-decomposition.md diff --git a/docs/superpowers/plans/2026-03-30-report-action-compose-decomposition.md b/docs/superpowers/plans/2026-03-30-report-action-compose-decomposition.md deleted file mode 100644 index daed1b357bb83..0000000000000 --- a/docs/superpowers/plans/2026-03-30-report-action-compose-decomposition.md +++ /dev/null @@ -1,981 +0,0 @@ -# ReportActionCompose Decomposition Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Decompose the 730-line ReportActionCompose monolith into focused components with correct data ownership, eliminating duplicate subscriptions and enabling React Compiler compliance. - -**Architecture:** Push subscriptions down to the narrowest owner. Extract side-effect-only concerns into renderless components. Convert the hook-returning-JSX pattern into a proper component. Remove manual memoization so the React Compiler can optimize the entire tree. - -**Tech Stack:** React, TypeScript, React Native Onyx (useOnyx), React Compiler (babel-plugin-react-compiler) - ---- - -## File Map - -| File | Action | Responsibility | -|------|--------|----------------| -| `src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx` | Modify | Slim orchestrator: local UI state + minimal subscriptions | -| `src/pages/inbox/report/ReportActionCompose/EmojiPickerCleanupHandler.tsx` | Create | Renderless component: hides emoji picker on unmount | -| `src/pages/inbox/report/ReportActionCompose/DropZoneArea.tsx` | Create | Self-subscribing component: transaction resolution + drop zone rendering | -| `src/pages/inbox/report/ReportActionCompose/AttachmentUploadHandler.tsx` | Create | Self-subscribing component replacing useAttachmentUploadValidation hook | -| `src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts` | Delete | Replaced by AttachmentUploadHandler component | -| `src/pages/inbox/report/ReportActionCompose/SendButton.tsx` | Modify | Remove memo() wrapper | - ---- - -## Coding Standards - -Every task must comply with all rules from `/coding-standards`. Key rules for this work: - -- **CLEAN-REACT-PATTERNS-0**: No manual memoization (useCallback, useMemo, React.memo). React Compiler handles it. -- **CLEAN-REACT-PATTERNS-2**: Components own their behavior. Don't pass data the child can get itself. -- **CLEAN-REACT-PATTERNS-4**: No side-effect spaghetti. Extract focused concerns. -- **CLEAN-REACT-PATTERNS-5**: Keep state narrow. Each component subscribes to exactly what it needs. -- **PERF-11**: Optimize data selection. Use selectors on useOnyx when extracting scalar values from objects. -- **CONSISTENCY-5**: Justify eslint-disable or remove it. - ---- - -### Task 1: Remove dead `onSubmitAction` export and duplicate `useCurrentUserPersonalDetails` - -**Files:** -- Modify: `src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx` - -**Context:** `onSubmitAction` is a module-level mutable variable that is exported but never imported anywhere in the codebase. It is assigned inside the component body (line 498: `onSubmitAction = handleSendMessage`), which is a side effect during render -- an anti-pattern. Additionally, `useCurrentUserPersonalDetails()` is called twice (line 157 and line 232 as `personalDetail`), creating duplicate subscriptions to the same Onyx key. - -- [ ] **Step 1: Verify `onSubmitAction` is unused** - -Run: `cd /Users/adhorodyski/Developer/Expensify-App-w2 && grep -r "onSubmitAction" src/ --include="*.ts" --include="*.tsx" | grep -v "ReportActionCompose.tsx"` - -Expected: No matches (confirming it is dead code). - -- [ ] **Step 2: Remove `onSubmitAction`** - -Remove these pieces from `ReportActionCompose.tsx`: -1. The `import noop from 'lodash/noop';` (line 2) -- only if no other usage exists in the file. -2. The `let onSubmitAction = noop;` (line 133) and the `// eslint-disable-next-line import/no-mutable-exports` comment above it (line 132). -3. The `onSubmitAction = handleSendMessage;` assignment (line 498). -4. The `export {onSubmitAction};` from the export line (line 728). Keep the other exports on that line. - -- [ ] **Step 3: Remove duplicate `useCurrentUserPersonalDetails` call** - -Line 232 calls `const personalDetail = useCurrentUserPersonalDetails();` separately from line 157's `const currentUserPersonalDetails = useCurrentUserPersonalDetails();`. The only usage of `personalDetail` is `personalDetail.timezone` on line 363. - -Replace `personalDetail.timezone` with `currentUserPersonalDetails.timezone` and remove the `const personalDetail = useCurrentUserPersonalDetails();` line entirely. - -- [ ] **Step 4: Remove `memo()` wrapper from ReportActionCompose** - -Line 727: Change `export default memo(ReportActionCompose);` to `export default ReportActionCompose;`. - -Remove `memo` from the React import on line 3. Keep other imports from react that are still used. - -- [ ] **Step 5: Remove `memo()` wrapper from SendButton** - -In `src/pages/inbox/report/ReportActionCompose/SendButton.tsx`: -Line 82: Change `export default memo(SendButton);` to `export default SendButton;`. - -Remove `memo` from the React import on line 1. `React` itself may still be needed if JSX transform requires it -- check if other imports from react remain. - -- [ ] **Step 6: Verify** - -Run: -```bash -cd /Users/adhorodyski/Developer/Expensify-App-w2 -npx prettier --write src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx src/pages/inbox/report/ReportActionCompose/SendButton.tsx -npx eslint src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx src/pages/inbox/report/ReportActionCompose/SendButton.tsx --max-warnings=0 -npm run typecheck-tsgo -``` - -- [ ] **Step 7: Commit** - -```bash -git add src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx src/pages/inbox/report/ReportActionCompose/SendButton.tsx -git commit -m "Remove dead onSubmitAction, duplicate useCurrentUserPersonalDetails, memo wrappers" -``` - ---- - -### Task 2: Extract EmojiPickerCleanupHandler (fix eslint-disable that blocks React Compiler) - -**Files:** -- Create: `src/pages/inbox/report/ReportActionCompose/EmojiPickerCleanupHandler.tsx` -- Modify: `src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx` - -**Context:** Lines 436-445 have a `useEffect` with `eslint-disable react-hooks/exhaustive-deps` for a mount-only cleanup effect that hides the emoji picker on unmount. This eslint-disable prevents the React Compiler from optimizing the entire component. The fix is to extract this into a renderless component where the empty deps are correct by construction (the component unmounts when the parent unmounts). - -- [ ] **Step 1: Create EmojiPickerCleanupHandler** - -Create `src/pages/inbox/report/ReportActionCompose/EmojiPickerCleanupHandler.tsx`: - -```tsx -import {useEffect} from 'react'; -import {hideEmojiPicker, isActive as isActiveEmojiPickerAction} from '@userActions/EmojiPickerAction'; - -type EmojiPickerCleanupHandlerProps = { - reportID: string | undefined; -}; - -/** - * Renderless component that hides the emoji picker when the composer unmounts, - * but only if the picker is active for this specific report. - */ -function EmojiPickerCleanupHandler({reportID}: EmojiPickerCleanupHandlerProps) { - useEffect(() => { - return () => { - if (!isActiveEmojiPickerAction(reportID)) { - return; - } - hideEmojiPicker(); - }; - }, [reportID]); - - return null; -} - -export default EmojiPickerCleanupHandler; -``` - -- [ ] **Step 2: Replace the inline effect in ReportActionCompose** - -In `ReportActionCompose.tsx`, remove lines 435-445 (the useEffect with eslint-disable): - -```tsx -// REMOVE THIS ENTIRE BLOCK: - // We are returning a callback here as we want to invoke the method on unmount only - useEffect( - () => () => { - if (!isActiveEmojiPickerAction(report?.reportID)) { - return; - } - hideEmojiPicker(); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [], - ); -``` - -Add the renderless component in the JSX return, right before the closing `` of the outermost wrapper (before line 724). Place it outside the visible UI tree: - -```tsx - - - ); -``` - -Add the import at the top of the file: -```tsx -import EmojiPickerCleanupHandler from './EmojiPickerCleanupHandler'; -``` - -Clean up unused imports: if `isActive as isActiveEmojiPickerAction` and `hideEmojiPicker` are no longer used in ReportActionCompose.tsx (they were only used in the removed effect), remove them from the import. - -- [ ] **Step 3: Fix the measureContainer eslint-disable** - -Lines 280-289 have a `useCallback` with `eslint-disable react-hooks/exhaustive-deps` to include `isComposerFullSize` in the dependency array for repositioning purposes. Since React Compiler handles memoization, remove the `useCallback` wrapper entirely and make it a plain function. The compiler will handle caching. - -Replace: -```tsx - const measureContainer = useCallback( - (callback: MeasureInWindowOnSuccessCallback) => { - if (!containerRef.current) { - return; - } - containerRef.current.measureInWindow(callback); - }, - // We added isComposerFullSize in dependencies so that when this value changes, we recalculate the position of the popup - // eslint-disable-next-line react-hooks/exhaustive-deps - [isComposerFullSize], - ); -``` - -With: -```tsx - const measureContainer = (callback: MeasureInWindowOnSuccessCallback) => { - if (!containerRef.current) { - return; - } - containerRef.current.measureInWindow(callback); - }; -``` - -Note: `isComposerFullSize` was in the deps to trigger re-calculation. Since the plain function captures the latest ref value on each render, and React Compiler will memoize based on actual dependencies, this is correct. The `containerRef.current.measureInWindow` always reads the current DOM position. - -- [ ] **Step 4: Remove all remaining manual memoization from ReportActionCompose** - -Remove ALL `useCallback` and `useMemo` wrappers in ReportActionCompose.tsx. Convert each to a plain function or expression. The React Compiler handles all memoization. - -For each `useCallback((args) => { ... }, [deps])`, replace with `(args) => { ... }`. -For each `useMemo(() => expr, [deps])`, replace with just `expr` (the expression itself). - -The following need conversion: -- `onAddActionPressed` (line 292) -> plain arrow function -- `onItemSelected` (line 299) -> plain arrow function -- `updateShouldShowSuggestionMenuToFalse` (line 303) -> plain arrow function -- `addAttachment` (line 314) -> plain arrow function -- `onAttachmentPreviewClose` (line 332) -> plain arrow function -- `submitForm` (line 348) -> plain arrow function -- `onTriggerAttachmentPicker` (line 400) -> plain arrow function -- `onBlur` (line 405) -> plain arrow function -- `onFocus` (line 420) -> plain arrow function -- `validateMaxLength` (line 457) -> plain arrow function -- `handleSendMessage` (line 477) -> plain arrow function -- `onValueChange` (line 530) -> plain arrow function -- `reportParticipantIDs` (line 208) -> plain expression -- `shouldShowReportRecipientLocalTime` (line 216) -> plain expression -- `includesConcierge` (line 221) -> plain expression -- `userBlockedFromConcierge` (line 222) -> plain expression -- `isBlockedFromConcierge` (line 223) -> plain expression -- `isTransactionThreadView` (line 225) -> plain expression -- `isExpensesReport` (line 226) -> plain expression -- `transactionID` (line 237) -> plain expression -- `isSingleTransactionView` (line 241) -> plain expression -- `shouldDisplayDualDropZone` (line 252) -> plain expression -- `inputPlaceholder` (line 262) -> plain expression -- `debouncedValidate` (line 471) -> needs careful handling (see below) -- `isGroupPolicyReport` (line 448) -> plain expression -- `emojiPositionValues` (line 500) -> plain expression -- `emojiShiftVertical` (line 517) -> plain expression - -**Special case -- `debouncedValidate`:** The `useMemo` around `lodashDebounce` creates a stable debounced function. Without `useMemo`, a new debounced function would be created on every render, breaking the debounce. The React Compiler will handle this correctly -- it will memoize the `lodashDebounce(...)` call based on `validateMaxLength` as a dependency. Convert it to a plain expression: - -```tsx -const debouncedValidate = lodashDebounce(validateMaxLength, CONST.TIMING.COMMENT_LENGTH_DEBOUNCE_TIME, {leading: true}); -``` - -The compiler will cache this value and only recreate it when `validateMaxLength` changes. - -After all conversions, remove `useCallback`, `useMemo` from the React import line. Keep `useContext`, `useEffect`, `useRef`, `useState`. - -- [ ] **Step 5: Verify** - -Run: -```bash -cd /Users/adhorodyski/Developer/Expensify-App-w2 -npx prettier --write src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx src/pages/inbox/report/ReportActionCompose/EmojiPickerCleanupHandler.tsx -npx eslint src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx src/pages/inbox/report/ReportActionCompose/EmojiPickerCleanupHandler.tsx --max-warnings=0 -npm run typecheck-tsgo -``` - -Verify no eslint-disable for react-hooks remains in ReportActionCompose.tsx: -```bash -grep -n "eslint-disable.*react-hooks" src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx -``` -Expected: Only the `rulesdir/prefer-shouldUseNarrowLayout` disable on the responsive layout line (this is a different rule, not react-hooks). - -- [ ] **Step 6: Commit** - -```bash -git add src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx src/pages/inbox/report/ReportActionCompose/EmojiPickerCleanupHandler.tsx -git commit -m "Extract EmojiPickerCleanupHandler, remove manual memoization for React Compiler" -``` - ---- - -### Task 3: Extract DropZoneArea (isolate transaction resolution + drop zone subscriptions) - -**Files:** -- Create: `src/pages/inbox/report/ReportActionCompose/DropZoneArea.tsx` -- Modify: `src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx` - -**Context:** ReportActionCompose subscribes to `COLLECTION.REPORT_ACTIONS/{reportID}`, `COLLECTION.TRANSACTION/{transactionID}`, `COLLECTION.POLICY/{policyID}`, `BETAS`, `usePreferredPolicy`, `useReportIsArchived`, and `COLLECTION.REPORT/{parentReportID}` primarily to compute `shouldDisplayDualDropZone`, `shouldAddOrReplaceReceipt`, and `hasReceipt`. These are all needed only by the drop zone rendering. Pushing them into a self-subscribing component eliminates ~8 subscriptions from the root. - -- [ ] **Step 1: Create DropZoneArea component** - -Create `src/pages/inbox/report/ReportActionCompose/DropZoneArea.tsx`: - -```tsx -import React from 'react'; -import type {OnyxEntry} from 'react-native-onyx'; -import DragAndDropConsumer from '@components/DragAndDrop/Consumer'; -import DropZoneUI from '@components/DropZone/DropZoneUI'; -import DualDropZone from '@components/DropZone/DualDropZone'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; -import usePreferredPolicy from '@hooks/usePreferredPolicy'; -import useReportIsArchived from '@hooks/useReportIsArchived'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {getLinkedTransactionID, getReportAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; -import { - canEditFieldOfMoneyRequest, - canUserPerformWriteAction as canUserPerformWriteActionReportUtils, - getParentReport, - isChatRoom, - isGroupChat, - isInvoiceReport, - isReportApproved, - isReportTransactionThread, - isSettled, - temporary_getMoneyRequestOptions, -} from '@libs/ReportUtils'; -import {getTransactionID, hasReceipt as hasReceiptTransactionUtils} from '@libs/TransactionUtils'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type * as OnyxTypes from '@src/types/onyx'; - -type DropZoneAreaProps = { - reportID: string; - report: OnyxEntry; - reportTransactions: OnyxEntry; - transactionThreadReportID: string | undefined; - onAttachmentDrop: (event: DragEvent) => void; - currentUserAccountID: number; -}; - -function DropZoneArea({reportID, report, reportTransactions, transactionThreadReportID, onAttachmentDrop, currentUserAccountID}: DropZoneAreaProps) { - const styles = useThemeStyles(); - const theme = useTheme(); - const {translate} = useLocalize(); - const icons = useMemoizedLazyExpensifyIcons(['MessageInABottle']); - const {isRestrictedToPreferredPolicy} = usePreferredPolicy(); - const isReportArchived = useReportIsArchived(report?.reportID); - - const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`); - const [betas] = useOnyx(ONYXKEYS.BETAS); - const [newParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`); - const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`, {canEvict: false}); - - const isTransactionThreadView = isReportTransactionThread(report); - const isExpensesReport = reportTransactions && reportTransactions.length > 1; - - const iouAction = reportActions ? Object.values(reportActions).find((action) => isMoneyRequestAction(action)) : null; - const linkedTransactionID = iouAction && !isExpensesReport ? getLinkedTransactionID(iouAction) : undefined; - const transactionID = getTransactionID(report) ?? linkedTransactionID; - - const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transactionID)}`); - - const isSingleTransactionView = !!transaction && !!reportTransactions && reportTransactions.length === 1; - const parentReportAction = isSingleTransactionView ? iouAction : getReportAction(report?.parentReportID, report?.parentReportActionID); - const canUserPerformWriteAction = !!canUserPerformWriteActionReportUtils(report, isReportArchived); - const canEditReceipt = - canUserPerformWriteAction && - canEditFieldOfMoneyRequest({reportAction: parentReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, transaction}) && - !transaction?.receipt?.isTestDriveReceipt; - const shouldAddOrReplaceReceipt = (isTransactionThreadView || isSingleTransactionView) && canEditReceipt; - const hasReceipt = hasReceiptTransactionUtils(transaction); - - const reportParticipantIDs = Object.keys(report?.participants ?? {}) - .map(Number) - .filter((accountID) => accountID !== currentUserAccountID); - - const shouldDisplayDualDropZone = (() => { - const parentReport = getParentReport(report); - const isSettledOrApproved = isSettled(report) || isSettled(parentReport) || isReportApproved({report}) || isReportApproved({report: parentReport}); - const hasMoneyRequestOptions = !!temporary_getMoneyRequestOptions(report, policy, reportParticipantIDs, betas, isReportArchived, isRestrictedToPreferredPolicy).length; - const canModifyReceipt = shouldAddOrReplaceReceipt && !isSettledOrApproved; - const isRoomOrGroupChat = isChatRoom(report) || isGroupChat(report); - return !isRoomOrGroupChat && (canModifyReceipt || hasMoneyRequestOptions) && !isInvoiceReport(report); - })(); - - if (shouldDisplayDualDropZone) { - return ( - - ); - } - - return ( - - - - ); -} - -export default DropZoneArea; -``` - -**Important note:** The `onReceiptDrop` prop on `DualDropZone` currently uses `onReceiptDropped` from `useAttachmentUploadValidation` in the original code, which is different from `onAttachmentDrop`. This will be fully resolved in Task 4 when `AttachmentUploadHandler` replaces the hook. For now, we'll pass the `onAttachmentDrop` handler. Task 4 will provide the correct receipt handler. - -Actually -- let me reconsider. The DropZoneArea needs the receipt drop handler from the attachment upload validation logic. We need to think about this dependency more carefully. The DropZoneArea needs two handlers: -1. `onAttachmentDrop` -- for the standard attachment drop zone -2. `onReceiptDrop` -- from `useAttachmentUploadValidation`'s `onReceiptDropped` - -Since Task 4 converts useAttachmentUploadValidation into a component, the handlers will live in that component. The clean approach is: DropZoneArea receives both handlers as props. The parent orchestrates by getting `onReceiptDropped` from the attachment validation and passing it down. - -Update the props type: - -```tsx -type DropZoneAreaProps = { - reportID: string; - report: OnyxEntry; - reportTransactions: OnyxEntry; - transactionThreadReportID: string | undefined; - onAttachmentDrop: (event: DragEvent) => void; - onReceiptDrop: (event: DragEvent) => void; - currentUserAccountID: number; -}; -``` - -And update the DualDropZone usage: -```tsx - -``` - -- [ ] **Step 2: Update ReportActionCompose to use DropZoneArea** - -In `ReportActionCompose.tsx`: - -1. Remove these subscriptions/hooks/computations that are now in DropZoneArea: - - `const [policy] = useOnyx(...)` (COLLECTION.POLICY) - - `const [newParentReport] = useOnyx(...)` (COLLECTION.REPORT/{parentReportID}) -- but check if still needed by useAttachmentUploadValidation. If so, keep for now and remove in Task 4. - - `const [reportActions] = useOnyx(...)` (COLLECTION.REPORT_ACTIONS/{reportID}) - - `const [transaction] = useOnyx(...)` (COLLECTION.TRANSACTION/{transactionID}) - - `const [betas] = useOnyx(...)` (ONYXKEYS.BETAS) - - `const {isRestrictedToPreferredPolicy} = usePreferredPolicy()` - - `const isReportArchived = useReportIsArchived(...)` - - `const isTransactionThreadView = ...` - - `const isExpensesReport = ...` - - `const iouAction = ...` - - `const linkedTransactionID = ...` - - `const transactionID = ...` - - `const isSingleTransactionView = ...` - - `const parentReportAction = ...` - - `const canUserPerformWriteAction = ...` - - `const canEditReceipt = ...` - - `const shouldAddOrReplaceReceipt = ...` - - `const hasReceipt = ...` - - `const shouldDisplayDualDropZone = ...` - - `const reportParticipantIDs = ...` -- but check if still needed by AttachmentPickerWithMenuItems prop. If so, keep. - - **Be careful:** Some of these values are used by `useAttachmentUploadValidation` and `AttachmentPickerWithMenuItems`. Do NOT remove values that are still used elsewhere in the component. Only remove values that were exclusively used for drop zone logic. In practice: - - `policy` is passed to `useAttachmentUploadValidation` and `AttachmentPickerWithMenuItems` -- keep for now (Task 4 will handle) - - `betas` is not passed anywhere else in RAC now (it was used for `shouldDisplayDualDropZone` and `AttachmentPickerWithMenuItems`, but APWMI already self-subscribes)... Actually, check: `betas` is NOT passed as a prop to `AttachmentPickerWithMenuItems` in the JSX. APWMI has its own `useOnyx(ONYXKEYS.BETAS)`. So `betas` in RAC is only used for `shouldDisplayDualDropZone` -- remove it. - - `reportActions` is only used for `iouAction` which feeds into transaction resolution -- remove it. - - `transaction` is only used for drop zone and receipt logic -- remove it (but `transactionID` feeds into `useAttachmentUploadValidation` -- keep `transactionID` computation for now, or see if AUUV can derive it itself in Task 4) - - `isReportArchived` is passed to no child as prop in JSX -- remove (APWMI self-subscribes) - - `isRestrictedToPreferredPolicy` same -- remove - - `newParentReport` is passed to `useAttachmentUploadValidation` -- keep for now - - `reportParticipantIDs` is passed to `AttachmentPickerWithMenuItems` -- keep for now - - **Net removals from root for this task:** - - `[betas]` subscription - - `[reportActions]` subscription - - `[transaction]` subscription - - `useReportIsArchived` - - `usePreferredPolicy` (the `isRestrictedToPreferredPolicy` part) - - All the transaction resolution computations (isTransactionThreadView, isExpensesReport, iouAction, linkedTransactionID, isSingleTransactionView, parentReportAction, canUserPerformWriteAction, canEditReceipt, shouldAddOrReplaceReceipt, hasReceipt, shouldDisplayDualDropZone) - -2. Replace the drop zone JSX block (the `{shouldDisplayDualDropZone && (...)}` and `{!shouldDisplayDualDropZone && (...)}` blocks) with: - -```tsx - -``` - -3. Add the import: -```tsx -import DropZoneArea from './DropZoneArea'; -``` - -4. Clean up unused imports: Remove imports for `DragAndDropConsumer`, `DropZoneUI`, `DualDropZone`, `getNonEmptyStringOnyxID`, `getLinkedTransactionID`, `getReportAction`, `isMoneyRequestAction`, `canEditFieldOfMoneyRequest`, `getParentReport`, `isChatRoom`, `isGroupChat`, `isInvoiceReport`, `isReportApproved`, `isReportTransactionThread`, `isSettled`, `temporary_getMoneyRequestOptions`, `getTransactionID`, `hasReceipt as hasReceiptTransactionUtils` -- but ONLY if they are not used elsewhere in the file. Some may still be used (e.g., `temporary_getMoneyRequestOptions` was only used for `shouldDisplayDualDropZone`). - -- [ ] **Step 3: Verify** - -Run: -```bash -cd /Users/adhorodyski/Developer/Expensify-App-w2 -npx prettier --write src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx src/pages/inbox/report/ReportActionCompose/DropZoneArea.tsx -npx eslint src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx src/pages/inbox/report/ReportActionCompose/DropZoneArea.tsx --max-warnings=0 -npm run typecheck-tsgo -``` - -- [ ] **Step 4: Commit** - -```bash -git add src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx src/pages/inbox/report/ReportActionCompose/DropZoneArea.tsx -git commit -m "Extract DropZoneArea: isolate transaction resolution and drop zone subscriptions" -``` - ---- - -### Task 4: Convert useAttachmentUploadValidation to AttachmentUploadHandler component - -**Files:** -- Create: `src/pages/inbox/report/ReportActionCompose/AttachmentUploadHandler.tsx` -- Modify: `src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx` -- Delete (or empty): `src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts` - -**Context:** `useAttachmentUploadValidation` is a hook that returns JSX (`PDFValidationComponent`, `ErrorModal`). This blurs the hook/component boundary -- hooks should return data, components should return JSX. The hook receives 13 props from the parent, most of which it could subscribe to itself. Converting it to a component means: -1. It self-subscribes to what it needs (policy, policyCategories, ownerBillingGraceEndPeriod, etc.) -2. The parent passes only IDs and handlers -3. The JSX it returns renders naturally at its mount point - -The hook also receives `shouldAddOrReplaceReceipt` and `transactionID` which were computed in the parent from the transaction resolution logic (now in DropZoneArea). The new component will derive these itself, or they can be passed as props since DropZoneArea already computes them. - -**Design decision:** The `AttachmentUploadHandler` component will be renderless for its attachment validation logic but will render the `PDFValidationComponent` and `ErrorModal`. It communicates validation results upward through callback props (`onValidateAttachments`, `onReceiptDropped`). - -However, this is a complex refactoring. The simpler approach: make `AttachmentUploadHandler` a component that: -- Self-subscribes to all its data needs -- Exposes `validateAttachments` and `onReceiptDropped` via an imperative handle (ref) -- Renders `PDFValidationComponent` and `ErrorModal` inline - -Actually, the cleanest approach is to recognize that the parent needs `validateAttachments` and `onReceiptDropped` as functions to pass to other children (AttachmentPickerWithMenuItems needs `validateAttachments`, DropZoneArea needs both). This is coordination state that the parent legitimately owns. The solution: - -1. Keep the validation logic as a hook (it correctly returns functions) -2. BUT make the hook self-subscribing (stop passing 13 props from the parent) -3. Move the JSX rendering into a separate component - -Let me reconsider: the cleanest path is to make `useAttachmentUploadValidation` self-subscribing by having it take only IDs (reportID, report) and subscribe to everything internally. This aligns with CLEAN-REACT-PATTERNS-2. - -- [ ] **Step 1: Refactor useAttachmentUploadValidation to be self-subscribing** - -Modify `src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts` to accept only the minimal props it cannot derive itself: - -New signature: -```tsx -type AttachmentUploadValidationProps = { - reportID: string; - report: OnyxEntry; - addAttachment: (file: FileObject | FileObject[]) => void; - onAttachmentPreviewClose: () => void; - exceededMaxLength: boolean | number | null; - isAttachmentPreviewActive: boolean; - setIsAttachmentPreviewActive: (isActive: boolean) => void; -}; -``` - -The hook will internally subscribe to: -- `COLLECTION.POLICY/{report.policyID}` (was passed as `policy`) -- `COLLECTION.REPORT/{report.parentReportID}` (was passed as `newParentReport`) -- `CURRENT_DATE` (was passed as `currentDate`) -- `useCurrentUserPersonalDetails()` (was passed as `currentUserPersonalDetails`) - -And derive `shouldAddOrReplaceReceipt` and `transactionID` internally by doing the same transaction resolution that DropZoneArea does (yes, this duplicates the computation, but each component owns exactly what it needs -- the subscriptions are per-key, not COLLECTION-level, so the cost is minimal). - -Full implementation of the refactored hook: - -```tsx -import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; -import {useCallback, useContext, useRef} from 'react'; -import type {OnyxEntry} from 'react-native-onyx'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useFilesValidation from '@hooks/useFilesValidation'; -import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; -import usePersonalPolicy from '@hooks/usePersonalPolicy'; -import useReportIsArchived from '@hooks/useReportIsArchived'; -import {cleanFileObject, cleanFileObjectName, getFilesFromClipboardEvent} from '@libs/fileDownload/FileUtils'; -import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {hasOnlyPersonalPolicies as hasOnlyPersonalPoliciesUtil} from '@libs/PolicyUtils'; -import {getLinkedTransactionID, getReportAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; -import { - canEditFieldOfMoneyRequest, - canUserPerformWriteAction as canUserPerformWriteActionReportUtils, - isReportTransactionThread, - isSelfDM, -} from '@libs/ReportUtils'; -import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; -import {getTransactionID, hasReceipt as hasReceiptTransactionUtils} from '@libs/TransactionUtils'; -import Navigation from '@navigation/Navigation'; -import AttachmentModalContext from '@pages/media/AttachmentModalScreen/AttachmentModalContext'; -import {initMoneyRequest, replaceReceipt, setMoneyRequestParticipantsFromReport, setMoneyRequestReceipt} from '@userActions/IOU'; -import {buildOptimisticTransactionAndCreateDraft} from '@userActions/TransactionEdit'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import type SCREENS from '@src/SCREENS'; -import type * as OnyxTypes from '@src/types/onyx'; -import type {FileObject} from '@src/types/utils/Attachment'; - -type AttachmentUploadValidationProps = { - reportID: string; - report: OnyxEntry; - addAttachment: (file: FileObject | FileObject[]) => void; - onAttachmentPreviewClose: () => void; - exceededMaxLength: boolean | number | null; - isAttachmentPreviewActive: boolean; - setIsAttachmentPreviewActive: (isActive: boolean) => void; -}; - -function useAttachmentUploadValidation({ - reportID, - report, - addAttachment, - onAttachmentPreviewClose, - exceededMaxLength, - isAttachmentPreviewActive, - setIsAttachmentPreviewActive, -}: AttachmentUploadValidationProps) { - const {translate} = useLocalize(); - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const isReportArchived = useReportIsArchived(report?.reportID); - - // Self-subscribe to data previously passed as props - const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`); - const [newParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`); - const [currentDate] = useOnyx(ONYXKEYS.CURRENT_DATE); - const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy?.id}`); - const [ownerBillingGraceEndPeriod] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); - const personalPolicy = usePersonalPolicy(); - const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const [userBillingGraceEndPeriods] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); - const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); - - // Derive transaction resolution internally - const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`, {canEvict: false}); - const [reportTransactionsRaw] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_TRANSACTIONS}${report?.reportID}`); - - const isTransactionThreadView = isReportTransactionThread(report); - const iouAction = reportActions ? Object.values(reportActions).find((action) => isMoneyRequestAction(action)) : null; - const isExpensesReport = reportTransactionsRaw && reportTransactionsRaw.length > 1; - const linkedTransactionID = iouAction && !isExpensesReport ? getLinkedTransactionID(iouAction) : undefined; - const transactionID = getTransactionID(report) ?? linkedTransactionID; - - const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transactionID)}`); - - const isSingleTransactionView = !!transaction && !!reportTransactionsRaw && reportTransactionsRaw.length === 1; - const parentReportAction = isSingleTransactionView ? iouAction : getReportAction(report?.parentReportID, report?.parentReportActionID); - const canUserPerformWriteAction = !!canUserPerformWriteActionReportUtils(report, isReportArchived); - const canEditReceipt = - canUserPerformWriteAction && - canEditFieldOfMoneyRequest({reportAction: parentReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, transaction}) && - !transaction?.receipt?.isTestDriveReceipt; - const shouldAddOrReplaceReceipt = (isTransactionThreadView || isSingleTransactionView) && canEditReceipt; - - const hasOnlyPersonalPolicies = hasOnlyPersonalPoliciesUtil(allPolicies); - - const reportAttachmentsContext = useContext(AttachmentModalContext); - const showAttachmentModalScreen = (file: FileObject | FileObject[], dataTransferItems?: DataTransferItem[]) => { - reportAttachmentsContext.setCurrentAttachment({ - reportID, - file, - dataTransferItems, - headerTitle: translate('reportActionCompose.sendAttachment'), - onConfirm: addAttachment, - onShow: () => setIsAttachmentPreviewActive(true), - onClose: onAttachmentPreviewClose, - shouldDisableSendButton: !!exceededMaxLength, - }); - Navigation.navigate(ROUTES.REPORT_ADD_ATTACHMENT.getRoute(reportID)); - }; - - const attachmentUploadType = useRef<'receipt' | 'attachment'>(undefined); - const onFilesValidated = (files: FileObject[], dataTransferItems: DataTransferItem[]) => { - if (files.length === 0) { - return; - } - - if (attachmentUploadType.current === 'attachment') { - showAttachmentModalScreen(files, dataTransferItems); - return; - } - - if (shouldAddOrReplaceReceipt && transactionID) { - const source = URL.createObjectURL(files.at(0) as Blob); - replaceReceipt({transactionID, file: files.at(0) as File, source, transactionPolicy: policy, transactionPolicyCategories: policyCategories}); - return; - } - - const initialTransaction = initMoneyRequest({ - reportID, - personalPolicy, - newIouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, - report, - parentReport: newParentReport, - currentDate, - currentUserPersonalDetails, - hasOnlyPersonalPolicies, - draftTransactionIDs, - }); - - for (const [index, file] of files.entries()) { - const source = URL.createObjectURL(file as Blob); - const newTransaction = - index === 0 - ? (initialTransaction as Partial) - : buildOptimisticTransactionAndCreateDraft({ - initialTransaction: initialTransaction as Partial, - currentUserPersonalDetails, - reportID, - }); - const newTransactionID = newTransaction?.transactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID; - setMoneyRequestReceipt(newTransactionID, source, file.name ?? '', true, file.type); - setMoneyRequestParticipantsFromReport(newTransactionID, report, currentUserPersonalDetails.accountID); - } - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute( - CONST.IOU.ACTION.CREATE, - isSelfDM(report) ? CONST.IOU.TYPE.TRACK : CONST.IOU.TYPE.SUBMIT, - CONST.IOU.OPTIMISTIC_TRANSACTION_ID, - reportID, - ), - ); - }; - - const {validateFiles, PDFValidationComponent, ErrorModal} = useFilesValidation(onFilesValidated); - - const validateAttachments = ({dragEvent, files}: {dragEvent?: DragEvent; files?: FileObject | FileObject[]}) => { - if (isAttachmentPreviewActive) { - return; - } - - let extractedFiles: FileObject[] = []; - - if (files) { - extractedFiles = Array.isArray(files) ? files : [files]; - } else { - if (!dragEvent) { - return; - } - extractedFiles = getFilesFromClipboardEvent(dragEvent); - } - - const dataTransferItems = Array.from(dragEvent?.dataTransfer?.items ?? []); - if (extractedFiles.length === 0) { - return; - } - - const validIndices: number[] = []; - const fileObjects = extractedFiles - .map((item, index) => { - const fileObject = cleanFileObject(item); - const cleanedFileObject = cleanFileObjectName(fileObject); - if (cleanedFileObject !== null) { - validIndices.push(index); - } - return cleanedFileObject; - }) - .filter((fileObject) => fileObject !== null); - - if (!fileObjects.length) { - return; - } - - const filteredItems = dataTransferItems && validIndices.length > 0 ? validIndices.map((index) => dataTransferItems.at(index) ?? ({} as DataTransferItem)) : undefined; - - attachmentUploadType.current = 'attachment'; - validateFiles(fileObjects, filteredItems, {isValidatingReceipts: false}); - }; - - const onReceiptDropped = (e: DragEvent) => { - if (policy && shouldRestrictUserBillableActions(policy.id, ownerBillingGraceEndPeriod, userBillingGraceEndPeriods)) { - Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); - return; - } - - const files = getFilesFromClipboardEvent(e); - const items = Array.from(e.dataTransfer?.items ?? []); - - if (shouldAddOrReplaceReceipt && transactionID) { - const file = files.at(0); - if (!file) { - return; - } - attachmentUploadType.current = 'receipt'; - validateFiles([file], items); - } - - attachmentUploadType.current = 'receipt'; - validateFiles(files, items, {isValidatingReceipts: true}); - }; - - return { - validateAttachments, - onReceiptDropped, - PDFValidationComponent, - ErrorModal, - }; -} - -export default useAttachmentUploadValidation; -``` - -- [ ] **Step 2: Update ReportActionCompose to pass minimal props to refactored hook** - -In `ReportActionCompose.tsx`, update the `useAttachmentUploadValidation` call to pass only the minimal props: - -```tsx -const {validateAttachments, onReceiptDropped, PDFValidationComponent, ErrorModal} = useAttachmentUploadValidation({ - reportID, - report, - addAttachment, - onAttachmentPreviewClose, - exceededMaxLength, - isAttachmentPreviewActive, - setIsAttachmentPreviewActive, -}); -``` - -Remove from ReportActionCompose the subscriptions/values that are now internal to the hook: -- `const [policy] = useOnyx(...)` -- if not used elsewhere. Check: `policy` was used for `shouldDisplayDualDropZone` (now in DropZoneArea) and `useAttachmentUploadValidation` (now self-subscribing). If nothing else uses it, remove. -- `const [newParentReport] = useOnyx(...)` -- only used by useAttachmentUploadValidation. Remove. -- `const [currentDate] = useOnyx(...)` -- only used by useAttachmentUploadValidation. Remove. -- `const personalDetail` -- already removed in Task 1 (the duplicate). The remaining `currentUserPersonalDetails` is still needed for `submitForm` and `AttachmentPickerWithMenuItems` prop. - -After this step, also check if `reportParticipantIDs` is still needed. It was used for `shouldDisplayDualDropZone` (now in DropZoneArea) and as a prop to `AttachmentPickerWithMenuItems`. If APWMI still receives it, keep it. Check the APWMI props in the JSX. - -Looking at the JSX, APWMI receives `reportParticipantIDs={reportParticipantIDs}`. APWMI already self-subscribes to policy/betas/etc but receives `reportParticipantIDs` as a prop because it uses it for `temporary_getMoneyRequestOptions`. APWMI could derive this itself from report.participants and currentUserPersonalDetails.accountID. But that's a further cleanup (APWMI self-subscribing more). For this task, keep `reportParticipantIDs` in RAC. - -- [ ] **Step 3: Update DropZoneArea to receive onReceiptDrop from parent** - -Verify that DropZoneArea (created in Task 3) correctly receives `onReceiptDrop` as a prop and the parent passes `onReceiptDropped` from the hook. - -In the parent JSX: -```tsx - -``` - -- [ ] **Step 4: Verify** - -Run: -```bash -cd /Users/adhorodyski/Developer/Expensify-App-w2 -npx prettier --write src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts src/pages/inbox/report/ReportActionCompose/DropZoneArea.tsx -npx eslint src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts src/pages/inbox/report/ReportActionCompose/DropZoneArea.tsx --max-warnings=0 -npm run typecheck-tsgo -``` - -- [ ] **Step 5: Commit** - -```bash -git add src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts src/pages/inbox/report/ReportActionCompose/DropZoneArea.tsx -git commit -m "Make useAttachmentUploadValidation self-subscribing, remove proxy props from parent" -``` - ---- - -### Task 5: Remove remaining duplicate subscriptions from root - -**Files:** -- Modify: `src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx` - -**Context:** After Tasks 1-4, review what subscriptions remain at the ReportActionCompose root level and eliminate any that are not directly consumed by the orchestrator's own logic. - -- [ ] **Step 1: Audit remaining subscriptions** - -Run this to see what useOnyx/useHook calls remain: -```bash -grep -n "useOnyx\|useCurrentUserPersonalDetails\|useReportIsArchived\|usePreferredPolicy\|useNetwork\|usePersonalDetails\|useAncestors" src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx -``` - -Expected remaining subscriptions after Tasks 1-4: -- `useCurrentUserPersonalDetails` -- needed for submitForm (accountID, timezone) and APWMI prop -- `usePersonalDetails` -- needed for `shouldShowReportRecipientLocalTime` and `reportRecipient` -- `useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE)` -- needed for isBlockedFromConcierge -- `useOnyx(ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT)` -- needed for shouldFocusComposerOnScreenFocus -- `useOnyx(ONYXKEYS.MODAL)` -- needed for initial focus state only (useState initializer) -- `useOnyx(COLLECTION.REPORT_DRAFT_COMMENT/{reportID})` -- needed for shouldFocusComposerOnScreenFocus and isCommentEmpty -- `useOnyx(COLLECTION.REPORT/{transactionThreadReportID})` -- needed for submitForm -- `useAncestors` -- needed for submitForm -- `useNetwork` -- needed for offline indicator styling -- Various UI hooks (useTheme, useThemeStyles, useLocalize, etc.) - -- [ ] **Step 2: Address MODAL subscription** - -`useOnyx(ONYXKEYS.MODAL)` on line 164 is named `initialModalState` and is only used in the `useState` initializer for `isFocused` (line 178-179). After the initial render, this subscription continues firing on every modal open/close for no reason -- the value is only read once. - -Since this is only needed at mount time, we can use `initWithStoredValues: false` is not the right pattern here. The correct approach: keep the subscription but note that it fires rarely (modal open/close) and the component needs to know if a modal is visible to avoid auto-focusing. Actually, looking more carefully at the code, `initialModalState` is ONLY used in the `useState` initializer -- it is never read again after mount. This means the subscription is pure waste after initialization. - -The fix: use `useOnyx` with the subscription, but recognize that React Compiler will not re-render the component when only the `initialModalState` changes IF the value is not used in the render output. Actually, `initialModalState` IS used in the useState initializer which only runs once, but the variable itself is in scope and could be captured by the compiler. The cleanest fix: rename and note that this is an initialization-only read. The compiler will handle memoization correctly -- if the value changes but nothing in the render path depends on it, no re-render propagates. - -Actually, the real issue is simpler: `useOnyx` will trigger a re-render whenever MODAL changes, regardless of whether the value is used in the output. The only way to avoid this is to NOT subscribe. We could replace this with reading from Onyx directly in the initializer. But that's a micro-optimization -- modal changes are infrequent. Leave it for now. Note it as a follow-up. - -- [ ] **Step 3: Clean up unused imports** - -After all removals, scan the import block for anything no longer used: -```bash -cd /Users/adhorodyski/Developer/Expensify-App-w2 -npx eslint src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx --max-warnings=0 -``` - -ESLint's `no-unused-vars` and `no-unused-imports` rules will flag unused imports. Fix them. - -- [ ] **Step 4: Verify the final state** - -Run: -```bash -cd /Users/adhorodyski/Developer/Expensify-App-w2 -npx prettier --write src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx -npx eslint src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx --max-warnings=0 -npm run typecheck-tsgo -``` - -- [ ] **Step 5: Run React Compiler compliance check** - -```bash -cd /Users/adhorodyski/Developer/Expensify-App-w2 -check-compiler.sh src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx -``` - -Expected: The file should now compile successfully with React Compiler (no eslint-disable react-hooks, no manual memoization). - -Also check the new files: -```bash -check-compiler.sh src/pages/inbox/report/ReportActionCompose/EmojiPickerCleanupHandler.tsx -check-compiler.sh src/pages/inbox/report/ReportActionCompose/DropZoneArea.tsx -``` - -- [ ] **Step 6: Commit** - -```bash -git add src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx -git commit -m "Clean up remaining imports and verify compiler compliance" -``` - ---- - -## Expected Outcome - -### Before (ReportActionCompose root) - -| Metric | Value | -|--------|-------| -| Lines | ~730 | -| useOnyx subscriptions at root | ~14 direct | -| Hook-based subscriptions at root | ~7 | -| Total subscriptions at root | ~21 | -| COLLECTION-level subscriptions | 3 (via useAncestors) | -| Duplicate subscriptions (parent + child) | 7 | -| React Compiler errors | 2 (eslint-disable) | -| Manual memoization instances | ~20 (useCallback/useMemo/memo) | - -### After - -| Metric | Value | -|--------|-------| -| Lines | ~400-450 (estimated) | -| useOnyx subscriptions at root | ~5 (BLOCKED_FROM_CONCIERGE, SHOULD_SHOW_COMPOSE_INPUT, MODAL, DRAFT_COMMENT, transactionThreadReport) | -| Hook-based subscriptions at root | ~5 (currentUserPersonalDetails, personalDetails, useNetwork, useAncestors, useIsInSidePanel) | -| Total subscriptions at root | ~10 | -| COLLECTION-level subscriptions | 3 (useAncestors -- unchanged, follow-up) | -| Duplicate subscriptions eliminated | policy, betas, preferredPolicy, reportIsArchived, currentDate, newParentReport, currentUserPersonalDetails(x1) | -| React Compiler errors | 0 | -| Manual memoization instances | 0 | - -### Follow-up work (not in this PR) - -1. **Move `useAncestors` to action layer** -- The 3 COLLECTION subscriptions are the heaviest remaining cost. They should be resolved at action-time using `Onyx.connect` inside the Report action, not in the render tree. This affects 14 call sites and is a separate cross-cutting PR. -2. **Make AttachmentPickerWithMenuItems fully self-subscribing** -- It still receives `currentUserPersonalDetails` and `reportParticipantIDs` as props that it could derive itself. -3. **Push `submitForm` into a dedicated handler** -- The submit logic (attachment path vs text path, telemetry, scrollOffset check) could be a focused hook, further slimming the orchestrator. From 0796a7e86c981f33c4cef9ce1c61b093b2e42a73 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 2 Apr 2026 03:49:17 +0530 Subject: [PATCH 34/57] Bump react-native-onyx from 3.0.54 to 3.0.57. Signed-off-by: krishna2323 --- package-lock.json | 14 +++++--------- package.json | 2 +- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index d27cfc61447aa..cabb3362fdc19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -115,7 +115,7 @@ "react-native-localize": "^3.5.4", "react-native-nitro-modules": "0.29.4", "react-native-nitro-sqlite": "9.2.0", - "react-native-onyx": "3.0.54", + "react-native-onyx": "3.0.57", "react-native-pager-view": "8.0.0", "react-native-pdf": "7.0.2", "react-native-permissions": "^5.4.0", @@ -34821,9 +34821,9 @@ } }, "node_modules/react-native-onyx": { - "version": "3.0.54", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-3.0.54.tgz", - "integrity": "sha512-202+t6reV9iQZnr5UOGHaJLCyO5X7Or0V2GHfvb5z10ZM1wnnZ0IKPkfUi+7WPZy4pFhEvDKymuaCIOu6++/rA==", + "version": "3.0.57", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-3.0.57.tgz", + "integrity": "sha512-7zHnwOdJ78pBmy1/ofJyN6NiBW/5Mo5vvt9oGgvXx2VmU02ZJY8Q2MvxpJq54Dad3wd70huud51drSEwSx/KNg==", "license": "MIT", "dependencies": { "ascii-table": "0.0.9", @@ -34844,8 +34844,7 @@ "react-native": ">=0.75.0", "react-native-device-info": "^10.3.0", "react-native-nitro-modules": ">=0.27.2", - "react-native-nitro-sqlite": "^9.2.0", - "react-native-performance": ">=5.1.0" + "react-native-nitro-sqlite": "^9.2.0" }, "peerDependenciesMeta": { "idb-keyval": { @@ -34859,9 +34858,6 @@ }, "react-native-nitro-sqlite": { "optional": true - }, - "react-native-performance": { - "optional": true } } }, diff --git a/package.json b/package.json index ec454fc138bc3..a5d20d6b6b1a5 100644 --- a/package.json +++ b/package.json @@ -178,7 +178,7 @@ "react-native-localize": "^3.5.4", "react-native-nitro-modules": "0.29.4", "react-native-nitro-sqlite": "9.2.0", - "react-native-onyx": "3.0.54", + "react-native-onyx": "3.0.57", "react-native-pager-view": "8.0.0", "react-native-pdf": "7.0.2", "react-native-permissions": "^5.4.0", From 2e0b4a5f1b957d33085b7e4da6dcf979471bfb96 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Thu, 2 Apr 2026 17:35:58 +0200 Subject: [PATCH 35/57] Restructure ComposerContext: 6 contexts by change frequency --- .../ReportActionCompose/ComposerContext.ts | 128 ++++++++++-------- 1 file changed, 70 insertions(+), 58 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts index 29b2bfbbf8f41..66137da5af472 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts +++ b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts @@ -1,4 +1,4 @@ -import type {ReactNode, RefObject} from 'react'; +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'; @@ -16,28 +16,48 @@ type SuggestionsRef = { getIsSuggestionsMenuVisible: () => boolean; }; +// Hot — changes on every keystroke +type ComposerText = string; + +// Warm — changes on interaction type ComposerState = { isFocused: boolean; - isFullComposerAvailable: boolean; - isComposerFullSize: boolean; isMenuVisible: boolean; + isFullComposerAvailable: boolean; }; +// Warm — changes based on content + policy type ComposerSendState = { isEmpty: boolean; - exceededMaxLength: number | null; isSendDisabled: boolean; - isBlockedFromConcierge: boolean; + exceededMaxLength: number | null; hasExceededMaxTaskTitleLength: boolean; + isBlockedFromConcierge: boolean; + validateAttachments: (args: {dragEvent?: DragEvent; files?: FileObject | FileObject[]}) => void; + onReceiptDropped: (event: DragEvent) => void; }; +// Frozen — stable references, never changes after mount type ComposerActions = { + setValue: (v: string) => void; setIsFocused: (v: boolean) => void; - setIsFullComposerAvailable: (v: boolean) => void; setMenuVisibility: (v: boolean) => void; - setValue: (v: string) => void; - handleSendMessage: () => void; + setIsFullComposerAvailable: (v: boolean) => void; + setComposerRef: (ref: ComposerRef | null) => void; + setIsAttachmentPreviewActive: (isActive: boolean) => void; focus: () => void; + onBlur: (event: BlurEvent) => void; + onFocus: () => void; + onAddActionPressed: () => void; + onItemSelected: () => void; + onTriggerAttachmentPicker: () => void; + addAttachment: (file: FileObject | FileObject[]) => void; + onAttachmentPreviewClose: () => void; +}; + +// Infrequent — changes only when send logic changes +type ComposerSendActions = { + handleSendMessage: () => void; onValueChange: (value: string) => void; validateMaxLength: (value: string) => boolean; debouncedValidate: { @@ -47,72 +67,68 @@ type ComposerActions = { }; }; +// Frozen — stable refs, set once type ComposerMeta = { containerRef: RefObject; composerRef: RefObject; suggestionsRef: RefObject; actionButtonRef: RefObject; isNextModalWillOpenRef: RefObject; - shouldFocusComposerOnScreenFocus: boolean; - shouldShowComposeInput: boolean; - isAttachmentPreviewActive: boolean; - userBlockedFromConcierge: boolean; - PDFValidationComponent: ReactNode; - ErrorModal: ReactNode; + attachmentFileRef: RefObject; }; -type ComposerMetaActions = { - setComposerRef: (ref: ComposerRef | null) => void; - onBlur: (event: BlurEvent) => void; - onFocus: () => void; - onAddActionPressed: () => void; - onItemSelected: () => void; - onTriggerAttachmentPicker: () => void; - submitForm: (newComment: string) => void; - addAttachment: (file: FileObject | FileObject[]) => void; - onAttachmentPreviewClose: () => void; - setIsAttachmentPreviewActive: (isActive: boolean) => void; - onReceiptDropped: (event: DragEvent) => void; - validateAttachments: (args: {dragEvent?: DragEvent; files?: FileObject | FileObject[]}) => void; -}; +const noop = () => {}; + +const ComposerTextContext = createContext(''); const defaultState: ComposerState = { isFocused: false, - isFullComposerAvailable: false, - isComposerFullSize: false, isMenuVisible: false, + isFullComposerAvailable: false, }; +const ComposerStateContext = createContext(defaultState); const defaultSendState: ComposerSendState = { isEmpty: true, - exceededMaxLength: null, isSendDisabled: true, - isBlockedFromConcierge: false, + exceededMaxLength: null, hasExceededMaxTaskTitleLength: false, + isBlockedFromConcierge: false, + validateAttachments: noop, + onReceiptDropped: noop, }; +const ComposerSendStateContext = createContext(defaultSendState); -const noop = () => {}; const defaultActions: ComposerActions = { + setValue: noop, setIsFocused: noop, - setIsFullComposerAvailable: noop, setMenuVisibility: noop, - setValue: noop, - handleSendMessage: noop, + setIsFullComposerAvailable: noop, + setComposerRef: noop, + setIsAttachmentPreviewActive: noop, focus: noop, + onBlur: noop, + onFocus: noop, + onAddActionPressed: noop, + onItemSelected: noop, + onTriggerAttachmentPicker: noop, + addAttachment: noop, + onAttachmentPreviewClose: noop, +}; +const ComposerActionsContext = createContext(defaultActions); + +const defaultSendActions: ComposerSendActions = { + handleSendMessage: noop, onValueChange: noop, validateMaxLength: () => true, debouncedValidate: Object.assign(() => true as boolean | undefined, {cancel: noop, flush: () => true as boolean | undefined}), }; +const ComposerSendActionsContext = createContext(defaultSendActions); -const ComposerValueContext = createContext(''); -const ComposerStateContext = createContext(defaultState); -const ComposerSendStateContext = createContext(defaultSendState); -const ComposerActionsContext = createContext(defaultActions); const ComposerMetaContext = createContext(null); -const ComposerMetaActionsContext = createContext(null); -function useComposerValue() { - return useContext(ComposerValueContext); +function useComposerText() { + return useContext(ComposerTextContext); } function useComposerState() { @@ -127,34 +143,30 @@ function useComposerActions() { return useContext(ComposerActionsContext); } -function useComposerMetaState() { - const ctx = useContext(ComposerMetaContext); - if (!ctx) { - throw new Error('useComposerMeta must be used inside ComposerProvider'); - } - return ctx; +function useComposerSendActions() { + return useContext(ComposerSendActionsContext); } -function useComposerMetaActions() { - const ctx = useContext(ComposerMetaActionsContext); +function useComposerMeta() { + const ctx = useContext(ComposerMetaContext); if (!ctx) { - throw new Error('useComposerMetaActions must be used inside ComposerProvider'); + throw new Error('useComposerMeta must be used inside ComposerProvider'); } return ctx; } export { - ComposerValueContext, + ComposerTextContext, ComposerStateContext, ComposerSendStateContext, ComposerActionsContext, + ComposerSendActionsContext, ComposerMetaContext, - ComposerMetaActionsContext, - useComposerValue, + useComposerText, useComposerState, useComposerSendState, useComposerActions, - useComposerMetaState, - useComposerMetaActions, + useComposerSendActions, + useComposerMeta, }; -export type {SuggestionsRef, ComposerState, ComposerSendState, ComposerActions, ComposerMeta, ComposerMetaActions}; +export type {SuggestionsRef, ComposerText, ComposerState, ComposerSendState, ComposerActions, ComposerSendActions, ComposerMeta}; From 08e66ab04d88158ac70121e038a216d448269095 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Thu, 2 Apr 2026 17:36:00 +0200 Subject: [PATCH 36/57] Split useComposerSubmit: accept attachmentFileRef as param, return only submitForm --- .../ReportActionCompose/useComposerSubmit.ts | 35 +++---------------- 1 file changed, 5 insertions(+), 30 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts index 0586dab2e9296..9a9d3fc81a05e 100644 --- a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts +++ b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts @@ -1,7 +1,7 @@ import {Str} from 'expensify-common'; -import {useContext, useRef} from 'react'; +import {useContext} from 'react'; +import type {RefObject} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; -import {scheduleOnUI} from 'react-native-worklets'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import useAncestors from '@hooks/useAncestors'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -12,7 +12,6 @@ import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useShortMentionsList from '@hooks/useShortMentionsList'; import {addComment} from '@libs/actions/Report'; -import ComposerFocusManager from '@libs/ComposerFocusManager'; import {isEmailPublicDomain} from '@libs/LoginUtils'; import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; import {rand64} from '@libs/NumberUtils'; @@ -28,17 +27,14 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import type {FileObject} from '@src/types/utils/Attachment'; -import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; type UseComposerSubmitParams = { report: OnyxEntry; reportID: string; - composerRefShared: {get: () => Partial}; - updateShouldShowSuggestionMenuToFalse: () => void; - setIsAttachmentPreviewActive: (value: boolean) => void; + attachmentFileRef: RefObject; }; -function useComposerSubmit({report, reportID, composerRefShared, updateShouldShowSuggestionMenuToFalse, setIsAttachmentPreviewActive}: UseComposerSubmitParams) { +function useComposerSubmit({report, reportID, attachmentFileRef}: UseComposerSubmitParams) { const isInSidePanel = useIsInSidePanel(); const {kickoffWaitingIndicator} = useAgentZeroStatusActions(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); @@ -64,29 +60,8 @@ function useComposerSubmit({report, reportID, composerRefShared, updateShouldSho const reportAncestors = useAncestors(report); const targetReportAncestors = useAncestors(targetReport); - const attachmentFileRef = useRef(null); - const currentUserEmail = currentUserPersonalDetails.email ?? ''; - const addAttachment = (file: FileObject | FileObject[]) => { - attachmentFileRef.current = file; - - const clearWorklet = composerRefShared.get().clearWorklet; - - if (!clearWorklet) { - throw new Error('The composerRef.clearWorklet function is not set yet. This should never happen, and indicates a developer error.'); - } - - scheduleOnUI(clearWorklet); - }; - - const onAttachmentPreviewClose = () => { - updateShouldShowSuggestionMenuToFalse(); - setIsAttachmentPreviewActive(false); - // This enables Composer refocus when the attachments modal is closed by the browser navigation - ComposerFocusManager.setReadyToFocus(); - }; - const handleCreateTask = (text: string): boolean => { const match = text.match(CONST.REGEX.TASK_TITLE_WITH_OPTIONAL_SHORT_MENTION); if (!match) { @@ -189,7 +164,7 @@ function useComposerSubmit({report, reportID, composerRefShared, updateShouldSho }); }; - return {submitForm, addAttachment, onAttachmentPreviewClose}; + return {submitForm}; } export default useComposerSubmit; From 4531ae2aeef3751fc0cdf45303224204e2aad587 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Thu, 2 Apr 2026 17:36:03 +0200 Subject: [PATCH 37/57] Rewrite ComposerProvider: 6 context providers, inline addAttachment + onAttachmentPreviewClose --- .../ReportActionCompose/ComposerProvider.tsx | 92 +++++++++++-------- 1 file changed, 53 insertions(+), 39 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx index 82e74bef364d4..f3dc0a147c30b 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx @@ -8,12 +8,14 @@ import useHandleExceedMaxTaskTitleLength from '@hooks/useHandleExceedMaxTaskTitl import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; +import ComposerFocusManager from '@libs/ComposerFocusManager'; import {chatIncludesConcierge} from '@libs/ReportUtils'; import {setIsComposerFullSize} from '@userActions/Report'; import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {ComposerActionsContext, ComposerMetaActionsContext, ComposerMetaContext, ComposerSendStateContext, ComposerStateContext, ComposerValueContext} from './ComposerContext'; +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 useAttachmentUploadValidation from './useAttachmentUploadValidation'; @@ -87,6 +89,7 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { const suggestionsRef = useRef(null); const composerRef = useRef(null); const actionButtonRef = useRef(null); + const attachmentFileRef = useRef(null); const composerRefShared = useSharedValue>({}); @@ -104,14 +107,28 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { setIsFocused, }); - const {submitForm, addAttachment, onAttachmentPreviewClose} = useComposerSubmit({ + const {submitForm} = useComposerSubmit({ report, reportID, - composerRefShared, - updateShouldShowSuggestionMenuToFalse, - setIsAttachmentPreviewActive, + attachmentFileRef, }); + const addAttachment = (file: FileObject | FileObject[]) => { + attachmentFileRef.current = file; + const clearWorklet = composerRefShared.get().clearWorklet; + if (!clearWorklet) { + throw new Error('The composerRef.clearWorklet function is not set yet. This should never happen, and indicates a developer error.'); + } + scheduleOnUI(clearWorklet); + }; + + const onAttachmentPreviewClose = () => { + updateShouldShowSuggestionMenuToFalse(); + setIsAttachmentPreviewActive(false); + // This enables Composer refocus when the attachments modal is closed by the browser navigation + ComposerFocusManager.setReadyToFocus(); + }; + const {validateAttachments, onReceiptDropped, PDFValidationComponent, ErrorModal} = useAttachmentUploadValidation({ reportID, report, @@ -159,74 +176,71 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { debouncedValidate(v); }; + const text = value; + const composerState = { isFocused, - isFullComposerAvailable, - isComposerFullSize, isMenuVisible, + isFullComposerAvailable, }; const composerSendState = { isEmpty, - exceededMaxLength, isSendDisabled, - isBlockedFromConcierge, + exceededMaxLength, hasExceededMaxTaskTitleLength, + isBlockedFromConcierge, + validateAttachments, + onReceiptDropped, }; const composerActions = { + setValue, setIsFocused, - setIsFullComposerAvailable, setMenuVisibility, - setValue, - handleSendMessage, + setIsFullComposerAvailable, + setComposerRef, + setIsAttachmentPreviewActive, focus, + onBlur, + onFocus, + onAddActionPressed, + onItemSelected, + onTriggerAttachmentPicker, + addAttachment, + onAttachmentPreviewClose, + }; + + const composerSendActions = { + handleSendMessage, onValueChange, validateMaxLength, debouncedValidate, }; - const composerMetaState = { + const composerMeta = { containerRef, composerRef, suggestionsRef, actionButtonRef, isNextModalWillOpenRef, - shouldFocusComposerOnScreenFocus, - shouldShowComposeInput, - isAttachmentPreviewActive, - userBlockedFromConcierge, - PDFValidationComponent, - ErrorModal, - }; - - const composerMetaActions = { - setComposerRef, - onBlur, - onFocus, - onAddActionPressed, - onItemSelected, - onTriggerAttachmentPicker, - submitForm, - addAttachment, - onAttachmentPreviewClose, - setIsAttachmentPreviewActive, - onReceiptDropped, - validateAttachments, + attachmentFileRef, }; return ( - + - - {children} - + + {children} + - + {PDFValidationComponent} + {ErrorModal} + ); } From b85935c198964ef7bdc0bd0cbd694b641d491b22 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Thu, 2 Apr 2026 17:37:37 +0200 Subject: [PATCH 38/57] Update leaf consumers to new context API --- .../report/ReportActionCompose/ComposerDropZone.tsx | 4 ++-- .../ReportActionCompose/ComposerEmojiPicker.tsx | 13 ++++++++++--- .../ReportActionCompose/ComposerLocalTime.tsx | 3 +-- .../ReportActionCompose/ComposerSendButton.tsx | 4 ++-- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx index 14b9d1163808b..dbacb7ec852dd 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx @@ -15,7 +15,7 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getParentReport, isChatRoom, isGroupChat, isInvoiceReport, isReportApproved, isSettled, temporary_getMoneyRequestOptions} from '@libs/ReportUtils'; import {hasReceipt as hasReceiptTransactionUtils} from '@libs/TransactionUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import {useComposerMetaActions} from './ComposerContext'; +import {useComposerSendState} from './ComposerContext'; import useShouldAddOrReplaceReceipt from './useShouldAddOrReplaceReceipt'; type ComposerDropZoneProps = { @@ -128,7 +128,7 @@ function ComposerDropZone({reportID, children}: ComposerDropZoneProps) { const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const {isOffline} = useNetwork(); const {shouldAddOrReplaceReceipt, transactionID} = useShouldAddOrReplaceReceipt(reportID, isOffline); - const {validateAttachments, onReceiptDropped} = useComposerMetaActions(); + const {validateAttachments, onReceiptDropped} = useComposerSendState(); const onAttachmentDrop = (dragEvent: DragEvent) => validateAttachments({dragEvent}); diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerEmojiPicker.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerEmojiPicker.tsx index 72ffb9169d687..e177b763d1117 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerEmojiPicker.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerEmojiPicker.tsx @@ -1,12 +1,16 @@ import React, {useEffect} from 'react'; import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; +import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import DomUtils from '@libs/DomUtils'; +import {chatIncludesConcierge} from '@libs/ReportUtils'; import {hideEmojiPicker, isActive as isActiveEmojiPickerAction} from '@userActions/EmojiPickerAction'; +import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User'; import CONST from '@src/CONST'; -import {useComposerActions, useComposerMetaState, useComposerSendState} from './ComposerContext'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {useComposerActions, useComposerMeta} from './ComposerContext'; type ComposerEmojiPickerProps = { reportID: string; @@ -16,9 +20,12 @@ function ComposerEmojiPicker({reportID}: ComposerEmojiPickerProps) { const styles = useThemeStyles(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isMediumScreenWidth} = useResponsiveLayout(); - const {isBlockedFromConcierge} = useComposerSendState(); const {focus} = useComposerActions(); - const {composerRef} = useComposerMetaState(); + const {composerRef} = useComposerMeta(); + + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [blockedFromConcierge] = useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); + const isBlockedFromConcierge = chatIncludesConcierge({participants: report?.participants}) && isBlockedFromConciergeUserAction(blockedFromConcierge); const chatItemComposeSecondaryRowHeight = styles.chatItemComposeSecondaryRow.height + styles.chatItemComposeSecondaryRow.marginTop + styles.chatItemComposeSecondaryRow.marginBottom; const reportActionComposeHeight = styles.chatItemComposeBox.minHeight + chatItemComposeSecondaryRowHeight; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx index 1537ea23a38d3..132f90f757947 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx @@ -10,14 +10,13 @@ import {canShowReportRecipientLocalTime, getReportOfflinePendingActionAndErrors, import ParticipantLocalTime from '@pages/inbox/report/ParticipantLocalTime'; import ONYXKEYS from '@src/ONYXKEYS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import {useComposerState} from './ComposerContext'; type ComposerLocalTimeProps = { reportID: string; }; function ComposerLocalTime({reportID}: ComposerLocalTimeProps) { - const {isComposerFullSize} = useComposerState(); + const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const personalDetails = usePersonalDetails(); const {isOffline} = useNetwork(); diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerSendButton.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerSendButton.tsx index fe6fb38c76d66..1114205167959 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerSendButton.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerSendButton.tsx @@ -1,10 +1,10 @@ import React from 'react'; -import {useComposerActions, useComposerSendState} from './ComposerContext'; +import {useComposerSendActions, useComposerSendState} from './ComposerContext'; import SendButton from './SendButton'; function ComposerSendButton() { const {isSendDisabled} = useComposerSendState(); - const {handleSendMessage} = useComposerActions(); + const {handleSendMessage} = useComposerSendActions(); return ( Date: Thu, 2 Apr 2026 17:39:08 +0200 Subject: [PATCH 39/57] Update ComposerBox + ComposerActionMenu to new context API --- .../ReportActionCompose/ComposerActionMenu.tsx | 18 +++++++++++------- .../report/ReportActionCompose/ComposerBox.tsx | 9 ++++----- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerActionMenu.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerActionMenu.tsx index 577b73863505f..d74972efa767c 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerActionMenu.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerActionMenu.tsx @@ -2,10 +2,10 @@ import React from 'react'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTriggered'; import useOnyx from '@hooks/useOnyx'; -import {chatIncludesConcierge} from '@libs/ReportUtils'; +import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import ONYXKEYS from '@src/ONYXKEYS'; import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; -import {useComposerActions, useComposerMetaActions, useComposerMetaState, useComposerSendState, useComposerState} from './ComposerContext'; +import {useComposerActions, useComposerMeta, useComposerSendState, useComposerState} from './ComposerContext'; type ComposerActionMenuProps = { reportID: string; @@ -13,11 +13,13 @@ type ComposerActionMenuProps = { function ComposerActionMenu({reportID}: ComposerActionMenuProps) { const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const {isComposerFullSize, isFullComposerAvailable, isMenuVisible} = useComposerState(); - const {isBlockedFromConcierge, exceededMaxLength} = useComposerSendState(); - const {setMenuVisibility, focus} = useComposerActions(); - const {actionButtonRef, shouldFocusComposerOnScreenFocus} = useComposerMetaState(); - const {onAddActionPressed, onItemSelected, onTriggerAttachmentPicker, validateAttachments} = useComposerMetaActions(); + const {isMenuVisible, isFullComposerAvailable} = useComposerState(); + const {isBlockedFromConcierge, exceededMaxLength, validateAttachments} = useComposerSendState(); + const {setMenuVisibility, focus, onAddActionPressed, onItemSelected, onTriggerAttachmentPicker} = useComposerActions(); + const {actionButtonRef} = useComposerMeta(); + + const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); + const [draftComment] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`); const {raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); @@ -27,6 +29,8 @@ function ComposerActionMenu({reportID}: ComposerActionMenuProps) { .map(Number) .filter((accountID) => accountID !== currentUserPersonalDetails.accountID); + const shouldFocusComposerOnScreenFocus = canFocusInputOnScreenFocus() || !!draftComment; + return ( validateAttachments({files})} diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx index f4bfd8ab6558e..f4bc3fc4d2c62 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx @@ -5,7 +5,7 @@ import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {getReportOfflinePendingActionAndErrors} from '@libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import {useComposerMetaState, useComposerSendState, useComposerState} from './ComposerContext'; +import {useComposerMeta, useComposerSendState, useComposerState} from './ComposerContext'; type ComposerBoxProps = { reportID: string; @@ -14,9 +14,10 @@ type ComposerBoxProps = { function ComposerBox({reportID, children}: ComposerBoxProps) { const styles = useThemeStyles(); - const {isFocused, isComposerFullSize} = useComposerState(); + const {isFocused} = useComposerState(); const {exceededMaxLength, isBlockedFromConcierge} = useComposerSendState(); - const {containerRef, PDFValidationComponent, ErrorModal} = useComposerMetaState(); + const {containerRef} = useComposerMeta(); + const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const {reportPendingAction: pendingAction} = getReportOfflinePendingActionAndErrors(report); @@ -39,10 +40,8 @@ function ComposerBox({reportID, children}: ComposerBoxProps) { !!exceededMaxLength && styles.borderColorDanger, ]} > - {PDFValidationComponent} {children} - {ErrorModal} ); } From d563c0ada390360ec46771b370f0afe818e31b66 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Thu, 2 Apr 2026 17:40:28 +0200 Subject: [PATCH 40/57] Update InputWrapper + ComposerWithSuggestions to new context API --- .../ComposerInputWrapper.tsx | 21 +++++++++++++------ .../ComposerWithSuggestions.tsx | 4 ++-- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerInputWrapper.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerInputWrapper.tsx index 4c901eb4dbef5..a8f92c34a31c8 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerInputWrapper.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerInputWrapper.tsx @@ -3,13 +3,16 @@ import type {MeasureInWindowOnSuccessCallback} from 'react-native'; import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTriggered'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import FS from '@libs/Fullstory'; import {chatIncludesChronos, chatIncludesConcierge} from '@libs/ReportUtils'; import {isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; +import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {useComposerActions, useComposerMetaActions, useComposerMetaState, useComposerSendState, useComposerState} from './ComposerContext'; +import {useComposerActions, useComposerMeta, useComposerSendActions, useComposerSendState, useComposerState} from './ComposerContext'; import ComposerWithSuggestions from './ComposerWithSuggestions'; +import useComposerSubmit from './useComposerSubmit'; type ComposerInputWrapperProps = { reportID: string; @@ -17,11 +20,16 @@ type ComposerInputWrapperProps = { function ComposerInputWrapper({reportID}: ComposerInputWrapperProps) { const {translate} = useLocalize(); - const {isComposerFullSize, isMenuVisible} = useComposerState(); - const {isBlockedFromConcierge} = useComposerSendState(); - const {setIsFullComposerAvailable, handleSendMessage, onValueChange} = useComposerActions(); - const {containerRef, suggestionsRef, isNextModalWillOpenRef, shouldShowComposeInput, userBlockedFromConcierge} = useComposerMetaState(); - const {onBlur, onFocus, submitForm, validateAttachments, setComposerRef} = useComposerMetaActions(); + const {isMenuVisible} = useComposerState(); + const {isBlockedFromConcierge, validateAttachments} = useComposerSendState(); + const {setIsFullComposerAvailable, onBlur, onFocus, setComposerRef} = useComposerActions(); + const {handleSendMessage, onValueChange} = useComposerSendActions(); + const {containerRef, suggestionsRef, isNextModalWillOpenRef, attachmentFileRef} = useComposerMeta(); + + const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); + const [shouldShowComposeInput = true] = useOnyx(ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT); + const [blockedFromConcierge] = useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); + const userBlockedFromConcierge = isBlockedFromConciergeUserAction(blockedFromConcierge); const measureContainer = (callback: MeasureInWindowOnSuccessCallback) => { containerRef.current?.measureInWindow(callback); @@ -30,6 +38,7 @@ function ComposerInputWrapper({reportID}: ComposerInputWrapperProps) { const {isScrollLayoutTriggered, raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const {submitForm} = useComposerSubmit({report, reportID, attachmentFileRef}); const includesConcierge = chatIncludesConcierge({participants: report?.participants}); const isGroupPolicyReport = !!report?.policyID && report.policyID !== CONST.POLICY.ID_FAKE; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 5621b67c5ce8e..b9b3442ce5c9d 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -48,7 +48,7 @@ import {isValidReportIDFromPath, shouldAutoFocusOnKeyPress} from '@libs/ReportUt import updateMultilineInputRange from '@libs/updateMultilineInputRange'; import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; import type {SuggestionsRef} from '@pages/inbox/report/ReportActionCompose/ComposerContext'; -import {useComposerActions, useComposerValue} from '@pages/inbox/report/ReportActionCompose/ComposerContext'; +import {useComposerActions, useComposerText} from '@pages/inbox/report/ReportActionCompose/ComposerContext'; import getCursorPosition from '@pages/inbox/report/ReportActionCompose/getCursorPosition'; import getScrollPosition from '@pages/inbox/report/ReportActionCompose/getScrollPosition'; import SilentCommentUpdater from '@pages/inbox/report/ReportActionCompose/SilentCommentUpdater'; @@ -253,7 +253,7 @@ function ComposerWithSuggestions({ const mobileInputScrollPosition = useRef(0); const cursorPositionValue = useSharedValue({x: 0, y: 0}); const tag = useSharedValue(-1); - const value = useComposerValue(); + const value = useComposerText(); const {setValue} = useComposerActions(); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); From c2beb2f635c88ac3a6210cb2c4466d1f0ffa182f Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Thu, 2 Apr 2026 17:43:42 +0200 Subject: [PATCH 41/57] =?UTF-8?q?Fix=20lint=20errors=20=E2=80=94=20remove?= =?UTF-8?q?=20unused=20imports,=20remove=20leftover=20useComposerSubmit=20?= =?UTF-8?q?from=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../report/ReportActionCompose/ComposerInputWrapper.tsx | 1 - .../inbox/report/ReportActionCompose/ComposerLocalTime.tsx | 2 -- .../inbox/report/ReportActionCompose/ComposerProvider.tsx | 7 ------- .../inbox/report/ReportActionCompose/useComposerSubmit.ts | 1 + 4 files changed, 1 insertion(+), 10 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerInputWrapper.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerInputWrapper.tsx index a8f92c34a31c8..72f802622d032 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerInputWrapper.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerInputWrapper.tsx @@ -3,7 +3,6 @@ import type {MeasureInWindowOnSuccessCallback} from 'react-native'; import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTriggered'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import FS from '@libs/Fullstory'; import {chatIncludesChronos, chatIncludesConcierge} from '@libs/ReportUtils'; import {isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx index 132f90f757947..72f005281de0d 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx @@ -1,11 +1,9 @@ import React from 'react'; -import {View} from 'react-native'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import useThemeStyles from '@hooks/useThemeStyles'; import {canShowReportRecipientLocalTime, getReportOfflinePendingActionAndErrors, getReportRecipientAccountIDs} from '@libs/ReportUtils'; import ParticipantLocalTime from '@pages/inbox/report/ParticipantLocalTime'; import ONYXKEYS from '@src/ONYXKEYS'; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx index f3dc0a147c30b..b995989296c35 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx @@ -20,7 +20,6 @@ import type {SuggestionsRef} from './ComposerContext'; import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; import useAttachmentUploadValidation from './useAttachmentUploadValidation'; import useComposerFocus from './useComposerFocus'; -import useComposerSubmit from './useComposerSubmit'; import useShouldAddOrReplaceReceipt from './useShouldAddOrReplaceReceipt'; const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); @@ -107,12 +106,6 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { setIsFocused, }); - const {submitForm} = useComposerSubmit({ - report, - reportID, - attachmentFileRef, - }); - const addAttachment = (file: FileObject | FileObject[]) => { attachmentFileRef.current = file; const clearWorklet = composerRefShared.get().clearWorklet; diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts index 9a9d3fc81a05e..9c35d0fc216ec 100644 --- a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts +++ b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts @@ -128,6 +128,7 @@ function useComposerSubmit({report, reportID, attachmentFileRef}: UseComposerSub shouldPlaySound: true, isInSidePanel, }); + // eslint-disable-next-line no-param-reassign attachmentFileRef.current = null; return; } From 2761058770ebb666b368e0fb469a28ff1c162d55 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:54:31 +0530 Subject: [PATCH 42/57] Prettier and cspell update --- cspell.json | 4 +++- src/pages/inbox/report/PureReportActionItem.tsx | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/cspell.json b/cspell.json index ed2cbd7589da9..a1381504cb01e 100644 --- a/cspell.json +++ b/cspell.json @@ -902,7 +902,9 @@ "Synovus", "Wallester", "Wintrust", - "Zürcher" + "Zürcher", + "CARDFROZEN", + "CARDUNFROZEN" ], "ignorePaths": [ "src/languages/de.ts", diff --git a/src/pages/inbox/report/PureReportActionItem.tsx b/src/pages/inbox/report/PureReportActionItem.tsx index 3c422508119c2..1257050fb9cc0 100644 --- a/src/pages/inbox/report/PureReportActionItem.tsx +++ b/src/pages/inbox/report/PureReportActionItem.tsx @@ -122,8 +122,8 @@ import { getRemovedConnectionMessage, getRemovedFromApprovalChainMessage, getRenamedAction, - getReportActionHtml, getRenamedCardFeedMessage, + getReportActionHtml, getReportActionMessage, getReportActionText, getSetAutoJoinMessage, From 344652784ce5f987fcc97552b46cffbf492f1694 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Fri, 3 Apr 2026 03:20:49 +0530 Subject: [PATCH 43/57] Update tests --- tests/unit/ReportActionsUtilsTest.ts | 57 ++++++++++++++++++---------- 1 file changed, 36 insertions(+), 21 deletions(-) diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 9083dfbc485a9..20f0bdc26c1ce 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -1413,28 +1413,29 @@ describe('ReportActionsUtils', () => { expect(ReportActionsUtils.getReportActionMessageFragments(translateLocal, action)).toEqual(action.message); }); - it('should synthesize fragments from originalMessage.html when CARDFROZEN has no message array entries', () => { - const cardFrozenMessage = 'A A froze their Expensify Card (ending in 1384). New transactions will be declined until the card is unfrozen.'; - const action: ReportAction = { - actionName: CONST.REPORT.ACTIONS.TYPE.CARD_FROZEN, - reportActionID: 'card-frozen-action-empty-message', + it('should preserve backend-provided CARDUNFROZEN fragments', () => { + const cardUnfrozenMessage = 'A A unfroze their Expensify Card (ending in 1384). This card can now be used for transactions.'; + const action: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.CARD_UNFROZEN, + reportActionID: 'card-unfrozen-action-123', actorAccountID: 21052128, - created: '2026-03-12 01:58:43.479', - message: [], + created: '2026-03-12 02:08:08.128', + message: [ + { + html: cardUnfrozenMessage, + text: cardUnfrozenMessage, + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + whisperedTo: [], + }, + ], originalMessage: { - html: cardFrozenMessage, + html: cardUnfrozenMessage, isNewDot: true, - lastModified: '2026-03-12 01:58:43.479', + lastModified: '2026-03-12 02:08:08.128', }, }; - expect(ReportActionsUtils.getReportActionMessageFragments(translateLocal, action)).toEqual([ - { - html: cardFrozenMessage, - text: cardFrozenMessage, - type: CONST.REPORT.MESSAGE.TYPE.COMMENT, - }, - ]); + expect(ReportActionsUtils.getReportActionMessageFragments(translateLocal, action)).toEqual(action.message); }); }); @@ -1464,14 +1465,21 @@ describe('ReportActionsUtils', () => { expect(ReportActionsUtils.getReportActionText(action)).toBe(cardFrozenMessage); }); - it('should return text from originalMessage.html when CARDUNFROZEN has no message array entries', () => { + it('should return the backend-provided CARDUNFROZEN text', () => { const cardUnfrozenMessage = 'A A unfroze their Expensify Card (ending in 1384). This card can now be used for transactions.'; const action: ReportAction = { actionName: CONST.REPORT.ACTIONS.TYPE.CARD_UNFROZEN, reportActionID: 'card-unfrozen-action-123', actorAccountID: 21052128, created: '2026-03-12 02:08:08.128', - message: [], + message: [ + { + html: cardUnfrozenMessage, + text: cardUnfrozenMessage, + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + whisperedTo: [], + }, + ], originalMessage: { html: cardUnfrozenMessage, isNewDot: true, @@ -1761,13 +1769,20 @@ describe('ReportActionsUtils', () => { expect(ReportActionsUtils.isDeletedAction(reportAction)).toBe(false); }); - it('should return false for CARDFROZEN action with empty message array when originalMessage.html is provided', () => { + it('should return false for CARDFROZEN action with a backend-provided message fragment', () => { const reportAction: ReportAction = { actionName: CONST.REPORT.ACTIONS.TYPE.CARD_FROZEN, - reportActionID: 'card-frozen-action-empty-message', + reportActionID: 'card-frozen-action-123', actorAccountID: 21052128, created: '2026-03-12 01:58:43.479', - message: [], + message: [ + { + html: 'A A froze their Expensify Card (ending in 1384). New transactions will be declined until the card is unfrozen.', + text: 'A A froze their Expensify Card (ending in 1384). New transactions will be declined until the card is unfrozen.', + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + whisperedTo: [], + }, + ], originalMessage: { html: 'A A froze their Expensify Card (ending in 1384). New transactions will be declined until the card is unfrozen.', isNewDot: true, From 882ccd283a24363f7cc7b16432d80ca682a2688b Mon Sep 17 00:00:00 2001 From: "{\"message\":\"Not Found\",\"documentation_url\":\"https://docs.github.com/rest/issues/comments#get-an-issue-comment\",\"status\":\"404\"} (via MelvinBot)" Date: Thu, 2 Apr 2026 22:14:45 +0000 Subject: [PATCH 44/57] Fix: Show all payment options for non-reimbursable expenses in report header PayPrimaryAction was missing the hasOnlyNonReimbursableTransactions check that PayActionButton (preview) already had, causing onlyShowPayElsewhere to be true for non-reimbursable reports. This made SettlementButton show only "Mark as Paid" instead of also showing "Pay with Business Account". Co-authored-by: {"message":"Not Found","documentation_url":"https://docs.github.com/rest/issues/comments#get-an-issue-comment","status":"404"} <{"message":"Not Found","documentation_url":"https://docs.github.com/rest/issues/comments#get-an-issue-comment","status":"404"}@users.noreply.github.com> --- .../PayPrimaryAction.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx index d5d7be2247a2e..23e040135d6d1 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx @@ -14,7 +14,13 @@ import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViol import {search} from '@libs/actions/Search'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getTotalAmountForIOUReportPreviewButton} from '@libs/MoneyRequestReportUtils'; -import {hasHeldExpenses as hasHeldExpensesReportUtils, hasUpdatedTotal, isAllowedToApproveExpenseReport, isInvoiceReport as isInvoiceReportUtil} from '@libs/ReportUtils'; +import { + hasHeldExpenses as hasHeldExpensesReportUtils, + hasOnlyNonReimbursableTransactions, + hasUpdatedTotal, + isAllowedToApproveExpenseReport, + isInvoiceReport as isInvoiceReportUtil, +} from '@libs/ReportUtils'; import {isExpensifyCardTransaction, isPending} from '@libs/TransactionUtils'; import {canApproveIOU, canIOUBePaid as canIOUBePaidAction, payInvoice, payMoneyRequest} from '@userActions/IOU'; import CONST from '@src/CONST'; @@ -76,9 +82,11 @@ function PayPrimaryAction({ const hasOnlyPendingTransactions = transactions.length > 0 && transactions.every((t) => isExpensifyCardTransaction(t) && isPending(t)); const canIOUBePaid = canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList, transaction ? [transaction] : undefined, false, undefined, invoiceReceiverPolicy); - const onlyShowPayElsewhere = - !canIOUBePaid && canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList, transaction ? [transaction] : undefined, true, undefined, invoiceReceiverPolicy); - const shouldShowPayButton = isPaidAnimationRunning || canIOUBePaid || onlyShowPayElsewhere; + const reportHasOnlyNonReimbursableTransactions = hasOnlyNonReimbursableTransactions(moneyRequestReport?.reportID, transactions); + const onlyShowPayElsewhere = reportHasOnlyNonReimbursableTransactions + ? false + : !canIOUBePaid && canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList, transaction ? [transaction] : undefined, true, undefined, invoiceReceiverPolicy); + const shouldShowPayButton = isPaidAnimationRunning || canIOUBePaid || onlyShowPayElsewhere || reportHasOnlyNonReimbursableTransactions; const shouldShowApproveButton = (canApproveIOU(moneyRequestReport, policy, reportMetadata, transactions) && !hasOnlyPendingTransactions) || isApprovedAnimationRunning; const shouldDisableApproveButton = shouldShowApproveButton && !isAllowedToApproveExpenseReport(moneyRequestReport); const canAllowSettlement = hasUpdatedTotal(moneyRequestReport, policy); From 98be9292603a4766f42966406c1b159a35eb076c Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Fri, 3 Apr 2026 11:54:49 +0700 Subject: [PATCH 45/57] fix: Per attendee amount on the table does not show negative sign --- src/components/TransactionItemRow/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/TransactionItemRow/index.tsx b/src/components/TransactionItemRow/index.tsx index c4db37bd52b7a..697152d63a7f6 100644 --- a/src/components/TransactionItemRow/index.tsx +++ b/src/components/TransactionItemRow/index.tsx @@ -280,14 +280,14 @@ function TransactionItemRow({ const totalPerAttendee = useMemo(() => { const attendeesCount = transactionAttendees.length ?? 0; - const totalAmount = getAmount(transactionItem); + const totalAmount = getAmount(transactionItem, isExpenseReport(report)); if (!attendeesCount || totalAmount === undefined) { return undefined; } return totalAmount / attendeesCount; - }, [transactionAttendees.length, transactionItem]); + }, [report, transactionAttendees.length, transactionItem]); const renderColumn = (column: SearchColumnType): React.ReactNode => { switch (column) { From 70e6890c24278a7b6c1173eb860233510e5aa82f Mon Sep 17 00:00:00 2001 From: "Situ Chandra Shil (via MelvinBot)" Date: Fri, 3 Apr 2026 11:25:03 +0000 Subject: [PATCH 46/57] Add non-reimbursable payment guard to PayPrimaryAction Co-authored-by: situchan <108292595+situchan@users.noreply.github.com> Co-authored-by: Situ Chandra Shil --- .../PayPrimaryAction.tsx | 49 +++++++++++-------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx index 23e040135d6d1..1dd324c9f13f0 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx @@ -6,6 +6,7 @@ import AnimatedSettlementButton from '@components/SettlementButton/AnimatedSettl import type {PaymentActionParams} from '@components/SettlementButton/types'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useNetwork from '@hooks/useNetwork'; +import useNonReimbursablePaymentModal from '@hooks/useNonReimbursablePaymentModal'; import useOnyx from '@hooks/useOnyx'; import useParticipantsInvoiceReport from '@hooks/useParticipantsInvoiceReport'; import usePolicy from '@hooks/usePolicy'; @@ -83,6 +84,7 @@ function PayPrimaryAction({ const canIOUBePaid = canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList, transaction ? [transaction] : undefined, false, undefined, invoiceReceiverPolicy); const reportHasOnlyNonReimbursableTransactions = hasOnlyNonReimbursableTransactions(moneyRequestReport?.reportID, transactions); + const {showNonReimbursablePaymentErrorModal, shouldBlockDirectPayment, nonReimbursablePaymentErrorDecisionModal} = useNonReimbursablePaymentModal(moneyRequestReport, transactions); const onlyShowPayElsewhere = reportHasOnlyNonReimbursableTransactions ? false : !canIOUBePaid && canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList, transaction ? [transaction] : undefined, true, undefined, invoiceReceiverPolicy); @@ -102,6 +104,10 @@ function PayPrimaryAction({ if (!type || !chatReport) { return; } + if (shouldBlockDirectPayment(type)) { + showNonReimbursablePaymentErrorModal(); + return; + } if (isDelegateAccessRestricted) { showDelegateNoAccessModal(); } else if (isAnyTransactionOnHold) { @@ -157,26 +163,29 @@ function PayPrimaryAction({ }; return ( - + <> + + {nonReimbursablePaymentErrorDecisionModal} + ); } From f98114f50d81c50ade4c7ad7a8c20034bf8eabee Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Fri, 3 Apr 2026 14:52:22 +0200 Subject: [PATCH 47/57] Remove attachment/receipt actions from ComposerContext, add clearComposer --- .../report/ReportActionCompose/ComposerContext.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts index 66137da5af472..17ab1d850808b 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts +++ b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts @@ -33,8 +33,6 @@ type ComposerSendState = { exceededMaxLength: number | null; hasExceededMaxTaskTitleLength: boolean; isBlockedFromConcierge: boolean; - validateAttachments: (args: {dragEvent?: DragEvent; files?: FileObject | FileObject[]}) => void; - onReceiptDropped: (event: DragEvent) => void; }; // Frozen — stable references, never changes after mount @@ -44,15 +42,13 @@ type ComposerActions = { setMenuVisibility: (v: boolean) => void; setIsFullComposerAvailable: (v: boolean) => void; setComposerRef: (ref: ComposerRef | null) => void; - setIsAttachmentPreviewActive: (isActive: boolean) => void; focus: () => void; onBlur: (event: BlurEvent) => void; onFocus: () => void; onAddActionPressed: () => void; onItemSelected: () => void; onTriggerAttachmentPicker: () => void; - addAttachment: (file: FileObject | FileObject[]) => void; - onAttachmentPreviewClose: () => void; + clearComposer: () => void; }; // Infrequent — changes only when send logic changes @@ -94,8 +90,6 @@ const defaultSendState: ComposerSendState = { exceededMaxLength: null, hasExceededMaxTaskTitleLength: false, isBlockedFromConcierge: false, - validateAttachments: noop, - onReceiptDropped: noop, }; const ComposerSendStateContext = createContext(defaultSendState); @@ -105,15 +99,13 @@ const defaultActions: ComposerActions = { setMenuVisibility: noop, setIsFullComposerAvailable: noop, setComposerRef: noop, - setIsAttachmentPreviewActive: noop, focus: noop, onBlur: noop, onFocus: noop, onAddActionPressed: noop, onItemSelected: noop, onTriggerAttachmentPicker: noop, - addAttachment: noop, - onAttachmentPreviewClose: noop, + clearComposer: noop, }; const ComposerActionsContext = createContext(defaultActions); From e8d1877d90fdc0d8e279dc4174b5cac4ca52894d Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Fri, 3 Apr 2026 14:52:24 +0200 Subject: [PATCH 48/57] Remove attachment/receipt logic from ComposerProvider, add clearComposer --- .../ReportActionCompose/ComposerProvider.tsx | 44 +------------------ 1 file changed, 2 insertions(+), 42 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx index b995989296c35..58c88fe824b47 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx @@ -5,10 +5,8 @@ import {useSharedValue} from 'react-native-reanimated'; import {scheduleOnUI} from 'react-native-worklets'; import useHandleExceedMaxCommentLength from '@hooks/useHandleExceedMaxCommentLength'; import useHandleExceedMaxTaskTitleLength from '@hooks/useHandleExceedMaxTaskTitleLength'; -import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; -import ComposerFocusManager from '@libs/ComposerFocusManager'; import {chatIncludesConcierge} from '@libs/ReportUtils'; import {setIsComposerFullSize} from '@userActions/Report'; import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User'; @@ -18,9 +16,7 @@ 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 useAttachmentUploadValidation from './useAttachmentUploadValidation'; import useComposerFocus from './useComposerFocus'; -import useShouldAddOrReplaceReceipt from './useShouldAddOrReplaceReceipt'; const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); @@ -30,8 +26,6 @@ type ComposerProviderProps = { }; function ComposerProvider({children, reportID}: ComposerProviderProps) { - const {isOffline} = useNetwork(); - const {shouldAddOrReplaceReceipt, transactionID} = useShouldAddOrReplaceReceipt(reportID, isOffline); const [blockedFromConcierge] = useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); const [shouldShowComposeInput = true] = useOnyx(ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT); const [initialModalState] = useOnyx(ONYXKEYS.MODAL); @@ -47,7 +41,6 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(isComposerFullSize); const [isMenuVisible, setMenuVisibility] = useState(false); - const [isAttachmentPreviewActive, setIsAttachmentPreviewActive] = useState(false); const [value, setValue] = useState(() => { return draftComment ?? ''; @@ -92,13 +85,6 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { const composerRefShared = useSharedValue>({}); - const updateShouldShowSuggestionMenuToFalse = () => { - if (!suggestionsRef.current) { - return; - } - suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false); - }; - const {onBlur, onFocus, focus, onAddActionPressed, onItemSelected, onTriggerAttachmentPicker, isNextModalWillOpenRef} = useComposerFocus({ composerRef, suggestionsRef, @@ -106,8 +92,7 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { setIsFocused, }); - const addAttachment = (file: FileObject | FileObject[]) => { - attachmentFileRef.current = file; + const clearComposer = () => { const clearWorklet = composerRefShared.get().clearWorklet; if (!clearWorklet) { throw new Error('The composerRef.clearWorklet function is not set yet. This should never happen, and indicates a developer error.'); @@ -115,25 +100,6 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { scheduleOnUI(clearWorklet); }; - const onAttachmentPreviewClose = () => { - updateShouldShowSuggestionMenuToFalse(); - setIsAttachmentPreviewActive(false); - // This enables Composer refocus when the attachments modal is closed by the browser navigation - ComposerFocusManager.setReadyToFocus(); - }; - - const {validateAttachments, onReceiptDropped, PDFValidationComponent, ErrorModal} = useAttachmentUploadValidation({ - reportID, - report, - addAttachment, - onAttachmentPreviewClose, - exceededMaxLength, - shouldAddOrReplaceReceipt, - transactionID, - isAttachmentPreviewActive, - setIsAttachmentPreviewActive, - }); - const handleSendMessage = () => { if (isSendDisabled || !debouncedValidate.flush()) { return; @@ -183,8 +149,6 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { exceededMaxLength, hasExceededMaxTaskTitleLength, isBlockedFromConcierge, - validateAttachments, - onReceiptDropped, }; const composerActions = { @@ -193,15 +157,13 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { setMenuVisibility, setIsFullComposerAvailable, setComposerRef, - setIsAttachmentPreviewActive, focus, onBlur, onFocus, onAddActionPressed, onItemSelected, onTriggerAttachmentPicker, - addAttachment, - onAttachmentPreviewClose, + clearComposer, }; const composerSendActions = { @@ -231,8 +193,6 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { - {PDFValidationComponent} - {ErrorModal} ); } From f747fdc3c5dd7366a0a8a46a1900db12f9fd3ebc Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Fri, 3 Apr 2026 14:52:26 +0200 Subject: [PATCH 49/57] Add useAttachmentPicker hook --- .../useAttachmentPicker.ts | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 src/pages/inbox/report/ReportActionCompose/useAttachmentPicker.ts diff --git a/src/pages/inbox/report/ReportActionCompose/useAttachmentPicker.ts b/src/pages/inbox/report/ReportActionCompose/useAttachmentPicker.ts new file mode 100644 index 0000000000000..24fa6f7a6f735 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/useAttachmentPicker.ts @@ -0,0 +1,100 @@ +import {useContext, useState} from 'react'; +import useFilesValidation from '@hooks/useFilesValidation'; +import useLocalize from '@hooks/useLocalize'; +import ComposerFocusManager from '@libs/ComposerFocusManager'; +import {cleanFileObject, cleanFileObjectName, getFilesFromClipboardEvent} from '@libs/fileDownload/FileUtils'; +import Navigation from '@navigation/Navigation'; +import AttachmentModalContext from '@pages/media/AttachmentModalScreen/AttachmentModalContext'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {FileObject} from '@src/types/utils/Attachment'; +import {useComposerActions, useComposerMeta, useComposerSendState} from './ComposerContext'; + +function useAttachmentPicker(reportID: string) { + const {translate} = useLocalize(); + const {exceededMaxLength} = useComposerSendState(); + const {clearComposer} = useComposerActions(); + const {attachmentFileRef, suggestionsRef} = useComposerMeta(); + const [isAttachmentPreviewActive, setIsAttachmentPreviewActive] = useState(false); + + const reportAttachmentsContext = useContext(AttachmentModalContext); + + const addAttachment = (file: FileObject | FileObject[]) => { + attachmentFileRef.current = file; + clearComposer(); + }; + + const onAttachmentPreviewClose = () => { + if (suggestionsRef.current) { + suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false); + } + setIsAttachmentPreviewActive(false); + ComposerFocusManager.setReadyToFocus(); + }; + + const onFilesValidated = (files: FileObject[], dataTransferItems: DataTransferItem[]) => { + if (files.length === 0) { + return; + } + + reportAttachmentsContext.setCurrentAttachment({ + reportID, + file: files, + dataTransferItems, + headerTitle: translate('reportActionCompose.sendAttachment'), + onConfirm: addAttachment, + onShow: () => setIsAttachmentPreviewActive(true), + onClose: onAttachmentPreviewClose, + shouldDisableSendButton: !!exceededMaxLength, + }); + Navigation.navigate(ROUTES.REPORT_ADD_ATTACHMENT.getRoute(reportID)); + }; + + const {validateFiles, PDFValidationComponent, ErrorModal} = useFilesValidation(onFilesValidated); + + const pickAttachments = ({dragEvent, files}: {dragEvent?: DragEvent; files?: FileObject | FileObject[]}) => { + if (isAttachmentPreviewActive) { + return; + } + + let extractedFiles: FileObject[] = []; + + if (files) { + extractedFiles = Array.isArray(files) ? files : [files]; + } else { + if (!dragEvent) { + return; + } + extractedFiles = getFilesFromClipboardEvent(dragEvent); + } + + const dataTransferItems = Array.from(dragEvent?.dataTransfer?.items ?? []); + if (extractedFiles.length === 0) { + return; + } + + const validIndices: number[] = []; + const fileObjects = extractedFiles + .map((item, index) => { + const fileObject = cleanFileObject(item); + const cleanedFileObject = cleanFileObjectName(fileObject); + if (cleanedFileObject !== null) { + validIndices.push(index); + } + return cleanedFileObject; + }) + .filter((fileObject) => fileObject !== null); + + if (!fileObjects.length) { + return; + } + + const filteredItems = dataTransferItems && validIndices.length > 0 ? validIndices.map((index) => dataTransferItems.at(index) ?? ({} as DataTransferItem)) : undefined; + + validateFiles(fileObjects, filteredItems, {isValidatingReceipts: false}); + }; + + return {pickAttachments, PDFValidationComponent, ErrorModal}; +} + +export default useAttachmentPicker; From 0e3bdf5562982c57b84ca596eb90dfe66314c6d0 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Fri, 3 Apr 2026 14:52:28 +0200 Subject: [PATCH 50/57] Add useReceiptDrop hook --- .../ReportActionCompose/useReceiptDrop.ts | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 src/pages/inbox/report/ReportActionCompose/useReceiptDrop.ts diff --git a/src/pages/inbox/report/ReportActionCompose/useReceiptDrop.ts b/src/pages/inbox/report/ReportActionCompose/useReceiptDrop.ts new file mode 100644 index 0000000000000..68a8606594ebd --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/useReceiptDrop.ts @@ -0,0 +1,120 @@ +import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; +import {useRef} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useFilesValidation from '@hooks/useFilesValidation'; +import useOnyx from '@hooks/useOnyx'; +import usePersonalPolicy from '@hooks/usePersonalPolicy'; +import {getFilesFromClipboardEvent} from '@libs/fileDownload/FileUtils'; +import {hasOnlyPersonalPolicies as hasOnlyPersonalPoliciesUtil} from '@libs/PolicyUtils'; +import {isSelfDM} from '@libs/ReportUtils'; +import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; +import Navigation from '@navigation/Navigation'; +import {initMoneyRequest, replaceReceipt, setMoneyRequestParticipantsFromReport, setMoneyRequestReceipt} from '@userActions/IOU'; +import {buildOptimisticTransactionAndCreateDraft} from '@userActions/TransactionEdit'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type * as OnyxTypes from '@src/types/onyx'; +import type {FileObject} from '@src/types/utils/Attachment'; + +type UseReceiptDropParams = { + reportID: string; + report: OnyxEntry; + shouldAddOrReplaceReceipt: boolean; + transactionID: string | undefined; +}; + +function useReceiptDrop({reportID, report, shouldAddOrReplaceReceipt, transactionID}: UseReceiptDropParams) { + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`); + const [newParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`); + const [currentDate] = useOnyx(ONYXKEYS.CURRENT_DATE); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy?.id}`); + const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); + const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); + const personalPolicy = usePersonalPolicy(); + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); + const hasOnlyPersonalPolicies = hasOnlyPersonalPoliciesUtil(allPolicies); + const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); + + const isReceiptReplace = useRef(false); + + const onFilesValidated = (files: FileObject[], dataTransferItems: DataTransferItem[]) => { + if (files.length === 0) { + return; + } + + if (isReceiptReplace.current && transactionID) { + const source = URL.createObjectURL(files.at(0) as Blob); + replaceReceipt({transactionID, file: files.at(0) as File, source, transactionPolicy: policy, transactionPolicyCategories: policyCategories}); + return; + } + + const initialTransaction = initMoneyRequest({ + reportID, + personalPolicy, + newIouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + report, + parentReport: newParentReport, + currentDate, + currentUserPersonalDetails, + hasOnlyPersonalPolicies, + draftTransactionIDs, + }); + + for (const [index, file] of files.entries()) { + const source = URL.createObjectURL(file as Blob); + const newTransaction = + index === 0 + ? (initialTransaction as Partial) + : buildOptimisticTransactionAndCreateDraft({ + initialTransaction: initialTransaction as Partial, + currentUserPersonalDetails, + reportID, + }); + const newTransactionID = newTransaction?.transactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID; + setMoneyRequestReceipt(newTransactionID, source, file.name ?? '', true, file.type); + setMoneyRequestParticipantsFromReport(newTransactionID, report, currentUserPersonalDetails.accountID); + } + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute( + CONST.IOU.ACTION.CREATE, + isSelfDM(report) ? CONST.IOU.TYPE.TRACK : CONST.IOU.TYPE.SUBMIT, + CONST.IOU.OPTIMISTIC_TRANSACTION_ID, + reportID, + ), + ); + }; + + const {validateFiles, PDFValidationComponent, ErrorModal} = useFilesValidation(onFilesValidated); + + const onReceiptDropped = (e: DragEvent) => { + if (policy && shouldRestrictUserBillableActions(policy.id, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); + return; + } + + const files = getFilesFromClipboardEvent(e); + const items = Array.from(e.dataTransfer?.items ?? []); + + if (shouldAddOrReplaceReceipt && transactionID) { + const file = files.at(0); + if (!file) { + return; + } + + isReceiptReplace.current = true; + validateFiles([file], items); + return; + } + + isReceiptReplace.current = false; + validateFiles(files, items, {isValidatingReceipts: true}); + }; + + return {onReceiptDropped, PDFValidationComponent, ErrorModal}; +} + +export default useReceiptDrop; From 5bf80d23873fc093d671ab19b1d7c82e3b3434c8 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Fri, 3 Apr 2026 14:54:51 +0200 Subject: [PATCH 51/57] Update consumers to use local attachment hooks --- .../ComposerActionMenu.tsx | 60 +++++++++++-------- .../ReportActionCompose/ComposerDropZone.tsx | 53 +++++++++++----- .../ComposerInputWrapper.tsx | 58 ++++++++++-------- 3 files changed, 106 insertions(+), 65 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerActionMenu.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerActionMenu.tsx index d74972efa767c..bce78e937f99e 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerActionMenu.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerActionMenu.tsx @@ -3,9 +3,12 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTriggered'; import useOnyx from '@hooks/useOnyx'; import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; +import {chatIncludesConcierge} from '@libs/ReportUtils'; +import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User'; import ONYXKEYS from '@src/ONYXKEYS'; import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; import {useComposerActions, useComposerMeta, useComposerSendState, useComposerState} from './ComposerContext'; +import useAttachmentPicker from './useAttachmentPicker'; type ComposerActionMenuProps = { reportID: string; @@ -14,7 +17,7 @@ type ComposerActionMenuProps = { function ComposerActionMenu({reportID}: ComposerActionMenuProps) { const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const {isMenuVisible, isFullComposerAvailable} = useComposerState(); - const {isBlockedFromConcierge, exceededMaxLength, validateAttachments} = useComposerSendState(); + const {exceededMaxLength} = useComposerSendState(); const {setMenuVisibility, focus, onAddActionPressed, onItemSelected, onTriggerAttachmentPicker} = useComposerActions(); const {actionButtonRef} = useComposerMeta(); @@ -24,6 +27,9 @@ function ComposerActionMenu({reportID}: ComposerActionMenuProps) { const {raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [blockedFromConcierge] = useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); + const isBlockedFromConcierge = chatIncludesConcierge({participants: report?.participants}) && isBlockedFromConciergeUserAction(blockedFromConcierge); + const {pickAttachments, PDFValidationComponent, ErrorModal} = useAttachmentPicker(reportID); const reportParticipantIDs = Object.keys(report?.participants ?? {}) .map(Number) @@ -32,30 +38,34 @@ function ComposerActionMenu({reportID}: ComposerActionMenuProps) { const shouldFocusComposerOnScreenFocus = canFocusInputOnScreenFocus() || !!draftComment; return ( - 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} - /> + <> + pickAttachments({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} + /> + {PDFValidationComponent} + {ErrorModal} + ); } diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx index dbacb7ec852dd..e12b4d2901ec7 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx @@ -15,7 +15,8 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getParentReport, isChatRoom, isGroupChat, isInvoiceReport, isReportApproved, isSettled, temporary_getMoneyRequestOptions} from '@libs/ReportUtils'; import {hasReceipt as hasReceiptTransactionUtils} from '@libs/TransactionUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import {useComposerSendState} from './ComposerContext'; +import useAttachmentPicker from './useAttachmentPicker'; +import useReceiptDrop from './useReceiptDrop'; import useShouldAddOrReplaceReceipt from './useShouldAddOrReplaceReceipt'; type ComposerDropZoneProps = { @@ -128,26 +129,50 @@ function ComposerDropZone({reportID, children}: ComposerDropZoneProps) { const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const {isOffline} = useNetwork(); const {shouldAddOrReplaceReceipt, transactionID} = useShouldAddOrReplaceReceipt(reportID, isOffline); - const {validateAttachments, onReceiptDropped} = useComposerSendState(); - - const onAttachmentDrop = (dragEvent: DragEvent) => validateAttachments({dragEvent}); + const {pickAttachments, PDFValidationComponent: AttachmentPDFValidation, ErrorModal: AttachmentErrorModal} = useAttachmentPicker(reportID); + const { + onReceiptDropped, + PDFValidationComponent: ReceiptPDFValidation, + ErrorModal: ReceiptErrorModal, + } = useReceiptDrop({ + reportID, + report, + shouldAddOrReplaceReceipt, + transactionID, + }); + + const onAttachmentDrop = (dragEvent: DragEvent) => pickAttachments({dragEvent}); // Cheap gate: rooms, groups, and invoices never show the dual drop zone. // ~60% of chats hit this path with zero extra subscriptions. if (isChatRoom(report) || isGroupChat(report) || isInvoiceReport(report)) { - return {children}; + return ( + <> + {children} + {AttachmentPDFValidation} + {AttachmentErrorModal} + {ReceiptPDFValidation} + {ReceiptErrorModal} + + ); } return ( - - {children} - + <> + + {children} + + {AttachmentPDFValidation} + {AttachmentErrorModal} + {ReceiptPDFValidation} + {ReceiptErrorModal} + ); } diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerInputWrapper.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerInputWrapper.tsx index 72f802622d032..49c2459048666 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerInputWrapper.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerInputWrapper.tsx @@ -11,6 +11,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {useComposerActions, useComposerMeta, useComposerSendActions, useComposerSendState, useComposerState} from './ComposerContext'; import ComposerWithSuggestions from './ComposerWithSuggestions'; +import useAttachmentPicker from './useAttachmentPicker'; import useComposerSubmit from './useComposerSubmit'; type ComposerInputWrapperProps = { @@ -20,7 +21,8 @@ type ComposerInputWrapperProps = { function ComposerInputWrapper({reportID}: ComposerInputWrapperProps) { const {translate} = useLocalize(); const {isMenuVisible} = useComposerState(); - const {isBlockedFromConcierge, validateAttachments} = useComposerSendState(); + const {isBlockedFromConcierge} = useComposerSendState(); + const {pickAttachments, PDFValidationComponent, ErrorModal} = useAttachmentPicker(reportID); const {setIsFullComposerAvailable, onBlur, onFocus, setComposerRef} = useComposerActions(); const {handleSendMessage, onValueChange} = useComposerSendActions(); const {containerRef, suggestionsRef, isNextModalWillOpenRef, attachmentFileRef} = useComposerMeta(); @@ -45,31 +47,35 @@ function ComposerInputWrapper({reportID}: ComposerInputWrapperProps) { const fsClass = report ? FS.getChatFSClass(report) : undefined; return ( - validateAttachments({files})} - onClear={submitForm} - disabled={isBlockedFromConcierge || isEmojiPickerVisible()} - onEnterKeyPress={handleSendMessage} - shouldShowComposeInput={shouldShowComposeInput} - onFocus={onFocus} - onBlur={onBlur} - measureParentContainer={measureContainer} - onValueChange={onValueChange} - forwardedFSClass={fsClass} - /> + <> + pickAttachments({files})} + onClear={submitForm} + disabled={isBlockedFromConcierge || isEmojiPickerVisible()} + onEnterKeyPress={handleSendMessage} + shouldShowComposeInput={shouldShowComposeInput} + onFocus={onFocus} + onBlur={onBlur} + measureParentContainer={measureContainer} + onValueChange={onValueChange} + forwardedFSClass={fsClass} + /> + {PDFValidationComponent} + {ErrorModal} + ); } From b16129f3dd7a52f28cfd9186299be8e87be0c107 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Fri, 3 Apr 2026 14:55:10 +0200 Subject: [PATCH 52/57] =?UTF-8?q?Delete=20useAttachmentUploadValidation=20?= =?UTF-8?q?=E2=80=94=20replaced=20by=20useAttachmentPicker=20+=20useReceip?= =?UTF-8?q?tDrop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../useAttachmentUploadValidation.ts | 207 ------------------ 1 file changed, 207 deletions(-) delete mode 100644 src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts diff --git a/src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts b/src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts deleted file mode 100644 index 73c61e8b712da..0000000000000 --- a/src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts +++ /dev/null @@ -1,207 +0,0 @@ -import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; -import {useContext, useRef} from 'react'; -import type {OnyxEntry} from 'react-native-onyx'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useFilesValidation from '@hooks/useFilesValidation'; -import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; -import usePersonalPolicy from '@hooks/usePersonalPolicy'; -import {cleanFileObject, cleanFileObjectName, getFilesFromClipboardEvent} from '@libs/fileDownload/FileUtils'; -import {hasOnlyPersonalPolicies as hasOnlyPersonalPoliciesUtil} from '@libs/PolicyUtils'; -import {isSelfDM} from '@libs/ReportUtils'; -import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; -import Navigation from '@navigation/Navigation'; -import AttachmentModalContext from '@pages/media/AttachmentModalScreen/AttachmentModalContext'; -import {initMoneyRequest, replaceReceipt, setMoneyRequestParticipantsFromReport, setMoneyRequestReceipt} from '@userActions/IOU'; -import {buildOptimisticTransactionAndCreateDraft} from '@userActions/TransactionEdit'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import type SCREENS from '@src/SCREENS'; -import type * as OnyxTypes from '@src/types/onyx'; -import type {FileObject} from '@src/types/utils/Attachment'; - -type AttachmentUploadValidationProps = { - reportID: string; - report: OnyxEntry; - addAttachment: (file: FileObject | FileObject[]) => void; - onAttachmentPreviewClose: () => void; - exceededMaxLength: boolean | number | null; - shouldAddOrReplaceReceipt: boolean; - transactionID: string | undefined; - isAttachmentPreviewActive: boolean; - setIsAttachmentPreviewActive: (isActive: boolean) => void; -}; - -function useAttachmentUploadValidation({ - reportID, - report, - addAttachment, - onAttachmentPreviewClose, - exceededMaxLength, - shouldAddOrReplaceReceipt, - transactionID, - isAttachmentPreviewActive, - setIsAttachmentPreviewActive, -}: AttachmentUploadValidationProps) { - const {translate} = useLocalize(); - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`); - const [newParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`); - const [currentDate] = useOnyx(ONYXKEYS.CURRENT_DATE); - const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy?.id}`); - const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); - const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); - const personalPolicy = usePersonalPolicy(); - const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); - const hasOnlyPersonalPolicies = hasOnlyPersonalPoliciesUtil(allPolicies); - const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); - - const reportAttachmentsContext = useContext(AttachmentModalContext); - const showAttachmentModalScreen = (file: FileObject | FileObject[], dataTransferItems?: DataTransferItem[]) => { - reportAttachmentsContext.setCurrentAttachment({ - reportID, - file, - dataTransferItems, - headerTitle: translate('reportActionCompose.sendAttachment'), - onConfirm: addAttachment, - onShow: () => setIsAttachmentPreviewActive(true), - onClose: onAttachmentPreviewClose, - shouldDisableSendButton: !!exceededMaxLength, - }); - Navigation.navigate(ROUTES.REPORT_ADD_ATTACHMENT.getRoute(reportID)); - }; - - const attachmentUploadType = useRef<'receipt' | 'attachment'>(undefined); - const onFilesValidated = (files: FileObject[], dataTransferItems: DataTransferItem[]) => { - if (files.length === 0) { - return; - } - - if (attachmentUploadType.current === 'attachment') { - showAttachmentModalScreen(files, dataTransferItems); - return; - } - - if (shouldAddOrReplaceReceipt && transactionID) { - const source = URL.createObjectURL(files.at(0) as Blob); - replaceReceipt({transactionID, file: files.at(0) as File, source, transactionPolicy: policy, transactionPolicyCategories: policyCategories}); - return; - } - - const initialTransaction = initMoneyRequest({ - reportID, - personalPolicy, - newIouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, - report, - parentReport: newParentReport, - currentDate, - currentUserPersonalDetails, - hasOnlyPersonalPolicies, - draftTransactionIDs, - }); - - for (const [index, file] of files.entries()) { - const source = URL.createObjectURL(file as Blob); - const newTransaction = - index === 0 - ? (initialTransaction as Partial) - : buildOptimisticTransactionAndCreateDraft({ - initialTransaction: initialTransaction as Partial, - currentUserPersonalDetails, - reportID, - }); - const newTransactionID = newTransaction?.transactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID; - setMoneyRequestReceipt(newTransactionID, source, file.name ?? '', true, file.type); - setMoneyRequestParticipantsFromReport(newTransactionID, report, currentUserPersonalDetails.accountID); - } - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute( - CONST.IOU.ACTION.CREATE, - isSelfDM(report) ? CONST.IOU.TYPE.TRACK : CONST.IOU.TYPE.SUBMIT, - CONST.IOU.OPTIMISTIC_TRANSACTION_ID, - reportID, - ), - ); - }; - - const {validateFiles, PDFValidationComponent, ErrorModal} = useFilesValidation(onFilesValidated); - - const validateAttachments = ({dragEvent, files}: {dragEvent?: DragEvent; files?: FileObject | FileObject[]}) => { - if (isAttachmentPreviewActive) { - return; - } - - let extractedFiles: FileObject[] = []; - - if (files) { - extractedFiles = Array.isArray(files) ? files : [files]; - } else { - if (!dragEvent) { - return; - } - - extractedFiles = getFilesFromClipboardEvent(dragEvent); - } - - const dataTransferItems = Array.from(dragEvent?.dataTransfer?.items ?? []); - if (extractedFiles.length === 0) { - return; - } - - const validIndices: number[] = []; - const fileObjects = extractedFiles - .map((item, index) => { - const fileObject = cleanFileObject(item); - const cleanedFileObject = cleanFileObjectName(fileObject); - if (cleanedFileObject !== null) { - validIndices.push(index); - } - return cleanedFileObject; - }) - .filter((fileObject) => fileObject !== null); - - if (!fileObjects.length) { - return; - } - - // Create a filtered items array that matches the fileObjects - const filteredItems = dataTransferItems && validIndices.length > 0 ? validIndices.map((index) => dataTransferItems.at(index) ?? ({} as DataTransferItem)) : undefined; - - attachmentUploadType.current = 'attachment'; - validateFiles(fileObjects, filteredItems, {isValidatingReceipts: false}); - }; - - const onReceiptDropped = (e: DragEvent) => { - if (policy && shouldRestrictUserBillableActions(policy.id, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed)) { - Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); - return; - } - - const files = getFilesFromClipboardEvent(e); - const items = Array.from(e.dataTransfer?.items ?? []); - - if (shouldAddOrReplaceReceipt && transactionID) { - const file = files.at(0); - if (!file) { - return; - } - - attachmentUploadType.current = 'receipt'; - validateFiles([file], items); - } - - attachmentUploadType.current = 'receipt'; - validateFiles(files, items, {isValidatingReceipts: true}); - }; - - return { - validateAttachments, - onReceiptDropped, - PDFValidationComponent, - ErrorModal, - }; -} - -export default useAttachmentUploadValidation; From d145c25124f096df4a9b347803daa28c0e6261af Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Fri, 3 Apr 2026 14:56:34 +0200 Subject: [PATCH 53/57] Fix unused param lint error in useReceiptDrop --- src/pages/inbox/report/ReportActionCompose/useReceiptDrop.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionCompose/useReceiptDrop.ts b/src/pages/inbox/report/ReportActionCompose/useReceiptDrop.ts index 68a8606594ebd..c5953e1d4fc22 100644 --- a/src/pages/inbox/report/ReportActionCompose/useReceiptDrop.ts +++ b/src/pages/inbox/report/ReportActionCompose/useReceiptDrop.ts @@ -41,7 +41,7 @@ function useReceiptDrop({reportID, report, shouldAddOrReplaceReceipt, transactio const isReceiptReplace = useRef(false); - const onFilesValidated = (files: FileObject[], dataTransferItems: DataTransferItem[]) => { + const onFilesValidated = (files: FileObject[], _dataTransferItems: DataTransferItem[]) => { if (files.length === 0) { return; } From 576f0e1235679e6e73be4cb0ef884e0f0669ffb2 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Fri, 3 Apr 2026 15:41:32 +0200 Subject: [PATCH 54/57] Remove dead context fields: isEmpty, setIsFocused, validateMaxLength, debouncedValidate --- .../report/ReportActionCompose/ComposerContext.ts | 12 ------------ .../report/ReportActionCompose/ComposerProvider.tsx | 4 ---- 2 files changed, 16 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts index 17ab1d850808b..3378fb68bbd04 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts +++ b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts @@ -28,7 +28,6 @@ type ComposerState = { // Warm — changes based on content + policy type ComposerSendState = { - isEmpty: boolean; isSendDisabled: boolean; exceededMaxLength: number | null; hasExceededMaxTaskTitleLength: boolean; @@ -38,7 +37,6 @@ type ComposerSendState = { // Frozen — stable references, never changes after mount type ComposerActions = { setValue: (v: string) => void; - setIsFocused: (v: boolean) => void; setMenuVisibility: (v: boolean) => void; setIsFullComposerAvailable: (v: boolean) => void; setComposerRef: (ref: ComposerRef | null) => void; @@ -55,12 +53,6 @@ type ComposerActions = { type ComposerSendActions = { handleSendMessage: () => void; onValueChange: (value: string) => void; - validateMaxLength: (value: string) => boolean; - debouncedValidate: { - (value: string): boolean | undefined; - cancel: () => void; - flush: () => boolean | undefined; - }; }; // Frozen — stable refs, set once @@ -85,7 +77,6 @@ const defaultState: ComposerState = { const ComposerStateContext = createContext(defaultState); const defaultSendState: ComposerSendState = { - isEmpty: true, isSendDisabled: true, exceededMaxLength: null, hasExceededMaxTaskTitleLength: false, @@ -95,7 +86,6 @@ const ComposerSendStateContext = createContext(defaultSendSta const defaultActions: ComposerActions = { setValue: noop, - setIsFocused: noop, setMenuVisibility: noop, setIsFullComposerAvailable: noop, setComposerRef: noop, @@ -112,8 +102,6 @@ const ComposerActionsContext = createContext(defaultActions); const defaultSendActions: ComposerSendActions = { handleSendMessage: noop, onValueChange: noop, - validateMaxLength: () => true, - debouncedValidate: Object.assign(() => true as boolean | undefined, {cancel: noop, flush: () => true as boolean | undefined}), }; const ComposerSendActionsContext = createContext(defaultSendActions); diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx index 58c88fe824b47..73567d6b3e585 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx @@ -144,7 +144,6 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { }; const composerSendState = { - isEmpty, isSendDisabled, exceededMaxLength, hasExceededMaxTaskTitleLength, @@ -153,7 +152,6 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { const composerActions = { setValue, - setIsFocused, setMenuVisibility, setIsFullComposerAvailable, setComposerRef, @@ -169,8 +167,6 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { const composerSendActions = { handleSendMessage, onValueChange, - validateMaxLength, - debouncedValidate, }; const composerMeta = { From 978ca84d642a2e7da4e4e7be10f460b41be3f26a Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Fri, 3 Apr 2026 15:53:18 +0200 Subject: [PATCH 55/57] Move isFocused state ownership into useComposerFocus --- .../report/ReportActionCompose/ComposerProvider.tsx | 8 +++----- .../inbox/report/ReportActionCompose/useComposerFocus.ts | 9 +++++---- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx index 73567d6b3e585..64cdd57319242 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx @@ -35,9 +35,7 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { const shouldFocusComposerOnScreenFocus = shouldFocusInputOnScreenFocus || !!draftComment; - const [isFocused, setIsFocused] = useState(() => { - return shouldFocusComposerOnScreenFocus && shouldShowComposeInput && !initialModalState?.isVisible && !initialModalState?.willAlertModalBecomeVisible; - }); + const initialFocused = shouldFocusComposerOnScreenFocus && shouldShowComposeInput && !initialModalState?.isVisible && !initialModalState?.willAlertModalBecomeVisible; const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(isComposerFullSize); const [isMenuVisible, setMenuVisibility] = useState(false); @@ -85,11 +83,11 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { const composerRefShared = useSharedValue>({}); - const {onBlur, onFocus, focus, onAddActionPressed, onItemSelected, onTriggerAttachmentPicker, isNextModalWillOpenRef} = useComposerFocus({ + const {isFocused, onBlur, onFocus, focus, onAddActionPressed, onItemSelected, onTriggerAttachmentPicker, isNextModalWillOpenRef} = useComposerFocus({ composerRef, suggestionsRef, actionButtonRef, - setIsFocused, + initialFocused, }); const clearComposer = () => { diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerFocus.ts b/src/pages/inbox/report/ReportActionCompose/useComposerFocus.ts index c6da5bd623268..78ca74dba6e36 100644 --- a/src/pages/inbox/report/ReportActionCompose/useComposerFocus.ts +++ b/src/pages/inbox/report/ReportActionCompose/useComposerFocus.ts @@ -1,4 +1,4 @@ -import {useRef} from 'react'; +import {useRef, useState} from 'react'; import type {RefObject} from 'react'; import type {BlurEvent, View} from 'react-native'; import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; @@ -11,10 +11,11 @@ type UseComposerFocusParams = { composerRef: RefObject; suggestionsRef: RefObject; actionButtonRef: RefObject; - setIsFocused: (value: boolean) => void; + initialFocused: boolean; }; -function useComposerFocus({composerRef, suggestionsRef, actionButtonRef, setIsFocused}: UseComposerFocusParams) { +function useComposerFocus({composerRef, suggestionsRef, actionButtonRef, initialFocused}: UseComposerFocusParams) { + const [isFocused, setIsFocused] = useState(initialFocused); const isKeyboardVisibleWhenShowingModalRef = useRef(false); const isNextModalWillOpenRef = useRef(false); @@ -56,7 +57,7 @@ function useComposerFocus({composerRef, suggestionsRef, actionButtonRef, setIsFo setIsFocused(true); }; - return {onBlur, onFocus, focus, onAddActionPressed, onItemSelected, onTriggerAttachmentPicker, isNextModalWillOpenRef}; + return {isFocused, onBlur, onFocus, focus, onAddActionPressed, onItemSelected, onTriggerAttachmentPicker, isNextModalWillOpenRef}; } export default useComposerFocus; From 535dc549acbdc3f3ba126584e05b489cbf0718ec Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Fri, 3 Apr 2026 16:00:39 +0200 Subject: [PATCH 56/57] Wrap PopoverMenu and EmojiPicker in Activity to skip hidden subtree reconciliation --- src/components/EmojiPicker/EmojiPicker.tsx | 82 ++++++++++--------- .../AttachmentPickerWithMenuItems.tsx | 64 ++++++++------- 2 files changed, 75 insertions(+), 71 deletions(-) diff --git a/src/components/EmojiPicker/EmojiPicker.tsx b/src/components/EmojiPicker/EmojiPicker.tsx index 1fec2354705b4..659f5805c7017 100644 --- a/src/components/EmojiPicker/EmojiPicker.tsx +++ b/src/components/EmojiPicker/EmojiPicker.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; +import React, {Activity, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; import type {ForwardedRef, RefObject} from 'react'; import {Dimensions, View} from 'react-native'; import type {Emoji} from '@assets/emojis/types'; @@ -236,45 +236,47 @@ function EmojiPicker({viewportOffsetTop, ref}: EmojiPickerProps) { }, [isEmojiPickerVisible, shouldUseNarrowLayout, emojiPopoverAnchorOrigin, getEmojiPopoverAnchor, hideEmojiPicker]); return ( - } - withoutOverlay={isWithoutOverlay} - popoverDimensions={{ - width: CONST.EMOJI_PICKER_SIZE.WIDTH, - height: CONST.EMOJI_PICKER_SIZE.HEIGHT, - }} - anchorAlignment={emojiPopoverAnchorOrigin} - outerStyle={StyleUtils.getOuterModalStyle(windowHeight, viewportOffsetTop)} - innerContainerStyle={styles.popoverInnerContainer} - anchorDimensions={emojiAnchorDimension.current} - avoidKeyboard - shouldSwitchPositionIfOverflow - shouldEnableNewFocusManagement - restoreFocusType={CONST.MODAL.RESTORE_FOCUS_TYPE.DELETE} - shouldSkipRemeasurement - > - - - { - emojiSearchInput.current = el; - }} - /> - - - + + } + withoutOverlay={isWithoutOverlay} + popoverDimensions={{ + width: CONST.EMOJI_PICKER_SIZE.WIDTH, + height: CONST.EMOJI_PICKER_SIZE.HEIGHT, + }} + anchorAlignment={emojiPopoverAnchorOrigin} + outerStyle={StyleUtils.getOuterModalStyle(windowHeight, viewportOffsetTop)} + innerContainerStyle={styles.popoverInnerContainer} + anchorDimensions={emojiAnchorDimension.current} + avoidKeyboard + shouldSwitchPositionIfOverflow + shouldEnableNewFocusManagement + restoreFocusType={CONST.MODAL.RESTORE_FOCUS_TYPE.DELETE} + shouldSkipRemeasurement + > + + + { + emojiSearchInput.current = el; + }} + /> + + + + ); } diff --git a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx index 949ffdebca882..46d92156765e4 100644 --- a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx +++ b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx @@ -1,6 +1,6 @@ import {useIsFocused} from '@react-navigation/native'; import {accountIDSelector} from '@selectors/Session'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {Activity, useCallback, useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import AttachmentPicker from '@components/AttachmentPicker'; @@ -531,37 +531,39 @@ function AttachmentPickerWithMenuItems({ )} - { - setMenuVisibility(false); - onItemSelected(); - - // In order for the file picker to open dynamically, the click - // function must be called from within a event handler that was initiated - // by the user on Safari. - if (index === menuItems.length - 1) { - if (isSafari()) { - triggerAttachmentPicker(); - return; + + { + setMenuVisibility(false); + onItemSelected(); + + // In order for the file picker to open dynamically, the click + // function must be called from within a event handler that was initiated + // by the user on Safari. + if (index === menuItems.length - 1) { + if (isSafari()) { + triggerAttachmentPicker(); + return; + } + close(() => { + triggerAttachmentPicker(); + }); } - close(() => { - triggerAttachmentPicker(); - }); - } - }} - anchorPosition={popoverAnchorPosition ?? {horizontal: 0, vertical: 0}} - anchorAlignment={{ - horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, - vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, - }} - menuItems={menuItems} - anchorRef={actionButtonRef} - /> + }} + anchorPosition={popoverAnchorPosition ?? {horizontal: 0, vertical: 0}} + anchorAlignment={{ + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, + }} + menuItems={menuItems} + anchorRef={actionButtonRef} + /> + ); }} From 2c42028076c44294bf05a5c2b069c88a27d0d80d Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Fri, 3 Apr 2026 16:20:04 +0200 Subject: [PATCH 57/57] Remove plan and spec files from PR --- ...2026-04-02-composer-context-restructure.md | 855 ------------------ .../plans/2026-04-03-attachment-hook-split.md | 692 -------------- ...-02-composer-context-restructure-design.md | 118 --- ...2026-04-03-attachment-hook-split-design.md | 71 -- 4 files changed, 1736 deletions(-) delete mode 100644 docs/superpowers/plans/2026-04-02-composer-context-restructure.md delete mode 100644 docs/superpowers/plans/2026-04-03-attachment-hook-split.md delete mode 100644 docs/superpowers/specs/2026-04-02-composer-context-restructure-design.md delete mode 100644 docs/superpowers/specs/2026-04-03-attachment-hook-split-design.md diff --git a/docs/superpowers/plans/2026-04-02-composer-context-restructure.md b/docs/superpowers/plans/2026-04-02-composer-context-restructure.md deleted file mode 100644 index ab128c069af0d..0000000000000 --- a/docs/superpowers/plans/2026-04-02-composer-context-restructure.md +++ /dev/null @@ -1,855 +0,0 @@ -# Composer Context Restructure Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Restructure 6 composer contexts from semantic grouping (state/actions/meta) to change-frequency grouping (frozen/warm/hot), move Onyx subscriptions to self-subscribing consumers, and relocate `useComposerSubmit` to its sole consumer. - -**Architecture:** 6 contexts organized by stability — 2 frozen (Actions, Meta), 3 warm (State, SendState, SendActions), 1 hot (Text). Provider sheds ~12 Onyx subscriptions by relocating `useComposerSubmit` to InputWrapper. Each compound child self-subscribes to Onyx data it previously received through context. - -**Tech Stack:** React Context, React Native Onyx (`useOnyx`), TypeScript - -**Spec:** `docs/superpowers/specs/2026-04-02-composer-context-restructure-design.md` - ---- - -All files live under `src/pages/inbox/report/ReportActionCompose/` unless otherwise noted. - -### Task 1: Rewrite ComposerContext.ts — new types and contexts - -**Files:** -- Modify: `ComposerContext.ts` - -- [ ] **Step 1: Replace all types and context definitions** - -Replace the entire file with the new 6-context structure. The old types (`ComposerState`, `ComposerSendState`, `ComposerActions`, `ComposerMeta`, `ComposerMetaActions`) are replaced by new ones aligned to change frequency. - -```ts -import type {ReactNode, RefObject} from 'react'; -import {createContext, useContext} from 'react'; -import type {BlurEvent, View} from 'react-native'; -import type {FileObject} from '@src/types/utils/Attachment'; -import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; - -type SuggestionsRef = { - resetSuggestions: () => void; - onSelectionChange?: (event: import('react-native').TextInputSelectionChangeEvent) => void; - triggerHotkeyActions: (event: KeyboardEvent) => boolean | undefined; - updateShouldShowSuggestionMenuToFalse: (shouldShowSuggestionMenu?: boolean) => void; - setShouldBlockSuggestionCalc: (shouldBlock: boolean) => void; - getSuggestions: () => import('@components/MentionSuggestions').Mention[] | import('@assets/emojis/types').Emoji[]; - getIsSuggestionsMenuVisible: () => boolean; -}; - -// ── Hot: changes every keystroke ────────────────────────── -type ComposerText = string; - -// ── Warm: changes on user interactions ──────────────────── -type ComposerState = { - isFocused: boolean; - isMenuVisible: boolean; - isFullComposerAvailable: boolean; -}; - -// ── Warm: changes on debounced validation / infrequent ──── -type ComposerSendState = { - isEmpty: boolean; - isSendDisabled: boolean; - exceededMaxLength: number | null; - hasExceededMaxTaskTitleLength: boolean; - isBlockedFromConcierge: boolean; - validateAttachments: (args: {dragEvent?: DragEvent; files?: FileObject | FileObject[]}) => void; - onReceiptDropped: (event: DragEvent) => void; -}; - -// ── Frozen: stable setters + ref-closing callbacks ──────── -type ComposerActions = { - setValue: (v: string) => void; - setIsFocused: (v: boolean) => void; - setMenuVisibility: (v: boolean) => void; - setIsFullComposerAvailable: (v: boolean) => void; - setComposerRef: (ref: ComposerRef | null) => void; - setIsAttachmentPreviewActive: (isActive: boolean) => void; - focus: () => void; - onBlur: (event: BlurEvent) => void; - onFocus: () => void; - onAddActionPressed: () => void; - onItemSelected: () => void; - onTriggerAttachmentPicker: () => void; - addAttachment: (file: FileObject | FileObject[]) => void; - onAttachmentPreviewClose: () => void; -}; - -// ── Infrequent: reactive handlers that close over state ─── -type ComposerSendActions = { - handleSendMessage: () => void; - onValueChange: (value: string) => void; - validateMaxLength: (value: string) => boolean; - debouncedValidate: { - (value: string): boolean | undefined; - cancel: () => void; - flush: () => boolean | undefined; - }; -}; - -// ── Frozen: refs only ───────────────────────────────────── -type ComposerMeta = { - containerRef: RefObject; - composerRef: RefObject; - suggestionsRef: RefObject; - actionButtonRef: RefObject; - isNextModalWillOpenRef: RefObject; - attachmentFileRef: RefObject; -}; - -// ── Defaults ────────────────────────────────────────────── - -const noop = () => {}; - -const defaultState: ComposerState = { - isFocused: false, - isMenuVisible: false, - isFullComposerAvailable: false, -}; - -const defaultSendState: ComposerSendState = { - isEmpty: true, - isSendDisabled: true, - exceededMaxLength: null, - hasExceededMaxTaskTitleLength: false, - isBlockedFromConcierge: false, - validateAttachments: noop, - onReceiptDropped: noop, -}; - -const defaultActions: ComposerActions = { - setValue: noop, - setIsFocused: noop, - setMenuVisibility: noop, - setIsFullComposerAvailable: noop, - setComposerRef: noop, - setIsAttachmentPreviewActive: noop, - focus: noop, - onBlur: noop, - onFocus: noop, - onAddActionPressed: noop, - onItemSelected: noop, - onTriggerAttachmentPicker: noop, - addAttachment: noop, - onAttachmentPreviewClose: noop, -}; - -const defaultSendActions: ComposerSendActions = { - handleSendMessage: noop, - onValueChange: noop, - validateMaxLength: () => true, - debouncedValidate: Object.assign(() => true as boolean | undefined, {cancel: noop, flush: () => true as boolean | undefined}), -}; - -// ── Contexts ────────────────────────────────────────────── - -const ComposerTextContext = createContext(''); -const ComposerStateContext = createContext(defaultState); -const ComposerSendStateContext = createContext(defaultSendState); -const ComposerActionsContext = createContext(defaultActions); -const ComposerSendActionsContext = createContext(defaultSendActions); -const ComposerMetaContext = createContext(null); - -// ── Hooks ───────────────────────────────────────────────── - -function useComposerText() { - return useContext(ComposerTextContext); -} - -function useComposerState() { - return useContext(ComposerStateContext); -} - -function useComposerSendState() { - return useContext(ComposerSendStateContext); -} - -function useComposerActions() { - return useContext(ComposerActionsContext); -} - -function useComposerSendActions() { - return useContext(ComposerSendActionsContext); -} - -function useComposerMeta() { - const ctx = useContext(ComposerMetaContext); - if (!ctx) { - throw new Error('useComposerMeta must be used inside ComposerProvider'); - } - return ctx; -} - -export { - ComposerTextContext, - ComposerStateContext, - ComposerSendStateContext, - ComposerActionsContext, - ComposerSendActionsContext, - ComposerMetaContext, - useComposerText, - useComposerState, - useComposerSendState, - useComposerActions, - useComposerSendActions, - useComposerMeta, -}; -export type {SuggestionsRef, ComposerText, ComposerState, ComposerSendState, ComposerActions, ComposerSendActions, ComposerMeta}; -``` - -- [ ] **Step 2: Commit** - -```bash -git add ComposerContext.ts -git commit -m "Rewrite ComposerContext — 6 contexts by change frequency" -``` - ---- - -### Task 2: Split useComposerSubmit - -The hook currently returns `{submitForm, addAttachment, onAttachmentPreviewClose}`. `submitForm` is the only export that has many Onyx subscriptions (~12). `addAttachment` and `onAttachmentPreviewClose` are stable (close over refs/setters only). - -Split: extract `addAttachment` and `onAttachmentPreviewClose` out of the hook so they can be defined inline in the provider. The hook keeps `submitForm` only and will be called from InputWrapper. - -**Files:** -- Modify: `useComposerSubmit.ts` - -- [ ] **Step 1: Remove addAttachment and onAttachmentPreviewClose from the hook** - -Remove the `composerRefShared`, `updateShouldShowSuggestionMenuToFalse`, and `setIsAttachmentPreviewActive` params. Remove the `addAttachment` and `onAttachmentPreviewClose` functions. Add `attachmentFileRef` as a param (previously internal). The hook now takes: - -```ts -type UseComposerSubmitParams = { - report: OnyxEntry; - reportID: string; - attachmentFileRef: RefObject; -}; -``` - -Update the hook signature and return type to only return `{submitForm}`. - -Remove these lines from the hook body: -```ts -const addAttachment = (file: FileObject | FileObject[]) => { ... }; -const onAttachmentPreviewClose = () => { ... }; -``` - -Update the return: -```ts -return {submitForm}; -``` - -Remove unused imports: `scheduleOnUI`, `ComposerFocusManager` (if only used by removed functions). - -- [ ] **Step 2: Commit** - -```bash -git add useComposerSubmit.ts -git commit -m "Split useComposerSubmit — keep only submitForm" -``` - ---- - -### Task 3: Rewrite ComposerProvider - -**Files:** -- Modify: `ComposerProvider.tsx` - -- [ ] **Step 1: Update imports** - -Replace old context imports with new ones: - -```ts -import { - ComposerActionsContext, - ComposerMetaContext, - ComposerSendActionsContext, - ComposerSendStateContext, - ComposerStateContext, - ComposerTextContext, -} from './ComposerContext'; -import type {SuggestionsRef} from './ComposerContext'; -import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; -``` - -Remove import for `useComposerSubmit`. - -- [ ] **Step 2: Rewrite the provider body** - -The provider no longer calls `useComposerSubmit`. Instead it defines `addAttachment` and `onAttachmentPreviewClose` inline. It creates `attachmentFileRef` and includes it in `ComposerMeta`. It structures 6 context value objects. - -Key changes from current provider: - -1. Add `attachmentFileRef`: -```ts -const attachmentFileRef = useRef(null); -``` - -2. Replace `useComposerSubmit` call with inline `addAttachment` and `onAttachmentPreviewClose`: -```ts -const addAttachment = (file: FileObject | FileObject[]) => { - attachmentFileRef.current = file; - const clearWorklet = composerRefShared.get().clearWorklet; - if (!clearWorklet) { - throw new Error('The composerRef.clearWorklet function is not set yet.'); - } - scheduleOnUI(clearWorklet); -}; - -const updateShouldShowSuggestionMenuToFalse = () => { - if (!suggestionsRef.current) { - return; - } - suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false); -}; - -const onAttachmentPreviewClose = () => { - updateShouldShowSuggestionMenuToFalse(); - setIsAttachmentPreviewActive(false); - ComposerFocusManager.setReadyToFocus(); -}; -``` - -3. Add `ComposerFocusManager` import: -```ts -import ComposerFocusManager from '@libs/ComposerFocusManager'; -``` - -4. Build 6 context value objects: - -```ts -const text = value; - -const composerState = { - isFocused, - isMenuVisible, - isFullComposerAvailable, -}; - -const composerSendState = { - isEmpty, - isSendDisabled, - exceededMaxLength, - hasExceededMaxTaskTitleLength, - isBlockedFromConcierge, - validateAttachments, - onReceiptDropped, -}; - -const composerActions = { - setValue, - setIsFocused, - setMenuVisibility, - setIsFullComposerAvailable, - setComposerRef, - setIsAttachmentPreviewActive, - focus, - onBlur, - onFocus, - onAddActionPressed, - onItemSelected, - onTriggerAttachmentPicker, - addAttachment, - onAttachmentPreviewClose, -}; - -const composerSendActions = { - handleSendMessage, - onValueChange, - validateMaxLength, - debouncedValidate, -}; - -const composerMeta = { - containerRef, - composerRef, - suggestionsRef, - actionButtonRef, - isNextModalWillOpenRef, - attachmentFileRef, -}; -``` - -5. Render 6 providers + JSX directly: - -```tsx -return ( - - - - - - - {children} - - - - - - {PDFValidationComponent} - {ErrorModal} - -); -``` - -Remove: all old context value object construction (`composerMetaState`, `composerMetaActions`, etc.). -Remove: the `useComposerSubmit` call and its destructuring. -Remove: old `submitForm` usage in `useAttachmentUploadValidation` params — wait, `useAttachmentUploadValidation` takes `addAttachment` and `onAttachmentPreviewClose`, which are now inline. They're already available in scope. No change needed for the validation hook call. - -- [ ] **Step 3: Commit** - -```bash -git add ComposerProvider.tsx -git commit -m "Rewrite ComposerProvider — 6 frequency-based contexts, inline addAttachment" -``` - ---- - -### Task 4: Update ComposerLocalTime — remove context, self-subscribe - -**Files:** -- Modify: `ComposerLocalTime.tsx` - -- [ ] **Step 1: Replace context with self-subscription** - -Remove: -```ts -import {useComposerState} from './ComposerContext'; -``` -and the destructure: -```ts -const {isComposerFullSize} = useComposerState(); -``` - -Add: -```ts -import ONYXKEYS from '@src/ONYXKEYS'; -``` -(if not already imported) and: -```ts -const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); -``` - -`useOnyx` is already imported in this file. `ONYXKEYS` is already imported. - -- [ ] **Step 2: Commit** - -```bash -git add ComposerLocalTime.tsx -git commit -m "ComposerLocalTime self-subscribes to isComposerFullSize" -``` - ---- - -### Task 5: Update ComposerSendButton — switch to SendState + SendActions - -**Files:** -- Modify: `ComposerSendButton.tsx` - -- [ ] **Step 1: Update context imports** - -Replace: -```ts -import {useComposerActions, useComposerSendState} from './ComposerContext'; -``` -with: -```ts -import {useComposerSendActions, useComposerSendState} from './ComposerContext'; -``` - -Replace: -```ts -const {handleSendMessage} = useComposerActions(); -``` -with: -```ts -const {handleSendMessage} = useComposerSendActions(); -``` - -- [ ] **Step 2: Commit** - -```bash -git add ComposerSendButton.tsx -git commit -m "ComposerSendButton reads from SendState + SendActions" -``` - ---- - -### Task 6: Update ComposerEmojiPicker — switch to Actions + Meta, self-subscribe - -**Files:** -- Modify: `ComposerEmojiPicker.tsx` - -- [ ] **Step 1: Update context imports and add self-subscription** - -Replace: -```ts -import {useComposerActions, useComposerMetaState, useComposerSendState} from './ComposerContext'; -``` -with: -```ts -import {useComposerActions, useComposerMeta} from './ComposerContext'; -``` - -Add Onyx imports if not present: -```ts -import useOnyx from '@hooks/useOnyx'; -import {chatIncludesConcierge} from '@libs/ReportUtils'; -import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User'; -import ONYXKEYS from '@src/ONYXKEYS'; -``` - -Replace: -```ts -const {isBlockedFromConcierge} = useComposerSendState(); -const {focus} = useComposerActions(); -const {composerRef} = useComposerMetaState(); -``` -with: -```ts -const {focus} = useComposerActions(); -const {composerRef} = useComposerMeta(); - -const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); -const [blockedFromConcierge] = useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); -const isBlockedFromConcierge = chatIncludesConcierge({participants: report?.participants}) && isBlockedFromConciergeUserAction(blockedFromConcierge); -``` - -- [ ] **Step 2: Commit** - -```bash -git add ComposerEmojiPicker.tsx -git commit -m "ComposerEmojiPicker self-subscribes to isBlockedFromConcierge" -``` - ---- - -### Task 7: Update ComposerDropZone — switch from MetaActions to SendState - -**Files:** -- Modify: `ComposerDropZone.tsx` - -- [ ] **Step 1: Update context import** - -Replace: -```ts -import {useComposerMetaActions} from './ComposerContext'; -``` -with: -```ts -import {useComposerSendState} from './ComposerContext'; -``` - -Replace: -```ts -const {validateAttachments, onReceiptDropped} = useComposerMetaActions(); -``` -with: -```ts -const {validateAttachments, onReceiptDropped} = useComposerSendState(); -``` - -- [ ] **Step 2: Commit** - -```bash -git add ComposerDropZone.tsx -git commit -m "ComposerDropZone reads from SendState" -``` - ---- - -### Task 8: Update ComposerBox — switch to State + SendState, self-subscribe - -**Files:** -- Modify: `ComposerBox.tsx` - -- [ ] **Step 1: Update context imports and add self-subscription** - -Replace: -```ts -import {useComposerMetaState, useComposerSendState, useComposerState} from './ComposerContext'; -``` -with: -```ts -import {useComposerMeta, useComposerSendState, useComposerState} from './ComposerContext'; -``` - -Replace: -```ts -const {isFocused, isComposerFullSize} = useComposerState(); -const {exceededMaxLength, isBlockedFromConcierge} = useComposerSendState(); -const {containerRef, PDFValidationComponent, ErrorModal} = useComposerMetaState(); -``` -with: -```ts -const {isFocused} = useComposerState(); -const {exceededMaxLength, isBlockedFromConcierge} = useComposerSendState(); -const {containerRef} = useComposerMeta(); -const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); -``` - -Ensure `useOnyx` and `ONYXKEYS` are imported (both already are in this file). - -Remove `PDFValidationComponent` and `ErrorModal` from the JSX. Replace: -```tsx - - {PDFValidationComponent} - {children} - -{ErrorModal} -``` -with: -```tsx - - {children} - -``` - -- [ ] **Step 2: Commit** - -```bash -git add ComposerBox.tsx -git commit -m "ComposerBox self-subscribes, PDFValidation/ErrorModal rendered by provider" -``` - ---- - -### Task 9: Update ComposerActionMenu — switch contexts, self-subscribe - -**Files:** -- Modify: `ComposerActionMenu.tsx` - -- [ ] **Step 1: Update context imports and add self-subscriptions** - -Replace: -```ts -import {useComposerActions, useComposerMetaActions, useComposerMetaState, useComposerSendState, useComposerState} from './ComposerContext'; -``` -with: -```ts -import {useComposerActions, useComposerMeta, useComposerSendState, useComposerState} from './ComposerContext'; -``` - -Replace the context reads: -```ts -const {isComposerFullSize, isFullComposerAvailable, isMenuVisible} = useComposerState(); -const {isBlockedFromConcierge, exceededMaxLength} = useComposerSendState(); -const {setMenuVisibility, focus} = useComposerActions(); -const {actionButtonRef, shouldFocusComposerOnScreenFocus} = useComposerMetaState(); -const {onAddActionPressed, onItemSelected, onTriggerAttachmentPicker, validateAttachments} = useComposerMetaActions(); -``` -with: -```ts -const {isMenuVisible, isFullComposerAvailable} = useComposerState(); -const {isBlockedFromConcierge, exceededMaxLength, validateAttachments} = useComposerSendState(); -const {setMenuVisibility, focus, onAddActionPressed, onItemSelected, onTriggerAttachmentPicker} = useComposerActions(); -const {actionButtonRef} = useComposerMeta(); - -const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); -const [draftComment] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`); -``` - -Add import for `canFocusInputOnScreenFocus` and compute `shouldFocusComposerOnScreenFocus` locally: -```ts -import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; -``` - -Before the return, add: -```ts -const shouldFocusComposerOnScreenFocus = canFocusInputOnScreenFocus() || !!draftComment; -``` - -Ensure `useOnyx` and `ONYXKEYS` are imported (already present). - -- [ ] **Step 2: Commit** - -```bash -git add ComposerActionMenu.tsx -git commit -m "ComposerActionMenu self-subscribes, reads 4 contexts" -``` - ---- - -### Task 10: Update ComposerInputWrapper — switch contexts, add useComposerSubmit, self-subscribe - -This is the largest consumer change. InputWrapper now calls `useComposerSubmit` directly (previously in provider). - -**Files:** -- Modify: `ComposerInputWrapper.tsx` - -- [ ] **Step 1: Update context imports** - -Replace: -```ts -import {useComposerActions, useComposerMetaActions, useComposerMetaState, useComposerSendState, useComposerState} from './ComposerContext'; -``` -with: -```ts -import {useComposerActions, useComposerMeta, useComposerSendActions, useComposerSendState, useComposerState} from './ComposerContext'; -``` - -- [ ] **Step 2: Add self-subscriptions and useComposerSubmit** - -Add imports: -```ts -import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; -import {chatIncludesConcierge} from '@libs/ReportUtils'; -import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User'; -import useComposerSubmit from './useComposerSubmit'; -``` - -Replace context reads: -```ts -const {isComposerFullSize, isMenuVisible} = useComposerState(); -const {isBlockedFromConcierge} = useComposerSendState(); -const {setIsFullComposerAvailable, handleSendMessage, onValueChange} = useComposerActions(); -const {containerRef, suggestionsRef, isNextModalWillOpenRef, shouldShowComposeInput, userBlockedFromConcierge} = useComposerMetaState(); -const {onBlur, onFocus, submitForm, validateAttachments, setComposerRef} = useComposerMetaActions(); -``` -with: -```ts -const {isMenuVisible} = useComposerState(); -const {isBlockedFromConcierge, validateAttachments} = useComposerSendState(); -const {setIsFullComposerAvailable, onBlur, onFocus, setComposerRef} = useComposerActions(); -const {handleSendMessage, onValueChange} = useComposerSendActions(); -const {containerRef, suggestionsRef, isNextModalWillOpenRef, attachmentFileRef} = useComposerMeta(); - -const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); -const [shouldShowComposeInput = true] = useOnyx(ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT); -const [blockedFromConcierge] = useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); - -const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); -const userBlockedFromConcierge = isBlockedFromConciergeUserAction(blockedFromConcierge); - -const {submitForm} = useComposerSubmit({report, reportID, attachmentFileRef}); -``` - -Note: `report` is already fetched via `useOnyx` in the current file — keep the existing subscription and reuse it. Remove the duplicate if there is one. - -- [ ] **Step 3: Commit** - -```bash -git add ComposerInputWrapper.tsx -git commit -m "ComposerInputWrapper self-subscribes, calls useComposerSubmit directly" -``` - ---- - -### Task 11: Update ComposerWithSuggestions — Text + Actions - -**Files:** -- Modify: `ComposerWithSuggestions/ComposerWithSuggestions.tsx` - -- [ ] **Step 1: Update context imports** - -Replace: -```ts -import {useComposerActions, useComposerValue} from '@pages/inbox/report/ReportActionCompose/ComposerContext'; -``` -with: -```ts -import {useComposerActions, useComposerText} from '@pages/inbox/report/ReportActionCompose/ComposerContext'; -``` - -Replace: -```ts -const value = useComposerValue(); -const {setValue} = useComposerActions(); -``` -with: -```ts -const value = useComposerText(); -const {setValue} = useComposerActions(); -``` - -- [ ] **Step 2: Commit** - -```bash -git add ComposerWithSuggestions/ComposerWithSuggestions.tsx -git commit -m "ComposerWithSuggestions reads Text + Actions" -``` - ---- - -### Task 12: Update ReportActionCompose — remove isComposerFullSize subscription - -**Files:** -- Modify: `ReportActionCompose.tsx` - -- [ ] **Step 1: Remove Onyx subscription** - -The orchestrator currently reads `isComposerFullSize` for layout styling. Each child now self-subscribes, but the orchestrator still needs it for the outer `View` style `isComposerFullSize && styles.chatItemFullComposeRow`. - -Keep the `useOnyx` call in the orchestrator — it's used for its own rendering, not passed to children. No change needed here. - -Verify no old context hooks are imported. The file should not import anything from `ComposerContext.ts` (it doesn't currently — it only imports the component types). - -- [ ] **Step 2: Commit (skip if no changes)** - ---- - -### Task 13: Update ComposerFooter — verify no changes needed - -**Files:** -- Verify: `ComposerFooter.tsx` - -- [ ] **Step 1: Verify** - -ComposerFooter already reads only `useComposerSendState()` for `exceededMaxLength` and `hasExceededMaxTaskTitleLength`. Both fields still exist in the new `ComposerSendStateContext`. No code changes needed. - ---- - -### Task 14: Verification — typecheck, lint, prettier - -- [ ] **Step 1: Run TypeScript check** - -```bash -cd /Users/adhorodyski/Developer/Expensify-App-w2 && npm run typecheck-tsgo -``` - -Fix any type errors. Common issues: -- Old hook names (`useComposerValue` → `useComposerText`, `useComposerMetaState` → `useComposerMeta`, `useComposerMetaActions` → removed) -- Missing imports for new hooks -- Type mismatches from restructured context types - -- [ ] **Step 2: Run ESLint** - -```bash -npx eslint src/pages/inbox/report/ReportActionCompose/ --max-warnings=0 -``` - -- [ ] **Step 3: Run Prettier** - -```bash -npx prettier --write src/pages/inbox/report/ReportActionCompose/ -``` - -- [ ] **Step 4: Commit fixes** - -```bash -git add -A && git commit -m "Fix typecheck, lint, prettier" -``` - ---- - -### Task 15: Run existing perf test - -- [ ] **Step 1: Run the ReportActionCompose perf test** - -```bash -npx reassure --testPathPattern "ReportActionCompose" -``` - -Verify render counts haven't increased. The test covers: text input interaction, create button press, send button press. - -- [ ] **Step 2: Manual verification** - -Re-profile the "click MenuItem + Escape" scenario in React DevTools. Compare against the before/after profiles from the brainstorming session. Expected: -- Render commits: ~21 (matching main), down from ~33 -- Hoverable renders: ~72 (matching main), down from ~182 -- Total duration: ~215ms (matching main), down from ~270ms diff --git a/docs/superpowers/plans/2026-04-03-attachment-hook-split.md b/docs/superpowers/plans/2026-04-03-attachment-hook-split.md deleted file mode 100644 index a35f1cc9cb419..0000000000000 --- a/docs/superpowers/plans/2026-04-03-attachment-hook-split.md +++ /dev/null @@ -1,692 +0,0 @@ -# Attachment Hook Split Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Remove `useAttachmentUploadValidation` from ComposerProvider, split into `useAttachmentPicker` (lightweight) and `useReceiptDrop` (heavy), each called by its consumer directly. - -**Architecture:** Split the mixed-concern hook into two focused hooks. Each consumer calls its own hook and renders its own validation UI. Provider sheds ~10 Onyx subscriptions and removes unstable function references from context. `addAttachment` replaced by `clearComposer` in ActionsContext + inline `attachmentFileRef` write. - -**Tech Stack:** React hooks, React Native Onyx, `useFilesValidation` - -**Spec:** `docs/superpowers/specs/2026-04-03-attachment-hook-split-design.md` - ---- - -All files under `src/pages/inbox/report/ReportActionCompose/` unless noted otherwise. - -### Task 1: Create `useAttachmentPicker` hook - -**Files:** -- Create: `useAttachmentPicker.ts` - -- [ ] **Step 1: Create the hook** - -This hook handles file picking and paste validation. It opens the attachment modal for preview. No Onyx subscriptions. - -It extracts the file-cleaning logic and `showAttachmentModalScreen` from the old `useAttachmentUploadValidation`, plus the `validateAttachments` wrapper. The `onFilesValidated` callback always opens the attachment modal (no receipt branching). - -```ts -import {useContext, useRef, useState} from 'react'; -import useFilesValidation from '@hooks/useFilesValidation'; -import useLocalize from '@hooks/useLocalize'; -import {cleanFileObject, cleanFileObjectName, getFilesFromClipboardEvent} from '@libs/fileDownload/FileUtils'; -import ComposerFocusManager from '@libs/ComposerFocusManager'; -import Navigation from '@navigation/Navigation'; -import AttachmentModalContext from '@pages/media/AttachmentModalScreen/AttachmentModalContext'; -import ROUTES from '@src/ROUTES'; -import type SCREENS from '@src/SCREENS'; -import type {FileObject} from '@src/types/utils/Attachment'; -import {useComposerActions, useComposerMeta, useComposerSendState} from './ComposerContext'; - -function useAttachmentPicker(reportID: string) { - const {translate} = useLocalize(); - const {exceededMaxLength} = useComposerSendState(); - const {clearComposer} = useComposerActions(); - const {attachmentFileRef, suggestionsRef} = useComposerMeta(); - - const [isAttachmentPreviewActive, setIsAttachmentPreviewActive] = useState(false); - - const reportAttachmentsContext = useContext(AttachmentModalContext); - - const addAttachment = (file: FileObject | FileObject[]) => { - attachmentFileRef.current = file; - clearComposer(); - }; - - const onAttachmentPreviewClose = () => { - if (suggestionsRef.current) { - suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false); - } - setIsAttachmentPreviewActive(false); - ComposerFocusManager.setReadyToFocus(); - }; - - const showAttachmentModalScreen = (file: FileObject | FileObject[], dataTransferItems?: DataTransferItem[]) => { - reportAttachmentsContext.setCurrentAttachment({ - reportID, - file, - dataTransferItems, - headerTitle: translate('reportActionCompose.sendAttachment'), - onConfirm: addAttachment, - onShow: () => setIsAttachmentPreviewActive(true), - onClose: onAttachmentPreviewClose, - shouldDisableSendButton: !!exceededMaxLength, - }); - Navigation.navigate(ROUTES.REPORT_ADD_ATTACHMENT.getRoute(reportID)); - }; - - const onFilesValidated = (files: FileObject[], dataTransferItems: DataTransferItem[]) => { - if (files.length === 0) { - return; - } - showAttachmentModalScreen(files, dataTransferItems); - }; - - const {validateFiles, PDFValidationComponent, ErrorModal} = useFilesValidation(onFilesValidated); - - const pickAttachments = ({dragEvent, files}: {dragEvent?: DragEvent; files?: FileObject | FileObject[]}) => { - if (isAttachmentPreviewActive) { - return; - } - - let extractedFiles: FileObject[] = []; - - if (files) { - extractedFiles = Array.isArray(files) ? files : [files]; - } else { - if (!dragEvent) { - return; - } - extractedFiles = getFilesFromClipboardEvent(dragEvent); - } - - const dataTransferItems = Array.from(dragEvent?.dataTransfer?.items ?? []); - if (extractedFiles.length === 0) { - return; - } - - const validIndices: number[] = []; - const fileObjects = extractedFiles - .map((item, index) => { - const fileObject = cleanFileObject(item); - const cleanedFileObject = cleanFileObjectName(fileObject); - if (cleanedFileObject !== null) { - validIndices.push(index); - } - return cleanedFileObject; - }) - .filter((fileObject) => fileObject !== null); - - if (!fileObjects.length) { - return; - } - - const filteredItems = dataTransferItems && validIndices.length > 0 ? validIndices.map((index) => dataTransferItems.at(index) ?? ({} as DataTransferItem)) : undefined; - - validateFiles(fileObjects, filteredItems, {isValidatingReceipts: false}); - }; - - return {pickAttachments, PDFValidationComponent, ErrorModal}; -} - -export default useAttachmentPicker; -``` - -- [ ] **Step 2: Commit** - -```bash -git add useAttachmentPicker.ts -git commit -m "Create useAttachmentPicker — lightweight file validation hook" -``` - ---- - -### Task 2: Create `useReceiptDrop` hook - -**Files:** -- Create: `useReceiptDrop.ts` - -- [ ] **Step 1: Create the hook** - -This hook handles receipt drag-and-drop. It owns all the Onyx subscriptions for receipt creation (`policy`, `policyCategories`, `newParentReport`, `currentDate`, etc.). Only used by ComposerDropZone. - -```ts -import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; -import {useRef, useState} from 'react'; -import type {OnyxEntry} from 'react-native-onyx'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useFilesValidation from '@hooks/useFilesValidation'; -import useNetwork from '@hooks/useNetwork'; -import useOnyx from '@hooks/useOnyx'; -import usePersonalPolicy from '@hooks/usePersonalPolicy'; -import {cleanFileObject, cleanFileObjectName, getFilesFromClipboardEvent} from '@libs/fileDownload/FileUtils'; -import {hasOnlyPersonalPolicies as hasOnlyPersonalPoliciesUtil} from '@libs/PolicyUtils'; -import {isSelfDM} from '@libs/ReportUtils'; -import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; -import Navigation from '@navigation/Navigation'; -import {initMoneyRequest, replaceReceipt, setMoneyRequestParticipantsFromReport, setMoneyRequestReceipt} from '@userActions/IOU'; -import {buildOptimisticTransactionAndCreateDraft} from '@userActions/TransactionEdit'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import type * as OnyxTypes from '@src/types/onyx'; -import type {FileObject} from '@src/types/utils/Attachment'; -import {useComposerActions, useComposerMeta} from './ComposerContext'; - -type UseReceiptDropParams = { - reportID: string; - report: OnyxEntry; - shouldAddOrReplaceReceipt: boolean; - transactionID: string | undefined; -}; - -function useReceiptDrop({reportID, report, shouldAddOrReplaceReceipt, transactionID}: UseReceiptDropParams) { - const {isOffline} = useNetwork(); - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const {clearComposer} = useComposerActions(); - const {attachmentFileRef} = useComposerMeta(); - - const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`); - const [newParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`); - const [currentDate] = useOnyx(ONYXKEYS.CURRENT_DATE); - const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy?.id}`); - const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); - const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); - const personalPolicy = usePersonalPolicy(); - const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); - const hasOnlyPersonalPolicies = hasOnlyPersonalPoliciesUtil(allPolicies); - const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); - - const [isAttachmentPreviewActive, setIsAttachmentPreviewActive] = useState(false); - - const onFilesValidated = (files: FileObject[], _dataTransferItems: DataTransferItem[]) => { - if (files.length === 0) { - return; - } - - if (shouldAddOrReplaceReceipt && transactionID) { - const source = URL.createObjectURL(files.at(0) as Blob); - replaceReceipt({transactionID, file: files.at(0) as File, source, transactionPolicy: policy, transactionPolicyCategories: policyCategories}); - return; - } - - const initialTransaction = initMoneyRequest({ - reportID, - personalPolicy, - newIouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, - report, - parentReport: newParentReport, - currentDate, - currentUserPersonalDetails, - hasOnlyPersonalPolicies, - draftTransactionIDs, - }); - - for (const [index, file] of files.entries()) { - const source = URL.createObjectURL(file as Blob); - const newTransaction = - index === 0 - ? (initialTransaction as Partial) - : buildOptimisticTransactionAndCreateDraft({ - initialTransaction: initialTransaction as Partial, - currentUserPersonalDetails, - reportID, - }); - const newTransactionID = newTransaction?.transactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID; - setMoneyRequestReceipt(newTransactionID, source, file.name ?? '', true, file.type); - setMoneyRequestParticipantsFromReport(newTransactionID, report, currentUserPersonalDetails.accountID); - } - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute( - CONST.IOU.ACTION.CREATE, - isSelfDM(report) ? CONST.IOU.TYPE.TRACK : CONST.IOU.TYPE.SUBMIT, - CONST.IOU.OPTIMISTIC_TRANSACTION_ID, - reportID, - ), - ); - }; - - const {validateFiles, PDFValidationComponent, ErrorModal} = useFilesValidation(onFilesValidated); - - const onReceiptDropped = (e: DragEvent) => { - if (policy && shouldRestrictUserBillableActions(policy.id, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed)) { - Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); - return; - } - - const files = getFilesFromClipboardEvent(e); - const items = Array.from(e.dataTransfer?.items ?? []); - - if (shouldAddOrReplaceReceipt && transactionID) { - const file = files.at(0); - if (!file) { - return; - } - - validateFiles([file], items); - return; - } - - validateFiles(files, items, {isValidatingReceipts: true}); - }; - - const pickAttachmentsDrop = ({dragEvent}: {dragEvent: DragEvent}) => { - if (isAttachmentPreviewActive) { - return; - } - - const extractedFiles = getFilesFromClipboardEvent(dragEvent); - const dataTransferItems = Array.from(dragEvent.dataTransfer?.items ?? []); - if (extractedFiles.length === 0) { - return; - } - - const validIndices: number[] = []; - const fileObjects = extractedFiles - .map((item, index) => { - const fileObject = cleanFileObject(item); - const cleanedFileObject = cleanFileObjectName(fileObject); - if (cleanedFileObject !== null) { - validIndices.push(index); - } - return cleanedFileObject; - }) - .filter((fileObject) => fileObject !== null); - - if (!fileObjects.length) { - return; - } - - const filteredItems = validIndices.length > 0 ? validIndices.map((index) => dataTransferItems.at(index) ?? ({} as DataTransferItem)) : undefined; - - validateFiles(fileObjects, filteredItems, {isValidatingReceipts: false}); - }; - - return {onReceiptDropped, pickAttachmentsDrop, PDFValidationComponent, ErrorModal}; -} - -export default useReceiptDrop; -``` - -Note: `pickAttachmentsDrop` handles the attachment drop path (non-receipt files dropped onto the composer). DropZone needs this because it handles both attachment drops and receipt drops. The receipt path goes through `onReceiptDropped`, the attachment path through `pickAttachmentsDrop`. - -- [ ] **Step 2: Commit** - -```bash -git add useReceiptDrop.ts -git commit -m "Create useReceiptDrop — receipt creation hook with Onyx subscriptions" -``` - ---- - -### Task 3: Update `ComposerContext.ts` — remove attachment types from contexts - -**Files:** -- Modify: `ComposerContext.ts` - -- [ ] **Step 1: Update types** - -Remove from `ComposerSendState`: -```ts -validateAttachments: (args: {dragEvent?: DragEvent; files?: FileObject | FileObject[]}) => void; -onReceiptDropped: (event: DragEvent) => void; -``` - -Remove from `ComposerActions`: -```ts -setIsAttachmentPreviewActive: (isActive: boolean) => void; -addAttachment: (file: FileObject | FileObject[]) => void; -onAttachmentPreviewClose: () => void; -``` - -Add to `ComposerActions`: -```ts -clearComposer: () => void; -``` - -Update `defaultSendState` — remove `validateAttachments: noop` and `onReceiptDropped: noop`. - -Update `defaultActions` — remove `setIsAttachmentPreviewActive: noop`, `addAttachment: noop`, `onAttachmentPreviewClose: noop`. Add `clearComposer: noop`. - -Remove `FileObject` import if no longer needed (check — `attachmentFileRef` in `ComposerMeta` still uses it, so keep it). - -- [ ] **Step 2: Commit** - -```bash -git add ComposerContext.ts -git commit -m "Remove attachment types from contexts, add clearComposer" -``` - ---- - -### Task 4: Update `ComposerProvider.tsx` — remove hook and add `clearComposer` - -**Files:** -- Modify: `ComposerProvider.tsx` - -- [ ] **Step 1: Remove attachment-related code** - -Remove these imports: -```ts -import ComposerFocusManager from '@libs/ComposerFocusManager'; -import type {FileObject} from '@src/types/utils/Attachment'; -import useAttachmentUploadValidation from './useAttachmentUploadValidation'; -import useShouldAddOrReplaceReceipt from './useShouldAddOrReplaceReceipt'; -``` - -Remove from the function body: -- `const {shouldAddOrReplaceReceipt, transactionID} = useShouldAddOrReplaceReceipt(reportID, isOffline);` -- `const [isAttachmentPreviewActive, setIsAttachmentPreviewActive] = useState(false);` -- The entire `addAttachment` function -- The entire `updateShouldShowSuggestionMenuToFalse` function -- The entire `onAttachmentPreviewClose` function -- The entire `useAttachmentUploadValidation` call and destructuring - -Add `clearComposer` function (uses `composerRefShared`, which stays in the provider): -```ts -const clearComposer = () => { - const clearWorklet = composerRefShared.get().clearWorklet; - if (!clearWorklet) { - throw new Error('The composerRef.clearWorklet function is not set yet. This should never happen, and indicates a developer error.'); - } - scheduleOnUI(clearWorklet); -}; -``` - -Update `composerSendState` — remove `validateAttachments` and `onReceiptDropped`: -```ts -const composerSendState = { - isEmpty, - isSendDisabled, - exceededMaxLength, - hasExceededMaxTaskTitleLength, - isBlockedFromConcierge, -}; -``` - -Update `composerActions` — remove `setIsAttachmentPreviewActive`, `addAttachment`, `onAttachmentPreviewClose`. Add `clearComposer`: -```ts -const composerActions = { - setValue, - setIsFocused, - setMenuVisibility, - setIsFullComposerAvailable, - setComposerRef, - clearComposer, - focus, - onBlur, - onFocus, - onAddActionPressed, - onItemSelected, - onTriggerAttachmentPicker, -}; -``` - -Update JSX — remove `{PDFValidationComponent}` and `{ErrorModal}` from the return: -```tsx -return ( - - - - - - {children} - - - - - -); -``` - -Remove `{isOffline}` from `useNetwork()` if it's no longer used (it was only passed to `useShouldAddOrReplaceReceipt`). Check if `useNetwork` is still needed — if not, remove the call and import. - -- [ ] **Step 2: Commit** - -```bash -git add ComposerProvider.tsx -git commit -m "Remove attachment hook from provider, add clearComposer" -``` - ---- - -### Task 5: Update `ComposerActionMenu.tsx` — call `useAttachmentPicker` directly - -**Files:** -- Modify: `ComposerActionMenu.tsx` - -- [ ] **Step 1: Switch to local hook** - -Remove `useComposerSendState` from context import (ActionMenu no longer reads `validateAttachments` from it). Keep `useComposerState` for `isMenuVisible`/`isFullComposerAvailable`. - -Add self-subscription for `isBlockedFromConcierge` and `exceededMaxLength` (these were in SendState before, but ActionMenu still needs them): -```ts -import {chatIncludesConcierge} from '@libs/ReportUtils'; -import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User'; -import useAttachmentPicker from './useAttachmentPicker'; -``` - -Replace: -```ts -const {isBlockedFromConcierge, exceededMaxLength, validateAttachments} = useComposerSendState(); -``` -with: -```ts -const [blockedFromConcierge] = useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); -const isBlockedFromConcierge = chatIncludesConcierge({participants: report?.participants}) && isBlockedFromConciergeUserAction(blockedFromConcierge); -const {pickAttachments, PDFValidationComponent, ErrorModal} = useAttachmentPicker(reportID); -``` - -Note: `exceededMaxLength` was used for `shouldDisableAttachmentItem`. It's now read inside `useAttachmentPicker` internally. For the `shouldDisableAttachmentItem` prop on `AttachmentPickerWithMenuItems`, ActionMenu needs it directly. Add a self-subscription: -```ts -// exceededMaxLength is still available from useComposerSendState for the disable prop -const {exceededMaxLength} = useComposerSendState(); -``` - -Actually, keep `useComposerSendState` but only destructure `exceededMaxLength`: -```ts -const {exceededMaxLength} = useComposerSendState(); -``` - -Replace `onAttachmentPicked`: -```ts -onAttachmentPicked={(files) => pickAttachments({files})} -``` - -Render validation UI after `AttachmentPickerWithMenuItems`: -```tsx -return ( - <> - - {PDFValidationComponent} - {ErrorModal} - -); -``` - -- [ ] **Step 2: Commit** - -```bash -git add ComposerActionMenu.tsx -git commit -m "ComposerActionMenu calls useAttachmentPicker directly" -``` - ---- - -### Task 6: Update `ComposerInputWrapper.tsx` — call `useAttachmentPicker` for paste - -**Files:** -- Modify: `ComposerInputWrapper.tsx` - -- [ ] **Step 1: Switch to local hook** - -Remove `validateAttachments` from `useComposerSendState`: -```ts -const {isBlockedFromConcierge} = useComposerSendState(); -``` - -Add: -```ts -import useAttachmentPicker from './useAttachmentPicker'; -``` - -Call the hook: -```ts -const {pickAttachments, PDFValidationComponent, ErrorModal} = useAttachmentPicker(reportID); -``` - -Replace: -```ts -onPasteFile={(files) => validateAttachments({files})} -``` -with: -```ts -onPasteFile={(files) => pickAttachments({files})} -``` - -Wrap the return in a fragment to include validation UI: -```tsx -return ( - <> - - {PDFValidationComponent} - {ErrorModal} - -); -``` - -- [ ] **Step 2: Commit** - -```bash -git add ComposerInputWrapper.tsx -git commit -m "ComposerInputWrapper calls useAttachmentPicker for paste" -``` - ---- - -### Task 7: Update `ComposerDropZone.tsx` — call `useReceiptDrop` directly - -**Files:** -- Modify: `ComposerDropZone.tsx` - -- [ ] **Step 1: Switch to local hook** - -Remove: -```ts -import {useComposerSendState} from './ComposerContext'; -``` - -Add: -```ts -import useReceiptDrop from './useReceiptDrop'; -``` - -Replace: -```ts -const {validateAttachments, onReceiptDropped} = useComposerSendState(); -``` -with: -```ts -const {onReceiptDropped, pickAttachmentsDrop, PDFValidationComponent, ErrorModal} = useReceiptDrop({ - reportID, - report, - shouldAddOrReplaceReceipt, - transactionID, -}); -``` - -Replace: -```ts -const onAttachmentDrop = (dragEvent: DragEvent) => validateAttachments({dragEvent}); -``` -with: -```ts -const onAttachmentDrop = (dragEvent: DragEvent) => pickAttachmentsDrop({dragEvent}); -``` - -Render validation UI. Wrap the early return and main return to include it: - -For the SimpleDropZone path: -```tsx -return ( - <> - {children} - {PDFValidationComponent} - {ErrorModal} - -); -``` - -For the RichDropZone path: -```tsx -return ( - <> - {children} - {PDFValidationComponent} - {ErrorModal} - -); -``` - -- [ ] **Step 2: Commit** - -```bash -git add ComposerDropZone.tsx -git commit -m "ComposerDropZone calls useReceiptDrop directly" -``` - ---- - -### Task 8: Delete `useAttachmentUploadValidation.ts` - -**Files:** -- Delete: `useAttachmentUploadValidation.ts` - -- [ ] **Step 1: Delete the file** - -```bash -git rm useAttachmentUploadValidation.ts -``` - -- [ ] **Step 2: Commit** - -```bash -git commit -m "Delete useAttachmentUploadValidation — replaced by useAttachmentPicker + useReceiptDrop" -``` - ---- - -### Task 9: Verification - -- [ ] **Step 1: TypeScript check** - -```bash -npm run typecheck-tsgo -``` - -Fix any type errors. Common issues: -- Old references to `validateAttachments` / `onReceiptDropped` / `addAttachment` / `onAttachmentPreviewClose` in context types -- Missing `clearComposer` in consumer destructuring - -- [ ] **Step 2: ESLint** - -```bash -npx eslint src/pages/inbox/report/ReportActionCompose/ --max-warnings=0 -``` - -- [ ] **Step 3: Prettier** - -```bash -npx prettier --write src/pages/inbox/report/ReportActionCompose/ -``` - -- [ ] **Step 4: Commit fixes** - -```bash -git add -A && git commit -m "Fix typecheck, lint, prettier" -``` diff --git a/docs/superpowers/specs/2026-04-02-composer-context-restructure-design.md b/docs/superpowers/specs/2026-04-02-composer-context-restructure-design.md deleted file mode 100644 index 68ee95c0f872b..0000000000000 --- a/docs/superpowers/specs/2026-04-02-composer-context-restructure-design.md +++ /dev/null @@ -1,118 +0,0 @@ -# Composer Context Restructure - -## Problem - -The ReportActionCompose compound component decomposition (branch `decompose/report-action-composer-v2`) introduces a +25% render duration regression and +59% more render commits vs main. Root causes: - -1. **Context value instability** — `ComposerActionsContext` contains `handleSendMessage` which closes over `isSendDisabled` (changes on empty/non-empty flip), causing all action consumers to re-render. `ComposerMetaContext` contains JSX nodes (`PDFValidationComponent`, `ErrorModal`) that are recreated each render. -2. **Provider as data intermediary** — ComposerProvider fetches ~8 Onyx keys and distributes them through context. Per CLEAN-REACT-PATTERNS-2, components should self-subscribe. -3. **Contexts split by semantic type, not change frequency** — Stable refs and unstable JSX nodes share `ComposerMetaContext`. Stable setters and unstable handlers share `ComposerActionsContext`. - -Profiling shows +110 Hoverable renders and +110 GenericTooltip renders from ActionMenu and InputWrapper re-rendering on context instability, cascading into their internal component trees. - -## Design - -### Principle - -- Each component self-subscribes to Onyx data (PATTERNS-2) -- Only ephemeral coordination state goes through context (PATTERNS-5) -- Provider + compound component pattern maintained (PATTERNS-1) -- Contexts organized by change frequency: frozen, warm, hot -- State/actions split enforced per codebase convention - -### Contexts (6) - -| Context | Contents | Frequency | -|---|---|---| -| `ComposerTextContext` | `value` | Every keystroke | -| `ComposerStateContext` | `isFocused`, `isMenuVisible`, `isFullComposerAvailable` | User interactions | -| `ComposerSendStateContext` | `isEmpty`, `isSendDisabled`, `exceededMaxLength`, `hasExceededMaxTaskTitleLength`, `isBlockedFromConcierge`, `validateAttachments`, `onReceiptDropped` | Debounced / infrequent | -| `ComposerActionsContext` | `setValue`, `setIsFocused`, `setMenuVisibility`, `setIsFullComposerAvailable`, `setComposerRef`, `setIsAttachmentPreviewActive`, `focus`, `onBlur`, `onFocus`, `onAddActionPressed`, `onItemSelected`, `onTriggerAttachmentPicker`, `addAttachment`, `onAttachmentPreviewClose` | Frozen | -| `ComposerSendActionsContext` | `handleSendMessage`, `onValueChange`, `validateMaxLength`, `debouncedValidate` | Infrequent | -| `ComposerMetaContext` | `containerRef`, `composerRef`, `suggestionsRef`, `actionButtonRef`, `isNextModalWillOpenRef` | Frozen | - -### Per-component subscriptions - -| Component | Contexts | Self-subscribes (Onyx) | -|---|---|---| -| ComposerBox | State, SendState | `isComposerFullSize`, `report` | -| ComposerDropZone | SendState | *(existing Onyx subs)* | -| ComposerActionMenu | State, SendState, Actions, Meta | `isComposerFullSize`, `report`, `shouldFocusComposerOnScreenFocus` | -| ComposerInputWrapper | State, SendState, SendActions, Actions, Meta | `isComposerFullSize`, `shouldShowComposeInput`, `userBlockedFromConcierge`, `report` | -| ComposerEmojiPicker | Actions, Meta | `isBlockedFromConcierge` | -| ComposerSendButton | SendState, SendActions | — | -| ComposerFooter | SendState | — | -| ComposerLocalTime | — | `isComposerFullSize` | -| ComposerWithSuggestions | Text, Actions | — | - -### Values removed from context - -| Value | Reason | New home | -|---|---|---| -| `isComposerFullSize` | Onyx key, each component reads directly | `useOnyx(REPORT_IS_COMPOSER_FULL_SIZE + reportID)` in Box, ActionMenu, InputWrapper, LocalTime, ReportActionCompose | -| `shouldShowComposeInput` | Onyx key, one consumer | `useOnyx(SHOULD_SHOW_COMPOSE_INPUT)` in InputWrapper | -| `shouldFocusComposerOnScreenFocus` | Derived from Onyx + platform check, one consumer | Compute locally in ActionMenu | -| `userBlockedFromConcierge` | Derived from Onyx, one consumer | Compute in InputWrapper | -| `isAttachmentPreviewActive` | Only used by provider internally | Provider-internal state | -| `PDFValidationComponent` | JSX node, was causing context instability | Rendered directly by Provider as sibling | -| `ErrorModal` | JSX node, was causing context instability | Rendered directly by Provider as sibling | - -### Hook relocation - -| Hook | From | To | Reason | -|---|---|---|---| -| `useComposerSubmit` | ComposerProvider | ComposerInputWrapper | `submitForm` has one consumer; removes ~12 Onyx subscriptions from provider | -| `useComposerFocus` | ComposerProvider | ComposerProvider (stays) | All outputs are stable (close over refs + useState setters); shared by multiple consumers | -| `useAttachmentUploadValidation` | ComposerProvider | ComposerProvider (stays) | `validateAttachments` shared by 3 consumers; JSX rendering moves out of context | - -`useComposerSubmit` will be split: `addAttachment` and `onAttachmentPreviewClose` (stable, used by validation hook) stay in provider. `submitForm` (unstable, one consumer) moves to InputWrapper. - -### Provider rendering - -```tsx -return ( - - - - - - - {children} - - - - - - {PDFValidationComponent} - {ErrorModal} - -); -``` - -## File changes - -| File | Action | -|---|---| -| `ComposerContext.ts` | Rewrite — 6 contexts + 6 hooks replacing current 6 + 6 | -| `ComposerProvider.tsx` | Major rewrite — remove `useComposerSubmit`, restructure context values, render JSX directly | -| `ComposerBox.tsx` | Update imports (3 to 2 contexts), add `useOnyx` for `isComposerFullSize` | -| `ComposerDropZone.tsx` | Update import (`MetaActions` to `SendState`) | -| `ComposerActionMenu.tsx` | Update imports (5 to 4 contexts), add self-subscriptions | -| `ComposerInputWrapper.tsx` | Update imports (5 to 5 contexts, different set), add `useComposerSubmit`, add self-subscriptions | -| `ComposerEmojiPicker.tsx` | Update imports (3 to 2 contexts), add self-subscription for `isBlockedFromConcierge` | -| `ComposerSendButton.tsx` | Update imports (2 to 2 contexts, different ones) | -| `ComposerFooter.tsx` | No change | -| `ComposerLocalTime.tsx` | Remove context, add `useOnyx` for `isComposerFullSize` | -| `ComposerWithSuggestions.tsx` | Update imports (Value+Actions to Text+Actions) | -| `ReportActionCompose.tsx` | Remove `useOnyx` for `isComposerFullSize` | -| `useComposerSubmit.ts` | Split — extract `addAttachment` + `onAttachmentPreviewClose` to stay in provider | -| `useComposerFocus.ts` | No change | -| `useAttachmentUploadValidation.ts` | No change | - -## Expected impact - -- **Keystroke path**: Only `ComposerTextContext` fires. Only `ComposerWithSuggestions` re-renders. ActionMenu, Box, SendButton, Footer untouched. -- **Menu toggle**: Only `ComposerStateContext` fires. Only Box, ActionMenu, InputWrapper re-render. -- **Provider Onyx subscriptions**: ~20 total down to ~8 after moving `useComposerSubmit` out. -- **Frozen contexts**: `ComposerActionsContext` and `ComposerMetaContext` never fire — holds the bulk of the shared API. -- **Hoverable/GenericTooltip renders**: Should drop from ~182/110 back to ~72 range (matching main) since ActionMenu no longer re-renders on keystroke. diff --git a/docs/superpowers/specs/2026-04-03-attachment-hook-split-design.md b/docs/superpowers/specs/2026-04-03-attachment-hook-split-design.md deleted file mode 100644 index f5ba2c346d259..0000000000000 --- a/docs/superpowers/specs/2026-04-03-attachment-hook-split-design.md +++ /dev/null @@ -1,71 +0,0 @@ -# Attachment Hook Split - -## Problem - -`useAttachmentUploadValidation` sits in ComposerProvider with ~10 Onyx subscriptions, mixing file validation (lightweight) with receipt creation (heavy). Its outputs (`validateAttachments`, `onReceiptDropped`) flow through SendState context, causing instability — the compiler tracks them as dependencies, and any Onyx change rebuilds the functions, cascading re-renders to all SendState consumers. - -## Design - -### New hooks - -**`useAttachmentPicker(reportID)`** — lightweight, no Onyx subscriptions -- Calls `useFilesValidation` with callback that opens attachment modal -- Returns: `pickAttachments({dragEvent?, files?})`, `PDFValidationComponent`, `ErrorModal` -- Consumers: ComposerActionMenu (file pick), ComposerInputWrapper (paste) - -**`useReceiptDrop(reportID)`** — heavy, owns all receipt Onyx subscriptions -- Calls `useFilesValidation` with callback that handles receipt creation/replacement -- Returns: `onReceiptDropped(event)`, `PDFValidationComponent`, `ErrorModal` -- Consumer: ComposerDropZone only - -Each consumer renders its own `PDFValidationComponent`/`ErrorModal` — they render null when inactive, zero cost. - -### Coordination: `addAttachment` replacement - -Current `addAttachment` writes to `attachmentFileRef` and clears composer via `composerRefShared`. Replace with: - -- Add `clearComposer` to `ComposerActionsContext` (stable — wraps `scheduleOnUI(composerRefShared.get().clearWorklet)`) -- Each consumer inlines: `attachmentFileRef.current = file; clearComposer();` -- `attachmentFileRef` in MetaContext, `clearComposer` in Actions — both frozen - -`isAttachmentPreviewActive` becomes local state in each consumer. - -`onAttachmentPreviewClose` inlined at call sites (3 lines: reset suggestions, clear preview state, restore focus). - -### Provider changes - -Remove: -- `useAttachmentUploadValidation` call -- `addAttachment` / `onAttachmentPreviewClose` inline functions -- `isAttachmentPreviewActive` state -- `useShouldAddOrReplaceReceipt` call (moves to DropZone — already called there) -- `PDFValidationComponent` / `ErrorModal` from render tree - -Add: -- `clearComposer` to ActionsContext - -### Context type changes - -| Context | Removed | Added | -|---|---|---| -| ComposerSendStateContext | `validateAttachments`, `onReceiptDropped` | — | -| ComposerActionsContext | `addAttachment`, `onAttachmentPreviewClose`, `setIsAttachmentPreviewActive` | `clearComposer` | - -### File changes - -| File | Action | -|---|---| -| `useAttachmentUploadValidation.ts` | Delete | -| New: `useAttachmentPicker.ts` | ~50 lines | -| New: `useReceiptDrop.ts` | ~80 lines | -| `ComposerContext.ts` | Update SendState and Actions types | -| `ComposerProvider.tsx` | Remove hook/state/functions, add `clearComposer` | -| `ComposerActionMenu.tsx` | Call `useAttachmentPicker`, render validation UI | -| `ComposerInputWrapper.tsx` | Call `useAttachmentPicker` for paste, render validation UI | -| `ComposerDropZone.tsx` | Call `useReceiptDrop`, render validation UI | - -### Expected impact - -- Provider Onyx subscriptions: ~8 fewer (all receipt-related subs move to DropZone) -- `composerSendState` no longer depends on `validateAttachments`/`onReceiptDropped` — removes the unstable function references the compiler was tracking -- `composerActions` no longer depends on `addAttachment` — removes `composerRefShared` from the dependency chain