diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 556b9f7590e1..4bae10319adb 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2413,6 +2413,7 @@ const CONST = { }, CONCIERGE_DISPLAY_NAME: 'Concierge', + CONCIERGE_GREETING_ACTION_ID: 'concierge-side-panel-greeting', INTEGRATION_ENTITY_MAP_TYPES: { DEFAULT: 'DEFAULT', diff --git a/src/components/SidePanel/SidePanelContextProvider.tsx b/src/components/SidePanel/SidePanelContextProvider.tsx index b237c3565f89..dcf962e1c6d4 100644 --- a/src/components/SidePanel/SidePanelContextProvider.tsx +++ b/src/components/SidePanel/SidePanelContextProvider.tsx @@ -8,6 +8,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSidePanelDisplayStatus from '@hooks/useSidePanelDisplayStatus'; import useWindowDimensions from '@hooks/useWindowDimensions'; import SidePanelActions from '@libs/actions/SidePanel'; +import DateUtils from '@libs/DateUtils'; import focusComposerWithDelay from '@libs/focusComposerWithDelay'; import {isPolicyAdmin, shouldShowPolicy} from '@libs/PolicyUtils'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; @@ -28,6 +29,7 @@ type SidePanelStateContextProps = { sidePanelTranslateX: RefObject; sidePanelNVP?: SidePanel; reportID?: string; + sessionStartTime: string | null; }; type SidePanelActionsContextProps = { @@ -44,6 +46,7 @@ const SidePanelStateContext = createContext({ shouldHideToolTip: false, sidePanelOffset: {current: new Animated.Value(0)}, sidePanelTranslateX: {current: new Animated.Value(0)}, + sessionStartTime: null, }); const SidePanelActionsContext = createContext({ @@ -83,6 +86,16 @@ function SidePanelContextProvider({children}: PropsWithChildren) { const reportID = isRHPAdminsRoom && isUserAdmin && isPolicyActive && adminsChatReportID ? adminsChatReportID : conciergeReportID; + const [sessionStartTime, setSessionStartTime] = useState(null); + const [prevShouldHideSidePanel, setPrevShouldHideSidePanel] = useState(shouldHideSidePanel); + + if (prevShouldHideSidePanel !== shouldHideSidePanel) { + setPrevShouldHideSidePanel(shouldHideSidePanel); + if (!shouldHideSidePanel) { + setSessionStartTime(DateUtils.getDBTime()); + } + } + useEffect(() => { sidePanelWidthRef.current = sidePanelWidth; }, [sidePanelWidth]); @@ -133,6 +146,7 @@ function SidePanelContextProvider({children}: PropsWithChildren) { sidePanelTranslateX, sidePanelNVP, reportID, + sessionStartTime, }; // Because of the React Compiler we don't need to memoize it manually diff --git a/src/hooks/useConciergeSidePanelReportActions.ts b/src/hooks/useConciergeSidePanelReportActions.ts new file mode 100644 index 000000000000..891896c29dea --- /dev/null +++ b/src/hooks/useConciergeSidePanelReportActions.ts @@ -0,0 +1,150 @@ +import {useCallback, useMemo, useState} from 'react'; +import DateUtils from '@libs/DateUtils'; +import {isCreatedAction} from '@libs/ReportActionsUtils'; +import {buildConciergeGreetingReportAction} from '@libs/ReportUtils'; +import type * as OnyxTypes from '@src/types/onyx'; + +type UseConciergeSidePanelReportActionsParams = { + report: OnyxTypes.Report; + reportActions: OnyxTypes.ReportAction[]; + visibleReportActions: OnyxTypes.ReportAction[]; + isConciergeSidePanel: boolean; + hasUserSentMessage: boolean; + hasOlderActions: boolean; + sessionStartTime: string | null; + currentUserAccountID: number; + greetingText: string; + loadOlderChats: (force?: boolean) => void; +}; + +function useConciergeSidePanelReportActions({ + report, + reportActions, + visibleReportActions, + isConciergeSidePanel, + hasUserSentMessage, + hasOlderActions, + sessionStartTime, + currentUserAccountID, + greetingText, + loadOlderChats, +}: UseConciergeSidePanelReportActionsParams) { + const [showFullHistory, setShowFullHistory] = useState(false); + const [prevSessionStartTime, setPrevSessionStartTime] = useState(sessionStartTime); + const [prevHasUserSentMessage, setPrevHasUserSentMessage] = useState(hasUserSentMessage); + + if (prevSessionStartTime !== sessionStartTime) { + setPrevSessionStartTime(sessionStartTime); + setShowFullHistory(false); + } else if (prevHasUserSentMessage && !hasUserSentMessage) { + setPrevHasUserSentMessage(hasUserSentMessage); + setShowFullHistory(false); + } else if (prevHasUserSentMessage !== hasUserSentMessage) { + setPrevHasUserSentMessage(hasUserSentMessage); + } + + // Check if the user had sent any message BEFORE this session started. + // Uses sessionStartTime as the boundary — any user message created before the + // panel opened is a pre-session message, regardless of when it was loaded. + // + // When no user message is found in the loaded set, hasOlderActions indicates + // whether there is unloaded history. On a new account all onboarding messages + // fit in a single page (hasOlderActions=false). On an existing account with + // prior interactions the history spans multiple pages (hasOlderActions=true). + // + const hadUserMessageAtSessionStart = useMemo(() => { + if (!isConciergeSidePanel || !sessionStartTime) { + return false; + } + const hasUserMessageInLoadedSet = visibleReportActions.some( + (action) => !isCreatedAction(action) && action.actorAccountID === currentUserAccountID && action.created < sessionStartTime, + ); + return hasUserMessageInLoadedSet || hasOlderActions; + }, [isConciergeSidePanel, visibleReportActions, currentUserAccountID, sessionStartTime, hasOlderActions]); + + const hasPreviousMessages = useMemo(() => { + if (!isConciergeSidePanel || !hadUserMessageAtSessionStart || !sessionStartTime) { + return false; + } + return visibleReportActions.some((action) => !isCreatedAction(action) && action.created < sessionStartTime); + }, [isConciergeSidePanel, visibleReportActions, sessionStartTime, hadUserMessageAtSessionStart]); + + const showConciergeSidePanelWelcome = isConciergeSidePanel && hadUserMessageAtSessionStart && !hasUserSentMessage && !showFullHistory; + const showConciergeGreeting = isConciergeSidePanel && hadUserMessageAtSessionStart && !showFullHistory; + + const conciergeGreetingAction = useMemo(() => { + if (!showConciergeGreeting) { + return undefined; + } + return buildConciergeGreetingReportAction(report.reportID, greetingText, report.lastReadTime ?? DateUtils.getDBTime()); + }, [showConciergeGreeting, report.reportID, report.lastReadTime, greetingText]); + + const firstUserMessageCreated = useMemo(() => { + if (showConciergeSidePanelWelcome || !isConciergeSidePanel || !hasUserSentMessage || !sessionStartTime) { + return undefined; + } + return reportActions.reduce((earliest, action) => { + if (isCreatedAction(action) || action.created < sessionStartTime || action.actorAccountID !== currentUserAccountID) { + return earliest; + } + return !earliest || action.created < earliest ? action.created : earliest; + }, undefined); + }, [showConciergeSidePanelWelcome, isConciergeSidePanel, hasUserSentMessage, sessionStartTime, reportActions, currentUserAccountID]); + + const isCurrentSessionAction = useCallback( + (action: OnyxTypes.ReportAction): boolean => { + if (!firstUserMessageCreated || !sessionStartTime) { + return false; + } + return isCreatedAction(action) || (action.created >= sessionStartTime && action.created >= firstUserMessageCreated); + }, + [firstUserMessageCreated, sessionStartTime], + ); + + const filterActions = useCallback( + (actions: OnyxTypes.ReportAction[]): OnyxTypes.ReportAction[] => { + if (showConciergeSidePanelWelcome && conciergeGreetingAction) { + const createdAction = actions.find(isCreatedAction); + return createdAction ? [conciergeGreetingAction, createdAction] : [conciergeGreetingAction]; + } + if (!isConciergeSidePanel || showFullHistory) { + return actions; + } + if (!sessionStartTime) { + return actions.filter(isCreatedAction); + } + if (!hadUserMessageAtSessionStart) { + return actions; + } + const filtered = actions.filter(isCurrentSessionAction); + if (filtered.length === 0) { + return actions; + } + if (conciergeGreetingAction) { + const createdIndex = filtered.findIndex(isCreatedAction); + filtered.splice(createdIndex === -1 ? filtered.length : createdIndex, 0, conciergeGreetingAction); + } + return filtered; + }, + [showConciergeSidePanelWelcome, conciergeGreetingAction, isConciergeSidePanel, showFullHistory, sessionStartTime, isCurrentSessionAction, hadUserMessageAtSessionStart], + ); + + const filteredVisibleActions = useMemo(() => filterActions(visibleReportActions), [filterActions, visibleReportActions]); + const filteredReportActions = useMemo(() => filterActions(reportActions), [filterActions, reportActions]); + + const handleShowPreviousMessages = useCallback(() => { + setShowFullHistory(true); + loadOlderChats(true); + }, [loadOlderChats]); + + return { + filteredVisibleActions, + filteredReportActions, + showConciergeSidePanelWelcome, + showFullHistory, + hasPreviousMessages, + handleShowPreviousMessages, + }; +} + +export default useConciergeSidePanelReportActions; diff --git a/src/languages/de.ts b/src/languages/de.ts index cd6712ef9e91..0c10c9e84348 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -545,6 +545,7 @@ const translations: TranslationDeepObject = { quarter: 'Quartal', vacationDelegate: 'Urlaubsvertretung', expensifyLogo: 'Expensify-Logo', + concierge: {sidePanelGreeting: 'Hallo, wie kann ich helfen?', showHistory: 'Verlauf anzeigen'}, duplicateReport: 'Duplizierten Bericht', approver: 'Genehmiger', }, diff --git a/src/languages/en.ts b/src/languages/en.ts index d33fd53de199..cea96bef961a 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -563,6 +563,10 @@ const translations = { week: 'Week', year: 'Year', quarter: 'Quarter', + concierge: { + sidePanelGreeting: 'Hi there, how can I help?', + showHistory: 'Show history', + }, vacationDelegate: 'Vacation delegate', expensifyLogo: 'Expensify logo', approver: 'Approver', diff --git a/src/languages/es.ts b/src/languages/es.ts index 7afb5bbacb40..d44fc2e3e847 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -416,6 +416,10 @@ const translations: TranslationDeepObject = { week: 'Semana', year: 'Año', quarter: 'Trimestre', + concierge: { + sidePanelGreeting: 'Hola, ¿en qué puedo ayudarte?', + showHistory: 'Mostrar historial', + }, vacationDelegate: 'Delegado de vacaciones', expensifyLogo: 'Logo de Expensify', approver: 'Aprobador', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 99d9d4db41f7..dad81eadd32e 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -545,6 +545,7 @@ const translations: TranslationDeepObject = { quarter: 'Trimestre', vacationDelegate: 'Délégué de vacances', expensifyLogo: 'Logo Expensify', + concierge: {sidePanelGreeting: 'Bonjour, comment puis-je vous aider ?', showHistory: 'Afficher l’historique'}, duplicateReport: 'Note de frais en double', approver: 'Approbateur', }, diff --git a/src/languages/it.ts b/src/languages/it.ts index 15b2c723274f..9ce904c9cef1 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -545,6 +545,7 @@ const translations: TranslationDeepObject = { quarter: 'Trimestre', vacationDelegate: 'Delega ferie', expensifyLogo: 'Logo Expensify', + concierge: {sidePanelGreeting: 'Ciao, come posso aiutarti?', showHistory: 'Mostra cronologia'}, duplicateReport: 'Report duplicato', approver: 'Approvante', }, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index d4bc27e7180e..9595dcef9571 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -544,6 +544,7 @@ const translations: TranslationDeepObject = { quarter: '四半期', vacationDelegate: '休暇代理人', expensifyLogo: 'Expensifyロゴ', + concierge: {sidePanelGreeting: 'こんにちは、どのようにお手伝いできますか?', showHistory: '履歴を表示'}, duplicateReport: 'レポートを複製', approver: '承認者', }, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index c05296e70e96..af465933e0de 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -544,6 +544,7 @@ const translations: TranslationDeepObject = { quarter: 'Kwartaal', vacationDelegate: 'Vertegenwoordiger tijdens vakantie', expensifyLogo: 'Expensify-logo', + concierge: {sidePanelGreeting: 'Hoi, waarmee kan ik je helpen?', showHistory: 'Geschiedenis weergeven'}, duplicateReport: 'Dubbel rapport', approver: 'Fiatteur', }, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 6ce265880a24..f6472869d10a 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -544,6 +544,7 @@ const translations: TranslationDeepObject = { quarter: 'Kwartał', vacationDelegate: 'Zastępca urlopowy', expensifyLogo: 'Logo Expensify', + concierge: {sidePanelGreeting: 'Cześć, w czym mogę pomóc?', showHistory: 'Pokaż historię'}, duplicateReport: 'Zduplikowany raport', approver: 'Osoba zatwierdzająca', }, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 51b5a895f28d..5e432775fab1 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -543,6 +543,7 @@ const translations: TranslationDeepObject = { quarter: 'Trimestre', vacationDelegate: 'Delegado de férias', expensifyLogo: 'Logo da Expensify', + concierge: {sidePanelGreeting: 'Oi, como posso ajudar?', showHistory: 'Mostrar histórico'}, duplicateReport: 'Duplicar relatório', approver: 'Aprovador', }, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 1ece47b19146..62ba5f82007d 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -540,6 +540,7 @@ const translations: TranslationDeepObject = { quarter: '季度', vacationDelegate: '休假代理', expensifyLogo: 'Expensify徽标', + concierge: {sidePanelGreeting: '你好,我能帮你做什么?', showHistory: '显示历史'}, duplicateReport: '重复报销单', approver: '审批人', }, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 85e70f9c9ed4..392f2693ed3c 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -6403,6 +6403,20 @@ function buildOptimisticAddCommentReportAction( }; } +function buildConciergeGreetingReportAction(reportID: string, greetingText: string, created: string): ReportAction { + return { + reportActionID: CONST.CONCIERGE_GREETING_ACTION_ID, + reportID, + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + actorAccountID: CONST.ACCOUNT_ID.CONCIERGE, + person: [{style: 'strong', text: CONST.CONCIERGE_DISPLAY_NAME, type: 'TEXT'}], + created, + message: [{type: CONST.REPORT.MESSAGE.TYPE.COMMENT, html: greetingText, text: greetingText}], + originalMessage: {html: greetingText, whisperedTo: []}, + shouldShow: true, + } as ReportAction; +} + /** * update optimistic parent reportAction when a comment is added or remove in the child report * @param parentReportAction - Parent report action of the child report @@ -13037,6 +13051,7 @@ function getReportFieldMaps(report: OnyxEntry, fieldList: Record getFilteredReportActionsForReportView(unfilteredReportActions), [unfilteredReportActions]); const [childReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${linkedAction?.childReportID}`); + const isConciergeSidePanel = useMemo(() => isInSidePanel && isConciergeChatReport(report, conciergeReportID), [isInSidePanel, report, conciergeReportID]); + + const {sessionStartTime} = useSidePanelState(); + + const hasUserSentMessage = useMemo(() => { + if (!isConciergeSidePanel || !sessionStartTime) { + return false; + } + return reportActions.some((action) => !isCreatedAction(action) && action.actorAccountID === currentUserAccountID && action.created >= sessionStartTime); + }, [isConciergeSidePanel, reportActions, currentUserAccountID, sessionStartTime]); const viewportOffsetTop = useViewportOffsetTop(); const {reportPendingAction, reportErrors} = getReportOfflinePendingActionAndErrors(report); @@ -1056,11 +1067,15 @@ function ReportScreen({route, navigation, isInSidePanel = false}: ReportScreenPr report={report} reportActions={reportActions} isLoadingInitialReportActions={reportMetadata?.isLoadingInitialReportActions} + hasOnceLoadedReportActions={reportMetadata?.hasOnceLoadedReportActions} hasNewerActions={hasNewerActions} hasOlderActions={hasOlderActions} parentReportAction={parentReportAction} transactionThreadReportID={transactionThreadReportID} isReportTransactionThread={isTransactionThreadView} + isConciergeSidePanel={isConciergeSidePanel} + hasUserSentMessage={hasUserSentMessage} + sessionStartTime={sessionStartTime} isConciergeProcessing={isConciergeProcessing} conciergeReasoningHistory={conciergeReasoningHistory} conciergeStatusLabel={conciergeStatusLabel} @@ -1088,6 +1103,7 @@ function ReportScreen({route, navigation, isInSidePanel = false}: ReportScreenPr // If the report is from the 'Send Money' flow, we add the comment to the `iou` report because for these we don't combine reportActions even if there is a single transaction (they always have a single transaction) transactionThreadReportID={isSentMoneyReport ? undefined : transactionThreadReportID} isInSidePanel={isInSidePanel} + shouldHideStatusIndicators={isConciergeSidePanel && !hasUserSentMessage} kickoffWaitingIndicator={kickoffWaitingIndicator} /> ) : null} diff --git a/src/pages/inbox/report/PureReportActionItem.tsx b/src/pages/inbox/report/PureReportActionItem.tsx index e34d99060349..2bf27f76a555 100644 --- a/src/pages/inbox/report/PureReportActionItem.tsx +++ b/src/pages/inbox/report/PureReportActionItem.tsx @@ -538,6 +538,9 @@ function PureReportActionItem({ reportMetadata, userBillingGraceEndPeriodCollection, }: PureReportActionItemProps) { + const isConciergeGreeting = action.reportActionID === CONST.CONCIERGE_GREETING_ACTION_ID; + const shouldDisplayContextMenuValue = shouldDisplayContextMenu && !isConciergeGreeting; + const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); const {transitionActionSheetState} = ActionSheetAwareScrollView.useActionSheetAwareScrollViewActions(); const {translate, formatPhoneNumber, localeCompare, formatTravelDate, getLocalDateFromDatetime, datetimeToCalendarTime} = useLocalize(); @@ -764,7 +767,7 @@ function PureReportActionItem({ const showPopover = useCallback( (event: GestureResponderEvent | MouseEvent) => { // Block menu on the message being Edited or if the report action item has errors - if (draftMessage !== undefined || !isEmptyValueObject(action.errors) || !shouldDisplayContextMenu) { + if (draftMessage !== undefined || !isEmptyValueObject(action.errors) || !shouldDisplayContextMenuValue) { return; } @@ -803,7 +806,7 @@ function PureReportActionItem({ reportID, toggleContextMenuFromActiveReportAction, originalReportID, - shouldDisplayContextMenu, + shouldDisplayContextMenuValue, disabledActions, isArchivedRoom, isChronosReport, @@ -827,9 +830,9 @@ function PureReportActionItem({ action, transactionThreadReport, isDisabled: false, - shouldDisplayContextMenu, + shouldDisplayContextMenuValue, }), - [report, action, transactionThreadReport, shouldDisplayContextMenu, isReportArchived], + [report, action, transactionThreadReport, shouldDisplayContextMenuValue, isReportArchived], ); const contextMenuActionsValue = useMemo( @@ -1164,7 +1167,7 @@ function PureReportActionItem({ checkIfContextMenuActive={toggleContextMenuFromActiveReportAction} style={displayAsGroup ? [] : [styles.mt2]} isWhisper={isWhisper} - shouldDisplayContextMenu={shouldDisplayContextMenu} + shouldDisplayContextMenu={shouldDisplayContextMenuValue} /> ); @@ -1179,7 +1182,7 @@ function PureReportActionItem({ chatReportID={reportID} reportID={reportID} action={action} - shouldDisplayContextMenu={shouldDisplayContextMenu} + shouldDisplayContextMenu={shouldDisplayContextMenuValue} isBillSplit={isSplitBillActionReportActionsUtils(action)} transactionID={shouldShowSplitPreview ? moneyRequestOriginalMessage?.IOUTransactionID : undefined} containerStyles={[reportPreviewStyles.transactionPreviewStandaloneStyle, styles.mt1]} @@ -1226,7 +1229,7 @@ function PureReportActionItem({ contextMenuAnchor={popoverAnchorRef.current} containerStyles={displayAsGroup ? [] : [styles.mt2]} checkIfContextMenuActive={toggleContextMenuFromActiveReportAction} - shouldDisplayContextMenu={shouldDisplayContextMenu} + shouldDisplayContextMenu={shouldDisplayContextMenuValue} /> ); } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && isClosedExpenseReportWithNoExpenses) { @@ -1244,7 +1247,7 @@ function PureReportActionItem({ checkIfContextMenuActive={toggleContextMenuFromActiveReportAction} onPaymentOptionsShow={() => setIsPaymentMethodPopoverActive(true)} onPaymentOptionsHide={() => setIsPaymentMethodPopoverActive(false)} - shouldDisplayContextMenu={shouldDisplayContextMenu} + shouldDisplayContextMenu={shouldDisplayContextMenuValue} shouldShowBorder={shouldShowBorder} /> ); @@ -1264,7 +1267,7 @@ function PureReportActionItem({ contextMenuAnchor={popoverAnchorRef.current} checkIfContextMenuActive={toggleContextMenuFromActiveReportAction} policyID={report?.policyID} - shouldDisplayContextMenu={shouldDisplayContextMenu} + shouldDisplayContextMenu={shouldDisplayContextMenuValue} /> @@ -2069,7 +2072,7 @@ function PureReportActionItem({ {(hovered) => ( {shouldDisplayNewMarker && (!shouldUseThreadDividerLine || !isFirstVisibleReportAction) && } - {shouldDisplayContextMenu && ( + {shouldDisplayContextMenuValue && ( void; }; @@ -141,6 +143,7 @@ function ReportActionCompose({ reportTransactions, transactionThreadReportID, isInSidePanel = false, + shouldHideStatusIndicators = false, kickoffWaitingIndicator, }: ReportActionComposeProps) { const styles = useThemeStyles(); @@ -674,7 +677,7 @@ function ReportActionCompose({ ]} > {!shouldUseNarrowLayout && } - + {!shouldHideStatusIndicators && } {!!exceededMaxLength && (