From e10317285dc9ae46ccae7219421ff2ab1b8d121d Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Tue, 11 Aug 2026 18:51:43 +0200 Subject: [PATCH 01/11] Add Concierge prompt box to home For You section --- src/CONST/index.ts | 1 + src/languages/en.ts | 7 ++ src/libs/DateUtils.ts | 26 +++++ .../home/ForYouSection/ConciergePromptBox.tsx | 99 +++++++++++++++++++ src/pages/home/ForYouSection/index.tsx | 11 ++- src/styles/index.ts | 36 +++++++ 6 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 src/pages/home/ForYouSection/ConciergePromptBox.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 40671c15fc60..0d776f1b2c12 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -619,6 +619,7 @@ const CONST = { FNS_TIMEZONE_FORMAT_STRING: "yyyy-MM-dd'T'HH:mm:ssXXX", FNS_DB_FORMAT_STRING: 'yyyy-MM-dd HH:mm:ss.SSS', LONG_DATE_FORMAT_WITH_WEEKDAY: 'eeee, MMMM d, yyyy', + LONG_DATE_FORMAT_WITH_WEEKDAY_WITHOUT_YEAR: 'eeee, MMMM d', ORDINAL_DAY_OF_MONTH: 'do', MONTH_DAY_YEAR_ORDINAL_FORMAT: 'MMMM do, yyyy', SECONDS_PER_DAY: 24 * 60 * 60, diff --git a/src/languages/en.ts b/src/languages/en.ts index 774e7a4b5a93..62f336b454b7 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -973,6 +973,13 @@ const translations = { }, homePage: { forYou: 'For you', + conciergePrompt: { + goodMorning: ({name}: {name?: string}) => (name ? `Good morning, ${name}.` : 'Good morning.'), + goodAfternoon: ({name}: {name?: string}) => (name ? `Good afternoon, ${name}.` : 'Good afternoon.'), + goodEvening: ({name}: {name?: string}) => (name ? `Good evening, ${name}.` : 'Good evening.'), + inputPlaceholder: 'Ask Concierge to analyze your expenses or get support', + inputPlaceholderMobile: 'Ask Concierge anything', + }, timeSensitiveSection: { title: 'Time sensitive', ctaFix: 'Fix', diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index 8fbfce4cf21c..67818b7d97cb 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -259,6 +259,30 @@ function formatToLongDateWithWeekday(datetime: string | Date, dateFnsLocale: Dat return format(new Date(datetime), CONST.DATE.LONG_DATE_FORMAT_WITH_WEEKDAY, {locale: dateFnsLocale}); } +/** + * Format date to a long date format with weekday but without the year + * + * @returns Sunday, July 9 + */ +function formatToLongDateWithWeekdayWithoutYear(datetime: string | Date, dateFnsLocale: DateFnsLocale | undefined): string { + return format(new Date(datetime), CONST.DATE.LONG_DATE_FORMAT_WITH_WEEKDAY_WITHOUT_YEAR, {locale: dateFnsLocale}); +} + +/** + * Get the time-of-day greeting key based on the hour of the given (already timezone-adjusted) date. + * Ranges per PRD: morning 5am–12pm, afternoon 12–5pm, evening 5pm–5am. + */ +function getTimeOfDayGreetingKey(date: Date): 'goodMorning' | 'goodAfternoon' | 'goodEvening' { + const hour = date.getHours(); + if (hour >= 4 && hour < 12) { + return 'goodMorning'; + } + if (hour >= 12 && hour < 17) { + return 'goodAfternoon'; + } + return 'goodEvening'; +} + /** * Format date to a weekday format * @@ -1181,6 +1205,8 @@ const DateUtils = { isDate, formatToDayOfWeek, formatToLongDateWithWeekday, + formatToLongDateWithWeekdayWithoutYear, + getTimeOfDayGreetingKey, formatToLocalTime, formatToReadableString, getZoneAbbreviation, diff --git a/src/pages/home/ForYouSection/ConciergePromptBox.tsx b/src/pages/home/ForYouSection/ConciergePromptBox.tsx new file mode 100644 index 000000000000..11b081ec2797 --- /dev/null +++ b/src/pages/home/ForYouSection/ConciergePromptBox.tsx @@ -0,0 +1,99 @@ +import Icon from '@components/Icon'; +import {PressableWithFeedback} from '@components/Pressable'; +import RNTextInput from '@components/RNTextInput'; +import useAskConcierge from '@components/Search/SearchRouter/useAskConcierge'; +import Text from '@components/Text'; + +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import DateUtils from '@libs/DateUtils'; + +import variables from '@styles/variables'; + +import React, {useState} from 'react'; +import {View} from 'react-native'; + +function ConciergePromptBox() { + const styles = useThemeStyles(); + const theme = useTheme(); + const {translate, getLocalDateFromDatetime, dateFnsLocale} = useLocalize(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {firstName} = useCurrentUserPersonalDetails(); + const {askConcierge, shouldShowAskConcierge} = useAskConcierge(); + const icons = useMemoizedLazyExpensifyIcons(['Plus', 'Send']); + const [value, setValue] = useState(''); + const [isFocused, setIsFocused] = useState(false); + + // Current moment in the user's timezone (resolved by the localization provider). + const localNow = getLocalDateFromDatetime(); + const dateLabel = DateUtils.formatToLongDateWithWeekdayWithoutYear(localNow, dateFnsLocale); + const greeting = translate(`homePage.conciergePrompt.${DateUtils.getTimeOfDayGreetingKey(localNow)}`, {name: firstName}); + const placeholder = translate(shouldUseNarrowLayout ? 'homePage.conciergePrompt.inputPlaceholderMobile' : 'homePage.conciergePrompt.inputPlaceholder'); + const canSubmit = shouldShowAskConcierge && value.trim().length > 0; + + const submit = () => { + if (!canSubmit) { + return; + } + askConcierge(value); + setValue(''); + }; + + return ( + + + {dateLabel} + {greeting} + + + + + + + + + setIsFocused(true)} + onBlur={() => setIsFocused(false)} + placeholder={placeholder} + placeholderTextColor={theme.placeholderText} + onSubmitEditing={submit} + submitBehavior="submit" + returnKeyType="send" + accessibilityLabel={placeholder} + /> + + + + + + ); +} + +ConciergePromptBox.displayName = 'ConciergePromptBox'; + +export default ConciergePromptBox; diff --git a/src/pages/home/ForYouSection/index.tsx b/src/pages/home/ForYouSection/index.tsx index d5c79257a972..8772dfd84a16 100644 --- a/src/pages/home/ForYouSection/index.tsx +++ b/src/pages/home/ForYouSection/index.tsx @@ -1,4 +1,5 @@ import BaseWidgetItem from '@components/BaseWidgetItem'; +import Text from '@components/Text'; import WidgetContainer from '@components/WidgetContainer'; import {useAppLoadSkeletonState} from '@hooks/useInFlightRequests'; @@ -27,6 +28,7 @@ import {useIsFocused} from '@react-navigation/native'; import React, {useCallback, useEffect, useMemo} from 'react'; import {View} from 'react-native'; +import ConciergePromptBox from './ConciergePromptBox'; import EmptyState from './EmptyState'; import ForYouSkeleton from './ForYouSkeleton'; import shouldHideForYouSection from './shouldHideForYouSection'; @@ -208,7 +210,14 @@ function ForYouSection() { return null; } - return {renderContent()}; + return ( + }> + + {translate('homePage.forYou')} + + {renderContent()} + + ); } export default ForYouSection; diff --git a/src/styles/index.ts b/src/styles/index.ts index d364a6465f57..5260e6827349 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -7188,6 +7188,42 @@ const plainStyles = (theme: ThemeColors) => marginTop: shouldUseNarrowLayout ? 20 : 32, }) satisfies ViewStyle, + getConciergePromptBoxContainerStyle: (isFocused: boolean) => + ({ + borderWidth: 1, + borderColor: isFocused ? theme.borderFocus : theme.border, + borderRadius: variables.componentBorderRadiusRounded, + backgroundColor: theme.appBG, + minHeight: variables.componentSizeMedium, + }) satisfies ViewStyle, + + conciergePromptBoxAddButton: { + alignItems: 'center', + justifyContent: 'center', + height: variables.componentSizeNormal, + paddingLeft: 8, + paddingRight: 4, + }, + + conciergePromptBoxDivider: { + width: 1, + alignSelf: 'stretch', + backgroundColor: theme.border, + }, + + conciergePromptBoxInput: { + color: theme.text, + height: variables.componentSizeNormal, + } satisfies TextStyle, + + conciergePromptBoxSendButton: { + alignItems: 'center', + justifyContent: 'center', + width: variables.componentSizeNormal, + height: variables.componentSizeNormal, + borderRadius: variables.componentBorderRadiusRounded, + }, + getWidgetItemIconContainerStyle: (backgroundColor: string) => ({ alignItems: 'center', From 6af486bed2029dfe8207a67c36374d7569e30872 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Tue, 11 Aug 2026 19:35:10 +0200 Subject: [PATCH 02/11] Add attachment support to Concierge prompt box on Home --- .../Search/SearchRouter/useAskConcierge.tsx | 35 +++++- src/libs/DateUtils.ts | 2 +- .../home/ForYouSection/ConciergePromptBox.tsx | 111 ++++++++++++++++-- .../useConciergeAttachmentPicker.ts | 51 ++++++++ 4 files changed, 182 insertions(+), 17 deletions(-) create mode 100644 src/pages/home/ForYouSection/useConciergeAttachmentPicker.ts diff --git a/src/components/Search/SearchRouter/useAskConcierge.tsx b/src/components/Search/SearchRouter/useAskConcierge.tsx index e4ba0680b438..8500cee46a4b 100644 --- a/src/components/Search/SearchRouter/useAskConcierge.tsx +++ b/src/components/Search/SearchRouter/useAskConcierge.tsx @@ -6,21 +6,26 @@ import useSidePanelReportID from '@hooks/useSidePanelReportID'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {addComment} from '@userActions/Report'; +import {addAttachmentWithComment, addComment} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {FileObject} from '@src/types/utils/Attachment'; /** * Returns a callback that opens the side panel (or Concierge chat on native) * and sends the provided search query as a message. * Also returns a flag indicating whether the Ask Concierge item is ready to be displayed. + * + * @param forceConcierge Always target the Concierge report, ignoring the report the side panel currently maps to. + * Entry points whose sole purpose is messaging Concierge (e.g. the Home prompt) must set this, otherwise the message + * can be posted to the workspace #admins room the side panel maps to during the onboarding RHP variants. */ -function useAskConcierge() { +function useAskConcierge({forceConcierge = false}: {forceConcierge?: boolean} = {}) { const sidePanelReportID = useSidePanelReportID(); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const {openConciergeAnywhere, isInSidePanel} = useOpenConciergeAnywhere(); - const targetReportID = (isInSidePanel ? sidePanelReportID : undefined) ?? conciergeReportID; + const targetReportID = forceConcierge ? conciergeReportID : ((isInSidePanel ? sidePanelReportID : undefined) ?? conciergeReportID); const [targetReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(targetReportID)}`); const {timezone, accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const delegateAccountID = useDelegateAccountID(); @@ -31,7 +36,7 @@ function useAskConcierge() { if (!trimmedQuery || !shouldShowAskConcierge) { return; } - openConciergeAnywhere(); + openConciergeAnywhere({forceConcierge}); addComment({ report: targetReport, notifyReportID: targetReportID, @@ -46,7 +51,27 @@ function useAskConcierge() { }); }; - return {askConcierge, shouldShowAskConcierge}; + const askConciergeWithAttachment = (attachments: FileObject | FileObject[], searchQuery: string) => { + if (!shouldShowAskConcierge) { + return; + } + openConciergeAnywhere({forceConcierge}); + addAttachmentWithComment({ + report: targetReport, + notifyReportID: targetReportID, + ancestors: [], + attachments, + currentUserAccountID, + text: searchQuery.trim(), + timezone: timezone ?? CONST.DEFAULT_TIME_ZONE, + shouldPlaySound: true, + isInSidePanel, + delegateAccountID, + conciergeReportID, + }); + }; + + return {askConcierge, askConciergeWithAttachment, shouldShowAskConcierge, conciergeTargetReportID: targetReportID}; } export default useAskConcierge; diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index 67818b7d97cb..053a0063879b 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -270,7 +270,7 @@ function formatToLongDateWithWeekdayWithoutYear(datetime: string | Date, dateFns /** * Get the time-of-day greeting key based on the hour of the given (already timezone-adjusted) date. - * Ranges per PRD: morning 5am–12pm, afternoon 12–5pm, evening 5pm–5am. + * Ranges: morning 4am to 12pm, afternoon 12pm to 5pm, evening 5pm to 4am. */ function getTimeOfDayGreetingKey(date: Date): 'goodMorning' | 'goodAfternoon' | 'goodEvening' { const hour = date.getHours(); diff --git a/src/pages/home/ForYouSection/ConciergePromptBox.tsx b/src/pages/home/ForYouSection/ConciergePromptBox.tsx index 11b081ec2797..406977ce6d14 100644 --- a/src/pages/home/ForYouSection/ConciergePromptBox.tsx +++ b/src/pages/home/ForYouSection/ConciergePromptBox.tsx @@ -1,4 +1,6 @@ +import AttachmentPicker from '@components/AttachmentPicker'; import Icon from '@components/Icon'; +import PopoverMenu from '@components/PopoverMenu'; import {PressableWithFeedback} from '@components/Pressable'; import RNTextInput from '@components/RNTextInput'; import useAskConcierge from '@components/Search/SearchRouter/useAskConcierge'; @@ -7,27 +9,60 @@ import Text from '@components/Text'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import usePopoverPosition from '@hooks/usePopoverPosition'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import {isSafari} from '@libs/Browser'; import DateUtils from '@libs/DateUtils'; import variables from '@styles/variables'; -import React, {useState} from 'react'; +import {close} from '@userActions/Modal'; + +import CONST from '@src/CONST'; +import type {AnchorPosition} from '@src/styles'; +import type {FileObject} from '@src/types/utils/Attachment'; + +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; +import useConciergeAttachmentPicker from './useConciergeAttachmentPicker'; + function ConciergePromptBox() { const styles = useThemeStyles(); const theme = useTheme(); const {translate, getLocalDateFromDatetime, dateFnsLocale} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {firstName} = useCurrentUserPersonalDetails(); - const {askConcierge, shouldShowAskConcierge} = useAskConcierge(); - const icons = useMemoizedLazyExpensifyIcons(['Plus', 'Send']); + const {askConcierge, askConciergeWithAttachment, shouldShowAskConcierge, conciergeTargetReportID} = useAskConcierge({forceConcierge: true}); + const icons = useMemoizedLazyExpensifyIcons(['Plus', 'Send', 'Paperclip']); + const {calculatePopoverPosition} = usePopoverPosition(); const [value, setValue] = useState(''); const [isFocused, setIsFocused] = useState(false); + const [isMenuVisible, setIsMenuVisible] = useState(false); + const [popoverAnchorPosition, setPopoverAnchorPosition] = useState(null); + const actionButtonRef = useRef(null); + + const sendAttachment = (attachments: FileObject | FileObject[]) => { + askConciergeWithAttachment(attachments, value); + setValue(''); + }; + const {pickAttachments, PDFValidationComponent} = useConciergeAttachmentPicker(conciergeTargetReportID, sendAttachment); + + // Anchor the "+" popover above the button, mirroring the composer's attachment menu. + useEffect(() => { + if (!actionButtonRef.current || !isMenuVisible) { + return; + } + calculatePopoverPosition(actionButtonRef, { + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, + }).then((position) => { + setPopoverAnchorPosition({...position, vertical: position.vertical - CONST.MODAL.POPOVER_MENU_PADDING}); + }); + }, [isMenuVisible, calculatePopoverPosition]); // Current moment in the user's timezone (resolved by the localization provider). const localNow = getLocalDateFromDatetime(); @@ -52,14 +87,67 @@ function ConciergePromptBox() { - - - + + {({openPicker}) => { + const triggerAttachmentPicker = () => openPicker({onPicked: pickAttachments}); + return ( + <> + { + e?.preventDefault(); + actionButtonRef.current?.blur(); + setIsMenuVisible((prev) => !prev); + }} + style={styles.conciergePromptBoxAddButton} + > + + + setIsMenuVisible(false)} + onItemSelected={() => { + setIsMenuVisible(false); + + // On Safari the file picker must be opened from within the user-initiated + // event handler, so it can't wait for the popover to finish closing. + if (isSafari()) { + triggerAttachmentPicker(); + return; + } + close(triggerAttachmentPicker); + }} + anchorPosition={popoverAnchorPosition ?? {horizontal: 0, vertical: 0}} + anchorAlignment={{ + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, + }} + menuItems={[ + { + icon: icons.Paperclip, + text: translate('reportActionCompose.addAttachment'), + shouldCallAfterModalHide: shouldUseNarrowLayout, + }, + ]} + anchorRef={actionButtonRef} + /> + + ); + }} + + {PDFValidationComponent} ); } diff --git a/src/pages/home/ForYouSection/useConciergeAttachmentPicker.ts b/src/pages/home/ForYouSection/useConciergeAttachmentPicker.ts new file mode 100644 index 000000000000..3d873414a5b1 --- /dev/null +++ b/src/pages/home/ForYouSection/useConciergeAttachmentPicker.ts @@ -0,0 +1,51 @@ +import useFilesValidation from '@hooks/useFilesValidation'; +import useLocalize from '@hooks/useLocalize'; + +import {cleanFileObject, cleanFileObjectName} 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 {useContext} from 'react'; + +/** + * Lets the Concierge prompt box pick file(s) and open the shared attachment preview modal. + * On confirm the modal invokes `onConfirm`, which is where the caller actually sends the attachment to Concierge. + */ +function useConciergeAttachmentPicker(reportID: string | undefined, onConfirm: (files: FileObject | FileObject[]) => void) { + const {translate} = useLocalize(); + const reportAttachmentsContext = useContext(AttachmentModalContext); + + const onFilesValidated = (files: FileObject[]) => { + if (files.length === 0 || !reportID) { + return; + } + + reportAttachmentsContext.setCurrentAttachment({ + reportID, + file: files, + headerTitle: translate('reportActionCompose.sendAttachment'), + onConfirm, + }); + Navigation.navigate(ROUTES.REPORT_ADD_ATTACHMENT.getRoute(reportID)); + }; + + const {validateFiles, PDFValidationComponent} = useFilesValidation(onFilesValidated); + + const pickAttachments = (files: FileObject[]) => { + const fileObjects = files.map((item) => cleanFileObjectName(cleanFileObject(item))); + if (!fileObjects.length) { + return; + } + validateFiles(fileObjects, undefined, {isValidatingReceipts: false}); + }; + + return {pickAttachments, PDFValidationComponent}; +} + +export default useConciergeAttachmentPicker; From 1c3ae702158c03d31597be3d3ae2bd91213570df Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Wed, 12 Aug 2026 11:56:31 +0200 Subject: [PATCH 03/11] Add Concierge prompt translations for non-English languages --- src/languages/de.ts | 7 +++++++ src/languages/el.ts | 7 +++++++ src/languages/es.ts | 7 +++++++ src/languages/fr.ts | 7 +++++++ src/languages/it.ts | 7 +++++++ src/languages/ja.ts | 7 +++++++ src/languages/nl.ts | 7 +++++++ src/languages/pl.ts | 7 +++++++ src/languages/pt-BR.ts | 7 +++++++ src/languages/zh-hans.ts | 7 +++++++ 10 files changed, 70 insertions(+) diff --git a/src/languages/de.ts b/src/languages/de.ts index 9de8a6238564..ec36eaf95432 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1092,6 +1092,13 @@ const translations: TranslationDeepObject = { emptyStateMessage: 'Erstellen Sie eine oder ziehen Sie eine Quittung hierher', }, insightsSection: {chartUnavailable: 'Diagramm nicht verfügbar', notEnoughData: 'Wir haben noch nicht genügend Daten, um dieses Diagramm auszufüllen'}, + conciergePrompt: { + goodMorning: ({name}: {name?: string}) => (name ? `Guten Morgen, ${name}.` : 'Guten Morgen.'), + goodAfternoon: ({name}: {name?: string}) => (name ? `Guten Tag, ${name}.` : 'Guten Tag.'), + goodEvening: ({name}: {name?: string}) => (name ? `Guten Abend, ${name}.` : 'Guten Abend.'), + inputPlaceholder: 'Bitten Sie Concierge, Ihre Ausgaben zu analysieren oder Unterstützung zu erhalten', + inputPlaceholderMobile: 'Stellen Sie Concierge eine Frage', + }, }, allSettingsScreen: { subscription: 'Abonnement', diff --git a/src/languages/el.ts b/src/languages/el.ts index 81d3aeb08997..e86a3707a58c 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -1138,6 +1138,13 @@ const translations: TranslationDeepObject = { today: 'Σήμερα', }, insightsSection: {chartUnavailable: 'Το γράφημα δεν είναι διαθέσιμο', notEnoughData: 'Δεν έχουμε ακόμη αρκετά δεδομένα για να συμπληρώσουμε αυτό το γράφημα'}, + conciergePrompt: { + goodMorning: ({name}: {name?: string}) => (name ? `Καλημέρα, ${name}.` : 'Καλημέρα.'), + goodAfternoon: ({name}: {name?: string}) => (name ? `Καλησπέρα σας, ${name}.` : 'Καλό απόγευμα.'), + goodEvening: ({name}: {name?: string}) => (name ? `Καλησπέρα, ${name}.` : 'Καλησπέρα.'), + inputPlaceholder: 'Ζητήστε από το Concierge να αναλύσει τα έξοδά σας ή να λάβετε υποστήριξη', + inputPlaceholderMobile: 'Ρωτήστε το Concierge οτιδήποτε', + }, }, allSettingsScreen: { subscription: 'Συνδρομή', diff --git a/src/languages/es.ts b/src/languages/es.ts index d0af721f6450..94cdfc2e0446 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1087,6 +1087,13 @@ const translations: TranslationDeepObject = { emptyStateMessage: 'Crea uno o arrastra un recibo aquí', }, insightsSection: {chartUnavailable: 'Gráfico no disponible', notEnoughData: 'Todavía no tenemos suficientes datos para completar este gráfico'}, + conciergePrompt: { + goodMorning: ({name}: {name?: string}) => (name ? `Buenos días, ${name}.` : 'Buenos días.'), + goodAfternoon: ({name}: {name?: string}) => (name ? `Buenas tardes, ${name}.` : 'Buenas tardes.'), + goodEvening: ({name}: {name?: string}) => (name ? `Buenas noches, ${name}.` : 'Buenas noches.'), + inputPlaceholder: 'Pídele a Concierge que analice tus gastos o que te ayude', + inputPlaceholderMobile: 'Pregunta a Concierge cualquier cosa', + }, }, allSettingsScreen: { subscription: 'Suscripcion', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 42787610f428..fce34d5383ef 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1095,6 +1095,13 @@ const translations: TranslationDeepObject = { emptyStateMessage: 'Créez-en un ou faites glisser un reçu ici', }, insightsSection: {chartUnavailable: 'Graphique indisponible', notEnoughData: 'Nous n’avons pas encore suffisamment de données pour remplir ce graphique'}, + conciergePrompt: { + goodMorning: ({name}: {name?: string}) => (name ? `Bonjour, ${name}.` : 'Bonjour.'), + goodAfternoon: ({name}: {name?: string}) => (name ? `Bonjour, ${name}.` : 'Bon après-midi.'), + goodEvening: ({name}: {name?: string}) => (name ? `Bonsoir, ${name}.` : 'Bonsoir.'), + inputPlaceholder: 'Demander à Concierge d’analyser vos dépenses ou d’obtenir de l’aide', + inputPlaceholderMobile: 'Demander n’importe quoi à Concierge', + }, }, allSettingsScreen: { subscription: 'Abonnement', diff --git a/src/languages/it.ts b/src/languages/it.ts index 6a362596b501..d92b66d5984d 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1093,6 +1093,13 @@ const translations: TranslationDeepObject = { emptyStateMessage: 'Creane una o trascina qui una ricevuta', }, insightsSection: {chartUnavailable: 'Grafico non disponibile', notEnoughData: 'Non abbiamo ancora abbastanza dati per compilare questo grafico'}, + conciergePrompt: { + goodMorning: ({name}: {name?: string}) => (name ? `Buongiorno, ${name}.` : 'Buongiorno.'), + goodAfternoon: ({name}: {name?: string}) => (name ? `Buon pomeriggio, ${name}.` : 'Buon pomeriggio.'), + goodEvening: ({name}: {name?: string}) => (name ? `Buonasera, ${name}.` : 'Buona sera.'), + inputPlaceholder: 'Chiedi a Concierge di analizzare le tue spese o chiedi supporto', + inputPlaceholderMobile: 'Chiedi qualsiasi cosa a Concierge', + }, }, allSettingsScreen: { subscription: 'Abbonamento', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 9a5267c3cefe..f097d883b0e3 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1076,6 +1076,13 @@ const translations: TranslationDeepObject = { emptyStateMessage: '新規作成するか、レシートをここにドラッグしてください', }, insightsSection: {chartUnavailable: 'グラフを表示できません', notEnoughData: 'このチャートを表示するためのデータがまだ十分にありません'}, + conciergePrompt: { + goodMorning: ({name}: {name?: string}) => (name ? `${name}さん、おはようございます。` : 'おはようございます。'), + goodAfternoon: ({name}: {name?: string}) => (name ? `${name}さん、こんにちは。` : 'こんにちは。'), + goodEvening: ({name}: {name?: string}) => (name ? `${name}さん、こんばんは。` : 'こんばんは。'), + inputPlaceholder: 'Concierge に経費の分析を依頼するか、サポートを受けます', + inputPlaceholderMobile: 'Concierge に何でも聞いてください', + }, }, allSettingsScreen: { subscription: 'サブスクリプション', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 3cd6ee5a3860..64b216cef51a 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1091,6 +1091,13 @@ const translations: TranslationDeepObject = { emptyStateMessage: 'Maak er een aan of sleep hier een bonnetje naartoe', }, insightsSection: {chartUnavailable: 'Diagram niet beschikbaar', notEnoughData: 'We hebben nog niet genoeg gegevens om deze grafiek te vullen'}, + conciergePrompt: { + goodMorning: ({name}: {name?: string}) => (name ? `Goedemorgen, ${name}.` : 'Goedemorgen.'), + goodAfternoon: ({name}: {name?: string}) => (name ? `Goedemiddag, ${name}.` : 'Goedemiddag.'), + goodEvening: ({name}: {name?: string}) => (name ? `Goedenavond, ${name}.` : 'Goedenavond.'), + inputPlaceholder: 'Vraag Concierge om je uitgaven te analyseren of om hulp te krijgen', + inputPlaceholderMobile: 'Stel Concierge alles gerust een vraag', + }, }, allSettingsScreen: { subscription: 'Abonnement', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 303b2c3eb23d..9a31c0d0b5fc 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1089,6 +1089,13 @@ const translations: TranslationDeepObject = { emptyStateMessage: 'Utwórz jeden lub przeciągnij tu paragon', }, insightsSection: {chartUnavailable: 'Wykres niedostępny', notEnoughData: 'Nie mamy jeszcze wystarczającej ilości danych, żeby wypełnić ten wykres'}, + conciergePrompt: { + goodMorning: ({name}: {name?: string}) => (name ? `Dzień dobry, ${name}.` : 'Dzień dobry.'), + goodAfternoon: ({name}: {name?: string}) => (name ? `Dzień dobry, ${name}.` : 'Dzień dobry.'), + goodEvening: ({name}: {name?: string}) => (name ? `Dobry wieczór, ${name}.` : 'Dobry wieczór.'), + inputPlaceholder: 'Poproś Concierge o przeanalizowanie swoich wydatków lub uzyskaj pomoc', + inputPlaceholderMobile: 'Zapytaj Concierge o cokolwiek', + }, }, allSettingsScreen: { subscription: 'Subskrypcja', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 8f41bd76602d..6712ef26e794 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1091,6 +1091,13 @@ const translations: TranslationDeepObject = { emptyStateMessage: 'Crie um ou arraste um recibo aqui', }, insightsSection: {chartUnavailable: 'Gráfico indisponível', notEnoughData: 'Ainda não temos dados suficientes para preencher este gráfico'}, + conciergePrompt: { + goodMorning: ({name}: {name?: string}) => (name ? `Bom dia, ${name}.` : 'Bom dia.'), + goodAfternoon: ({name}: {name?: string}) => (name ? `Boa tarde, ${name}.` : 'Boa tarde.'), + goodEvening: ({name}: {name?: string}) => (name ? `Boa noite, ${name}.` : 'Boa noite.'), + inputPlaceholder: 'Peça ao Concierge para analisar suas despesas ou obter suporte', + inputPlaceholderMobile: 'Pergunte qualquer coisa ao Concierge', + }, }, allSettingsScreen: { subscription: 'Assinatura', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index e09675eb46e8..7d20a7f71c70 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1047,6 +1047,13 @@ const translations: TranslationDeepObject = { seeMore: ({count}: {count: number}) => `再查看 ${count} 个`, recentlyAddedSection: {title: '最近添加', viewAll: '查看所有报销费用', emptyStateTitle: '最近没有报销记录', emptyStateMessage: '创建一个或将收据拖到这里'}, insightsSection: {chartUnavailable: '图表不可用', notEnoughData: '我们目前没有足够的数据来填充此图表'}, + conciergePrompt: { + goodMorning: ({name}: {name?: string}) => (name ? `早上好,${name}。` : '早上好。'), + goodAfternoon: ({name}: {name?: string}) => (name ? `下午好,${name}。` : '下午好。'), + goodEvening: ({name}: {name?: string}) => (name ? `晚上好,${name}。` : '晚上好。'), + inputPlaceholder: '向 Concierge 请求分析你的报销或获取支持', + inputPlaceholderMobile: '向 Concierge 提问任何问题', + }, }, allSettingsScreen: { subscription: '订阅', From ef3cde94f82eaf921ed87ed6c2fbe9d0ab714944 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Wed, 12 Aug 2026 12:54:59 +0200 Subject: [PATCH 04/11] Use Expensify Neue font in Concierge prompt box input --- src/styles/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/styles/index.ts b/src/styles/index.ts index 5260e6827349..0a9231271e80 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -7214,6 +7214,7 @@ const plainStyles = (theme: ThemeColors) => conciergePromptBoxInput: { color: theme.text, height: variables.componentSizeNormal, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE.fontFamily, } satisfies TextStyle, conciergePromptBoxSendButton: { From 4ed69f9017f8019797cf5f38a8e2ec153a7aded6 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Wed, 12 Aug 2026 13:18:55 +0200 Subject: [PATCH 05/11] Add getTimeOfDayGreetingKey unit tests --- tests/unit/DateUtilsTest.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/unit/DateUtilsTest.ts b/tests/unit/DateUtilsTest.ts index be81192c0676..346857b79cf6 100644 --- a/tests/unit/DateUtilsTest.ts +++ b/tests/unit/DateUtilsTest.ts @@ -724,4 +724,27 @@ describe('DateUtils', () => { expect(DateUtils.getRemainingSecondsInWindow(Date.now() - 31 * 1000, windowMs)).toBe(0); }); }); + + describe('getTimeOfDayGreetingKey', () => { + const atHour = (hour: number, minute = 0) => set(new Date(), {hours: hour, minutes: minute, seconds: 0, milliseconds: 0}); + + it('should return goodMorning from 4am up to noon', () => { + expect(DateUtils.getTimeOfDayGreetingKey(atHour(4))).toBe('goodMorning'); + expect(DateUtils.getTimeOfDayGreetingKey(atHour(8, 30))).toBe('goodMorning'); + expect(DateUtils.getTimeOfDayGreetingKey(atHour(11, 59))).toBe('goodMorning'); + }); + + it('should return goodAfternoon from noon up to 5pm', () => { + expect(DateUtils.getTimeOfDayGreetingKey(atHour(12))).toBe('goodAfternoon'); + expect(DateUtils.getTimeOfDayGreetingKey(atHour(14, 15))).toBe('goodAfternoon'); + expect(DateUtils.getTimeOfDayGreetingKey(atHour(16, 59))).toBe('goodAfternoon'); + }); + + it('should return goodEvening from 5pm up to 4am', () => { + expect(DateUtils.getTimeOfDayGreetingKey(atHour(17))).toBe('goodEvening'); + expect(DateUtils.getTimeOfDayGreetingKey(atHour(21))).toBe('goodEvening'); + expect(DateUtils.getTimeOfDayGreetingKey(atHour(0))).toBe('goodEvening'); + expect(DateUtils.getTimeOfDayGreetingKey(atHour(3, 59))).toBe('goodEvening'); + }); + }); }); From cb1e69e1efe71d7a42ff0dac5f9109cd0966d45d Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Wed, 12 Aug 2026 14:15:30 +0200 Subject: [PATCH 06/11] Bump Mobile-Expensify submodule to match main --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 7d586deb2421..b9ee1a7ddd64 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 7d586deb2421fd1246c67e903bf0e86028ba0974 +Subproject commit b9ee1a7ddd6456bf187fe0a39315a2e167426da2 From 8b032f0134f58e487b794fc533f5d8eca9654439 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Wed, 12 Aug 2026 14:21:42 +0200 Subject: [PATCH 07/11] Mock ConciergePromptBox in ForYouSection test after merge --- tests/ui/ForYouSectionTest.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/ui/ForYouSectionTest.tsx b/tests/ui/ForYouSectionTest.tsx index 56dca0c33ae7..25c65eced0a7 100644 --- a/tests/ui/ForYouSectionTest.tsx +++ b/tests/ui/ForYouSectionTest.tsx @@ -57,6 +57,11 @@ jest.mock('@pages/home/ForYouSection/ForYouSkeleton', () => () => { return ReactModule.createElement('View', {testID: 'for-you-skeleton'}); }); +jest.mock('@pages/home/ForYouSection/ConciergePromptBox', () => () => { + const ReactModule = jest.requireActual('react'); + return ReactModule.createElement('View', {testID: 'concierge-prompt-box'}); +}); + // ForYouSection calls useIsFocused() to freeze useTodoCounts when unfocused; this test renders it outside a // NavigationContainer, so stub the focus hook (useTodoCounts is mocked, so the focus value itself is irrelevant). jest.mock('@react-navigation/native', () => { From 728b747dfeac475338a2471b7ab641984201adff Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Wed, 12 Aug 2026 18:19:58 +0200 Subject: [PATCH 08/11] Fix stale Concierge history flash on side panel reopen --- src/hooks/useConciergeSidePanelReportActions.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/hooks/useConciergeSidePanelReportActions.ts b/src/hooks/useConciergeSidePanelReportActions.ts index 0b9e97ec0f2c..c272c899fa45 100644 --- a/src/hooks/useConciergeSidePanelReportActions.ts +++ b/src/hooks/useConciergeSidePanelReportActions.ts @@ -180,6 +180,12 @@ function useConciergeSidePanelReportActions({ } const filtered = actions.filter(isCurrentSessionAction); if (filtered.length === 0) { + // Side panel: nothing matched the current session yet (e.g. just after reopen, before the new + // message propagates). Show the greeting instead of `actions` to avoid flashing stale history. + if (!isConciergeMainDM && conciergeGreetingAction) { + const createdAction = actions.find(isCreatedAction); + return createdAction ? [conciergeGreetingAction, createdAction] : [conciergeGreetingAction]; + } return actions; } if (conciergeGreetingAction) { @@ -188,7 +194,16 @@ function useConciergeSidePanelReportActions({ } return filtered; }, - [showConciergeSidePanelWelcome, conciergeGreetingAction, isConciergeHiddenHistory, showFullHistory, sessionStartTime, isCurrentSessionAction, hadUserMessageAtSessionStart], + [ + showConciergeSidePanelWelcome, + conciergeGreetingAction, + isConciergeHiddenHistory, + showFullHistory, + sessionStartTime, + isCurrentSessionAction, + hadUserMessageAtSessionStart, + isConciergeMainDM, + ], ); const filteredVisibleActions = useMemo(() => filterActions(visibleReportActions), [filterActions, visibleReportActions]); From 5b0b88d7904c8ce230c542fef85843d6ad2b5eaa Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Thu, 13 Aug 2026 14:38:19 +0200 Subject: [PATCH 09/11] Auto-grow Concierge prompt input up to ~5 lines --- .../home/ForYouSection/ConciergePromptBox.tsx | 56 ++++++++++++++----- src/styles/index.ts | 15 ++++- 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/src/pages/home/ForYouSection/ConciergePromptBox.tsx b/src/pages/home/ForYouSection/ConciergePromptBox.tsx index 406977ce6d14..db90c9359f5c 100644 --- a/src/pages/home/ForYouSection/ConciergePromptBox.tsx +++ b/src/pages/home/ForYouSection/ConciergePromptBox.tsx @@ -30,6 +30,9 @@ import {View} from 'react-native'; import useConciergeAttachmentPicker from './useConciergeAttachmentPicker'; +// Max input height (~5 lines) before the input starts scrolling internally. +const MAX_INPUT_HEIGHT = variables.componentSizeNormal * 3; + function ConciergePromptBox() { const styles = useThemeStyles(); const theme = useTheme(); @@ -40,6 +43,10 @@ function ConciergePromptBox() { const icons = useMemoizedLazyExpensifyIcons(['Plus', 'Send', 'Paperclip']); const {calculatePopoverPosition} = usePopoverPosition(); const [value, setValue] = useState(''); + // The RNTextInput can't auto-grow on its own, so we measure a hidden mirror of its content + // (grows and shrinks correctly across web/native) and drive the input height from it. + const [inputWidth, setInputWidth] = useState(0); + const [contentHeight, setContentHeight] = useState(variables.lineHeightXLarge); const [isFocused, setIsFocused] = useState(false); const [isMenuVisible, setIsMenuVisible] = useState(false); const [popoverAnchorPosition, setPopoverAnchorPosition] = useState(null); @@ -71,6 +78,10 @@ function ConciergePromptBox() { const placeholder = translate(shouldUseNarrowLayout ? 'homePage.conciergePrompt.inputPlaceholderMobile' : 'homePage.conciergePrompt.inputPlaceholder'); const canSubmit = shouldShowAskConcierge && value.trim().length > 0; + // Grow the input to fit its content (measured mirror + vertical padding), clamped between a single + // line and a max height (~5 lines) after which the input scrolls internally. + const inputHeight = Math.min(MAX_INPUT_HEIGHT, Math.max(variables.componentSizeNormal, contentHeight + variables.componentSizeNormal - variables.lineHeightXLarge)); + const submit = () => { if (!canSubmit) { return; @@ -85,8 +96,8 @@ function ConciergePromptBox() { {dateLabel} {greeting} - - + + - setIsFocused(true)} - onBlur={() => setIsFocused(false)} - placeholder={placeholder} - placeholderTextColor={theme.placeholderText} - onSubmitEditing={submit} - submitBehavior="submit" - returnKeyType="send" - accessibilityLabel={placeholder} - /> + + setInputWidth(e.nativeEvent.layout.width)} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + multiline + placeholder={placeholder} + placeholderTextColor={theme.placeholderText} + onSubmitEditing={submit} + submitBehavior="submit" + returnKeyType="send" + accessibilityLabel={placeholder} + /> + {inputWidth > 0 && ( + setContentHeight(e.nativeEvent.layout.height)} + accessible={false} + aria-hidden + > + {/* Trailing zero-width space so a value ending in a newline still measures the extra line. */} + {value ? `${value}${value.endsWith('\n') ? '\u200B' : ''}` : placeholder} + + )} + conciergePromptBoxDivider: { width: 1, alignSelf: 'stretch', + // Cancel the container's vertical padding (pv1) so the divider spans edge-to-edge + // while the buttons and input keep their vertical breathing room. + marginVertical: -4, backgroundColor: theme.border, }, conciergePromptBoxInput: { color: theme.text, - height: variables.componentSizeNormal, + lineHeight: variables.lineHeightXLarge, + paddingVertical: (variables.componentSizeNormal - variables.lineHeightXLarge) / 2, + minHeight: variables.componentSizeNormal, + maxHeight: variables.componentSizeNormal * 3, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE.fontFamily, + textAlignVertical: 'top', + } satisfies TextStyle, + + // Font-only styles matching the input, used by the hidden mirror that measures content height. + conciergePromptBoxInputMeasure: { + lineHeight: variables.lineHeightXLarge, fontFamily: FontUtils.fontFamily.platform.EXP_NEUE.fontFamily, } satisfies TextStyle, From c9464be03f6ebebb7b2e9efef2dcf49e85296c13 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Fri, 14 Aug 2026 12:05:21 +0200 Subject: [PATCH 10/11] Merge Time sensitive into the Concierge For You card --- src/pages/home/ForYouSection/index.tsx | 45 +++++++----- src/pages/home/HomePage.tsx | 3 - .../TimeSensitiveGroup.tsx | 64 +++++++++++++++++ .../{index.tsx => useTimeSensitiveItems.tsx} | 69 +++---------------- tests/ui/ForYouSectionTest.tsx | 5 ++ tests/unit/pages/HomePage.test.tsx | 2 - .../AddBankAccountTest.tsx | 8 ++- .../UnlockBankAccountTest.tsx | 8 ++- .../ValidateAccountTest.tsx | 8 ++- 9 files changed, 127 insertions(+), 85 deletions(-) create mode 100644 src/pages/home/TimeSensitiveSection/TimeSensitiveGroup.tsx rename src/pages/home/TimeSensitiveSection/{index.tsx => useTimeSensitiveItems.tsx} (83%) diff --git a/src/pages/home/ForYouSection/index.tsx b/src/pages/home/ForYouSection/index.tsx index 8772dfd84a16..4e4bf45a733b 100644 --- a/src/pages/home/ForYouSection/index.tsx +++ b/src/pages/home/ForYouSection/index.tsx @@ -15,6 +15,9 @@ import {setHasSeenForYouTodo} from '@libs/actions/Todos'; import Navigation from '@libs/Navigation/Navigation'; import {buildQueryStringFromFilterFormValues} from '@libs/SearchQueryUtils'; +import TimeSensitiveGroup from '@pages/home/TimeSensitiveSection/TimeSensitiveGroup'; +import useTimeSensitiveItems from '@pages/home/TimeSensitiveSection/useTimeSensitiveItems'; + import colors from '@styles/theme/colors'; import CONST from '@src/CONST'; @@ -53,6 +56,9 @@ function ForYouSection() { const isNewDotOnboardedUser = !isEmptyObject(onboarding); const [hasSeenForYouTodo = false] = useOnyx(ONYXKEYS.NVP_HAS_SEEN_FOR_YOU_TODO); const {count: flaggedExpensesCount, reviewExpenses} = useReviewFlaggedExpenses(); + // "Time sensitive" now lives inside this card as a group above the "For you" todos (chat input stays on top). + const timeSensitiveItems = useTimeSensitiveItems(); + const hasTimeSensitiveContent = timeSensitiveItems.length > 0; const icons = useMemoizedLazyExpensifyIcons(['ReceiptSearch', 'MoneyBag', 'Send', 'ThumbsUp', 'Export']); @@ -195,27 +201,34 @@ function ForYouSection() { return hasAnyTodos ? renderTodoItems() : ; }; - if ( - shouldHideForYouSection({ - isInitialLoad, - hasAnyTodos, - hasSeenTodo: hasSeenForYouTodo, - firstDayFreeTrial, - cutoffDate: CONST.HOME.FOR_YOU_NEW_USER_CUTOFF_DATE, - isOnboardingCompleted, - isOnboardingStatusKnown, - isNewDotOnboardedUser, - }) - ) { + const hideForYou = shouldHideForYouSection({ + isInitialLoad, + hasAnyTodos, + hasSeenTodo: hasSeenForYouTodo, + firstDayFreeTrial, + cutoffDate: CONST.HOME.FOR_YOU_NEW_USER_CUTOFF_DATE, + isOnboardingCompleted, + isOnboardingStatusKnown, + isNewDotOnboardedUser, + }); + + // Keep the card (and its Concierge input) visible when the "For you" part is hidden but there's time-sensitive + // content, so those alerts aren't lost for users who wouldn't otherwise see the "For you" section. + if (hideForYou && !hasTimeSensitiveContent) { return null; } return ( }> - - {translate('homePage.forYou')} - - {renderContent()} + + {!hideForYou && ( + <> + + {translate('homePage.forYou')} + + {renderContent()} + + )} ); } diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx index a92fbee762af..6fa841861568 100644 --- a/src/pages/home/HomePage.tsx +++ b/src/pages/home/HomePage.tsx @@ -26,7 +26,6 @@ import FreeTrialSection from './FreeTrialSection'; import GettingStartedSection from './GettingStartedSection'; import InsightsSection from './InsightsSection'; import RecentlyAddedSection from './RecentlyAddedSection'; -import TimeSensitiveSection from './TimeSensitiveSection'; import UpcomingTravelSection from './UpcomingTravelSection'; import YourSpendSection from './YourSpendSection'; @@ -69,7 +68,6 @@ function HomePage() { {shouldUseNarrowLayout ? ( <> - @@ -85,7 +83,6 @@ function HomePage() { testID="homePageLeftColumn" style={styles.homePageLeftColumn} > - diff --git a/src/pages/home/TimeSensitiveSection/TimeSensitiveGroup.tsx b/src/pages/home/TimeSensitiveSection/TimeSensitiveGroup.tsx new file mode 100644 index 000000000000..4ec4f28f503e --- /dev/null +++ b/src/pages/home/TimeSensitiveSection/TimeSensitiveGroup.tsx @@ -0,0 +1,64 @@ +import Text from '@components/Text'; + +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import HomeSectionExpandToggle from '@pages/home/HomeSectionExpandToggle'; + +import CONST from '@src/CONST'; + +import {useFocusEffect} from '@react-navigation/native'; +import React, {useCallback, useState} from 'react'; +import {View} from 'react-native'; + +type TimeSensitiveGroupProps = { + /** The prebuilt time-sensitive item rows (from useTimeSensitiveItems). */ + items: React.ReactNode[]; +}; + +/** + * Renders the "Time sensitive" heading and item rows as a group inside another card (the Home "For you" card). + * Returns null when there are no items so the group leaves no trace. It's the card owner's job to keep the card + * visible whenever this group has content. + */ +function TimeSensitiveGroup({items}: TimeSensitiveGroupProps) { + const styles = useThemeStyles(); + const theme = useTheme(); + const {translate} = useLocalize(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const [isExpanded, setIsExpanded] = useState(false); + + // Collapse again whenever the user leaves and returns to Home. + useFocusEffect(useCallback(() => () => setIsExpanded(false), [])); + + if (items.length === 0) { + return null; + } + + const hiddenCount = Math.max(0, items.length - CONST.HOME.SECTION_VISIBLE_LIMIT); + const visibleItems = isExpanded ? items : items.slice(0, CONST.HOME.SECTION_VISIBLE_LIMIT); + + return ( + <> + + {translate('homePage.timeSensitiveSection.title')} + + + {visibleItems} + {hiddenCount > 0 && ( + setIsExpanded((prev) => !prev)} + collapsedLabel={translate('homePage.seeMore', {count: hiddenCount})} + /> + )} + + + ); +} + +TimeSensitiveGroup.displayName = 'TimeSensitiveGroup'; + +export default TimeSensitiveGroup; diff --git a/src/pages/home/TimeSensitiveSection/index.tsx b/src/pages/home/TimeSensitiveSection/useTimeSensitiveItems.tsx similarity index 83% rename from src/pages/home/TimeSensitiveSection/index.tsx rename to src/pages/home/TimeSensitiveSection/useTimeSensitiveItems.tsx index aef9df798b9b..4afede4c22a3 100644 --- a/src/pages/home/TimeSensitiveSection/index.tsx +++ b/src/pages/home/TimeSensitiveSection/useTimeSensitiveItems.tsx @@ -1,19 +1,12 @@ -import WidgetContainer from '@components/WidgetContainer'; - import useCardFeedErrors from '@hooks/useCardFeedErrors'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useIsAnonymousUser from '@hooks/useIsAnonymousUser'; -import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useThemeStyles from '@hooks/useThemeStyles'; import {hasSynchronizationErrorMessage, isConnectionInProgress} from '@libs/actions/connections'; import {getConnectedHRProvider} from '@libs/HRUtils'; import {expensifyLoginsSelector, isCurrentUserValidated} from '@libs/UserUtils'; -import HomeSectionExpandToggle from '@pages/home/HomeSectionExpandToggle'; - import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy} from '@src/types/onyx'; @@ -21,12 +14,10 @@ import type {ConnectionName, PolicyConnectionName} from '@src/types/onyx/Policy' import type {OnyxCollection} from 'react-native-onyx'; -import {useFocusEffect} from '@react-navigation/native'; import {isUserValidatedSelector} from '@selectors/Account'; import {activeAdminPoliciesSelector} from '@selectors/Policy'; import {emailSelector} from '@selectors/Session'; -import React, {useCallback, useState} from 'react'; -import {View} from 'react-native'; +import React, {useCallback} from 'react'; import useBrokenDirectCompanyCardFeedsForAdmin from './hooks/useBrokenDirectCompanyCardFeedsForAdmin'; import useTimeSensitiveAddBankAccount from './hooks/useTimeSensitiveAddBankAccount'; @@ -68,19 +59,13 @@ type BrokenPersonalCardConnection = { cardID: string; }; -function TimeSensitiveSection() { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); +/** + * Builds the prioritized list of time-sensitive action rows for the Home page. Returns an empty array when the user + * has no time-sensitive content, so the caller can decide whether to render the "Time sensitive" group at all. + */ +function useTimeSensitiveItems(): React.ReactNode[] { const {login} = useCurrentUserPersonalDetails(); const isAnonymous = useIsAnonymousUser(); - const [isExpanded, setIsExpanded] = useState(false); - - useFocusEffect( - useCallback(() => { - return () => setIsExpanded(false); - }, []), - ); // Use custom hooks for offers and cards (Release 3) const {shouldShowAddPaymentCard} = useTimeSensitiveAddPaymentCard(); @@ -160,28 +145,6 @@ function TimeSensitiveSection() { const isCurrentLoginValidated = isCurrentUserValidated(loginList, sessionEmail ?? login); const shouldShowValidateAccount = isUserValidated === false && !isAnonymous && !isCurrentLoginValidated; - // This guard must exactly match the conditions used to render each widget below. - // If a widget has additional conditions in the render (e.g. && !!discountInfo), those - // must be reflected here to avoid showing an empty "Time sensitive" section. - const hasAnyTimeSensitiveContent = - lockedBankAccounts.length > 0 || - shouldShowEnterSignerInfo || - shouldShowValidateAccount || - shouldShowFixFailedBilling || - shouldShowReviewCardFraud || - shouldShowAddPaymentCard || - shouldShowAddBankAccount || - hasBrokenCompanyCards || - hasBrokenPersonalCards || - hasBrokenPolicyConnections || - shouldShowAddShippingAddress || - shouldShowActivateCard || - shouldShowAddVirtualCardPersonalDetails; - - if (!hasAnyTimeSensitiveContent) { - return null; - } - // Priority order: // 1. Validate account // 2. Fix failed billing (existing customers with declined cards) @@ -323,23 +286,7 @@ function TimeSensitiveSection() { } } - const hiddenCount = Math.max(0, items.length - CONST.HOME.SECTION_VISIBLE_LIMIT); - const visibleItems = isExpanded ? items : items.slice(0, CONST.HOME.SECTION_VISIBLE_LIMIT); - - return ( - - - {visibleItems} - {hiddenCount > 0 && ( - setIsExpanded((prev) => !prev)} - collapsedLabel={translate('homePage.seeMore', {count: hiddenCount})} - /> - )} - - - ); + return items; } -export default TimeSensitiveSection; +export default useTimeSensitiveItems; diff --git a/tests/ui/ForYouSectionTest.tsx b/tests/ui/ForYouSectionTest.tsx index 25c65eced0a7..e2c9febac479 100644 --- a/tests/ui/ForYouSectionTest.tsx +++ b/tests/ui/ForYouSectionTest.tsx @@ -62,6 +62,11 @@ jest.mock('@pages/home/ForYouSection/ConciergePromptBox', () => () => { return ReactModule.createElement('View', {testID: 'concierge-prompt-box'}); }); +// The "Time sensitive" group is exercised in its own tests; stub it out here so these tests stay focused on "For you" +// (and so it doesn't pull in useFocusEffect, which needs a NavigationContainer this harness doesn't provide). +jest.mock('@pages/home/TimeSensitiveSection/useTimeSensitiveItems', () => jest.fn(() => [])); +jest.mock('@pages/home/TimeSensitiveSection/TimeSensitiveGroup', () => () => null); + // ForYouSection calls useIsFocused() to freeze useTodoCounts when unfocused; this test renders it outside a // NavigationContainer, so stub the focus hook (useTodoCounts is mocked, so the focus value itself is irrelevant). jest.mock('@react-navigation/native', () => { diff --git a/tests/unit/pages/HomePage.test.tsx b/tests/unit/pages/HomePage.test.tsx index d595d9560832..2af480adb522 100644 --- a/tests/unit/pages/HomePage.test.tsx +++ b/tests/unit/pages/HomePage.test.tsx @@ -81,7 +81,6 @@ function mockSection(name: string) { } jest.mock('@pages/home/FreeTrialSection', () => mockSection('FreeTrialSection')); -jest.mock('@pages/home/TimeSensitiveSection', () => mockSection('TimeSensitiveSection')); jest.mock('@pages/home/GettingStartedSection', () => mockSection('GettingStartedSection')); jest.mock('@pages/home/ForYouSection', () => mockSection('ForYouSection')); jest.mock('@pages/home/UpcomingTravelSection', () => mockSection('UpcomingTravelSection')); @@ -169,7 +168,6 @@ describe('HomePage', () => { expect(renderedSectionOrder()).toEqual([ 'section-FreeTrialSection', - 'section-TimeSensitiveSection', 'section-GettingStartedSection', 'section-ForYouSection', 'section-UpcomingTravelSection', diff --git a/tests/unit/pages/home/TimeSensitiveSection/AddBankAccountTest.tsx b/tests/unit/pages/home/TimeSensitiveSection/AddBankAccountTest.tsx index 76ec13efd894..2b6c078040ea 100644 --- a/tests/unit/pages/home/TimeSensitiveSection/AddBankAccountTest.tsx +++ b/tests/unit/pages/home/TimeSensitiveSection/AddBankAccountTest.tsx @@ -3,9 +3,10 @@ import {fireEvent, render, screen} from '@testing-library/react-native'; import OnyxListItemProvider from '@src/components/OnyxListItemProvider'; import {openPersonalBankAccountSetupView} from '@src/libs/actions/BankAccounts'; import ONYXKEYS from '@src/ONYXKEYS'; -import TimeSensitiveSection from '@src/pages/home/TimeSensitiveSection'; import useTimeSensitiveAddBankAccount from '@src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveAddBankAccount'; import useTimeSensitiveAddPaymentCard from '@src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveAddPaymentCard'; +import TimeSensitiveGroup from '@src/pages/home/TimeSensitiveSection/TimeSensitiveGroup'; +import useTimeSensitiveItems from '@src/pages/home/TimeSensitiveSection/useTimeSensitiveItems'; import type * as NativeNavigation from '@react-navigation/native'; @@ -67,6 +68,11 @@ jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({login: ' jest.mock('@hooks/useResponsiveLayout', () => jest.fn(() => ({shouldUseNarrowLayout: false}))); +// Renders the "Time sensitive" group the way the Home "For you" card now does (hook + presentational group). +function TimeSensitiveSection() { + return ; +} + const renderTimeSensitiveSection = () => render( diff --git a/tests/unit/pages/home/TimeSensitiveSection/UnlockBankAccountTest.tsx b/tests/unit/pages/home/TimeSensitiveSection/UnlockBankAccountTest.tsx index 092b40ad7662..8d08765bfccc 100644 --- a/tests/unit/pages/home/TimeSensitiveSection/UnlockBankAccountTest.tsx +++ b/tests/unit/pages/home/TimeSensitiveSection/UnlockBankAccountTest.tsx @@ -6,7 +6,8 @@ import {navigateToConciergeChat} from '@libs/actions/Report'; import OnyxListItemProvider from '@src/components/OnyxListItemProvider'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import TimeSensitiveSection from '@src/pages/home/TimeSensitiveSection'; +import TimeSensitiveGroup from '@src/pages/home/TimeSensitiveSection/TimeSensitiveGroup'; +import useTimeSensitiveItems from '@src/pages/home/TimeSensitiveSection/useTimeSensitiveItems'; import type * as NativeNavigation from '@react-navigation/native'; @@ -70,6 +71,11 @@ const POLICY_ID = 'policy_1'; const POLICY_NAME = 'My Workspace'; const CONCIERGE_REPORT_ID = 'concierge_report_1'; +// Renders the "Time sensitive" group the way the Home "For you" card now does (hook + presentational group). +function TimeSensitiveSection() { + return ; +} + const renderTimeSensitiveSection = () => render( diff --git a/tests/unit/pages/home/TimeSensitiveSection/ValidateAccountTest.tsx b/tests/unit/pages/home/TimeSensitiveSection/ValidateAccountTest.tsx index 1eb782060cb8..dc984f18481d 100644 --- a/tests/unit/pages/home/TimeSensitiveSection/ValidateAccountTest.tsx +++ b/tests/unit/pages/home/TimeSensitiveSection/ValidateAccountTest.tsx @@ -3,8 +3,9 @@ import {render, screen} from '@testing-library/react-native'; import OnyxListItemProvider from '@src/components/OnyxListItemProvider'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import TimeSensitiveSection from '@src/pages/home/TimeSensitiveSection'; import useTimeSensitiveAddPaymentCard from '@src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveAddPaymentCard'; +import TimeSensitiveGroup from '@src/pages/home/TimeSensitiveSection/TimeSensitiveGroup'; +import useTimeSensitiveItems from '@src/pages/home/TimeSensitiveSection/useTimeSensitiveItems'; import type * as NativeNavigation from '@react-navigation/native'; @@ -61,6 +62,11 @@ jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({login: ' jest.mock('@hooks/useResponsiveLayout', () => jest.fn(() => ({shouldUseNarrowLayout: false}))); +// Renders the "Time sensitive" group the way the Home "For you" card now does (hook + presentational group). +function TimeSensitiveSection() { + return ; +} + const renderTimeSensitiveSection = () => render( From e9efaccd33c5e78346477f32065d593a93975dde Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Fri, 14 Aug 2026 13:42:50 +0200 Subject: [PATCH 11/11] Fix ESLint errors in useTimeSensitiveItems and reset Mobile-Expensify submodule pointer --- Mobile-Expensify | 2 +- .../TimeSensitiveSection/useTimeSensitiveItems.tsx | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index b9ee1a7ddd64..95c3dcdc5d7d 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit b9ee1a7ddd6456bf187fe0a39315a2e167426da2 +Subproject commit 95c3dcdc5d7d8ece28961b8f93799cb99bdeb75c diff --git a/src/pages/home/TimeSensitiveSection/useTimeSensitiveItems.tsx b/src/pages/home/TimeSensitiveSection/useTimeSensitiveItems.tsx index 4afede4c22a3..339389db0b33 100644 --- a/src/pages/home/TimeSensitiveSection/useTimeSensitiveItems.tsx +++ b/src/pages/home/TimeSensitiveSection/useTimeSensitiveItems.tsx @@ -10,7 +10,8 @@ import {expensifyLoginsSelector, isCurrentUserValidated} from '@libs/UserUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy} from '@src/types/onyx'; -import type {ConnectionName, PolicyConnectionName} from '@src/types/onyx/Policy'; +import type {PolicyConnectionName} from '@src/types/onyx/Policy'; +import ObjectUtils from '@src/types/utils/ObjectUtils'; import type {OnyxCollection} from 'react-native-onyx'; @@ -94,7 +95,7 @@ function useTimeSensitiveItems(): React.ReactNode[] { const [loginList] = useOnyx(ONYXKEYS.LOGINS, {selector: expensifyLoginsSelector}); const [sessionEmail] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); const {lockedBankAccounts} = useTimeSensitiveLockedBankAccount(adminPolicies); - const {shouldShowEnterSignerInfo, pendingSignerInfo} = useTimeSensitiveSignerInfo(); + const {pendingSignerInfo} = useTimeSensitiveSignerInfo(); // Get card feed errors for company card connections (Release 4) const cardFeedErrors = useCardFeedErrors(); @@ -112,7 +113,7 @@ function useTimeSensitiveItems(): React.ReactNode[] { const syncProgress = connectionSyncProgress?.[`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policy.id}`]; const isSyncInProgress = isConnectionInProgress(syncProgress, policy); - for (const connectionName of Object.keys(policyConnections) as ConnectionName[]) { + for (const [connectionName] of ObjectUtils.typedEntries(policyConnections)) { if (hasSynchronizationErrorMessage(policy, connectionName, isSyncInProgress)) { const integrationName = connectionName === CONST.POLICY.CONNECTIONS.NAME.MERGE_HR @@ -139,9 +140,6 @@ function useTimeSensitiveItems(): React.ReactNode[] { } } - const hasBrokenCompanyCards = brokenCompanyCardConnections.length > 0; - const hasBrokenPersonalCards = brokenPersonalCardConnections.length > 0; - const hasBrokenPolicyConnections = brokenPolicyConnections.length > 0; const isCurrentLoginValidated = isCurrentUserValidated(loginList, sessionEmail ?? login); const shouldShowValidateAccount = isUserValidated === false && !isAnonymous && !isCurrentLoginValidated;