diff --git a/src/App.tsx b/src/App.tsx index 5bf33e75d410..840e48ec867d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -49,7 +49,7 @@ import useDefaultDragAndDrop from './hooks/useDefaultDragAndDrop'; import HybridAppHandler from './HybridAppHandler'; import OnyxUpdateManager from './libs/actions/OnyxUpdateManager'; import './libs/HybridApp'; -import {ConciergeSessionProvider} from './pages/inbox/ConciergeSessionContext'; +import {ConciergeChatHistoryProvider} from './pages/inbox/ConciergeChatHistoryContext'; import './setup/backgroundLocationTrackingTask'; import './setup/backgroundTask'; import './setup/fraudProtection'; @@ -115,7 +115,7 @@ function App() { SafeArea, PopoverContextProvider, CurrentReportIDContextProvider, - ConciergeSessionProvider, + ConciergeChatHistoryProvider, ScrollOffsetContextProvider, PickerStateProvider, EnvironmentProvider, diff --git a/src/CONST/index.ts b/src/CONST/index.ts index abde1d6daf5d..7527b73c0936 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1454,7 +1454,6 @@ const CONST = { WISE_REIMBURSEMENTS: 'wiseReimbursements', DEFAULT_LETTER_AVATARS: 'defaultLetterAvatars', TRAVEL_CODING_SYNC: 'travelCodingSync', - CONCIERGE_RESPOND_IN_THREAD: 'conciergeRespondInThread', ARCHIVE_POLICIES: 'archivePolicies', MERGE_ATS: 'mergeATSConnections', REPORT_MERGE: 'reportMerge', @@ -1745,7 +1744,6 @@ const CONST = { EXAMPLE_PHONE_NUMBER: '+15005550006', FORMATTED_EXAMPLE_PHONE_NUMBER: '+1-(201)-867-5309', CONCIERGE_CHAT_NAME: 'Concierge', - CONCIERGE_SESSION_EXPIRATION_MS: 2 * 3600 * 1000, // 2 hours CLOUDFRONT_URL, EMPTY_ARRAY, EMPTY_OBJECT, @@ -3366,6 +3364,7 @@ const CONST = { ATTACHMENT_SOURCE_ATTRIBUTE: 'data-expensify-source', ATTACHMENT_ID_ATTRIBUTE: 'data-attachment-id', ATTACHMENT_OPTIMISTIC_SOURCE_ATTRIBUTE: 'data-optimistic-src', + ADDITIONAL_ATTACHMENT_FILE_KEY_REGEX: /^file_\d+$/, ATTACHMENT_PREVIEW_ATTRIBUTE: 'src', ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE: 'data-name', ATTACHMENT_LOCAL_URL_PREFIX: ['blob:', 'file:'], diff --git a/src/components/Search/SearchRouter/useAskConcierge.tsx b/src/components/Search/SearchRouter/useAskConcierge.tsx index 2f8d2360bcdf..24bf63de801a 100644 --- a/src/components/Search/SearchRouter/useAskConcierge.tsx +++ b/src/components/Search/SearchRouter/useAskConcierge.tsx @@ -2,12 +2,13 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useOnyx from '@hooks/useOnyx'; import useOpenConciergeAnywhere from '@hooks/useOpenConciergeAnywhere'; -import usePermissions from '@hooks/usePermissions'; import useSidePanelReportID from '@hooks/useSidePanelReportID'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {generateReportID} from '@libs/ReportUtils'; +import {useConciergeChatHistoryActions} from '@pages/inbox/ConciergeChatHistoryContext'; + import {addAttachmentWithComment, addComment} from '@userActions/Report'; import {createTaskFromMarkdown} from '@userActions/Task'; @@ -27,16 +28,19 @@ function useAskConcierge({forceConcierge = false}: {forceConcierge?: boolean} = const sidePanelReportID = useSidePanelReportID(); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const {openConciergeAnywhere, isInSidePanel} = useOpenConciergeAnywhere(); - const targetReportID = !forceConcierge && isInSidePanel && sidePanelReportID ? sidePanelReportID : conciergeReportID; + const [sidePanelReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(sidePanelReportID)}`); + + const isSidePanelOnConciergeThread = !!conciergeReportID && sidePanelReport?.parentReportID === conciergeReportID; + const targetReportID = !forceConcierge && isInSidePanel && sidePanelReportID && !isSidePanelOnConciergeThread ? sidePanelReportID : conciergeReportID; const [targetReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(targetReportID)}`); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const {timezone, accountID: currentUserAccountID} = currentUserPersonalDetails; const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); const delegateAccountID = useDelegateAccountID(); - const {isBetaEnabled} = usePermissions(); + const {setShowFullHistory} = useConciergeChatHistoryActions(); const shouldShowAskConcierge = !!targetReportID && !!targetReport; - const shouldRespondInThread = targetReportID === conciergeReportID && isBetaEnabled(CONST.BETAS.CONCIERGE_RESPOND_IN_THREAD); + const shouldRespondInThread = targetReportID === conciergeReportID; const askConcierge = (searchQuery: string) => { const trimmedQuery = searchQuery.trim(); @@ -45,6 +49,9 @@ function useAskConcierge({forceConcierge = false}: {forceConcierge?: boolean} = } const isTask = createTaskFromMarkdown({text: trimmedQuery, parentReport: targetReport, currentUserPersonalDetails, quickAction, delegateAccountID}); if (isTask) { + if (shouldRespondInThread && !isInSidePanel) { + setShowFullHistory(true); + } openConciergeAnywhere({forceConcierge}); return; } @@ -73,8 +80,7 @@ function useAskConcierge({forceConcierge = false}: {forceConcierge?: boolean} = return; } - const willOpenThread = shouldRespondInThread && (!Array.isArray(attachments) || attachments.length === 1); - const conciergeThreadReportID = willOpenThread ? generateReportID() : undefined; + const conciergeThreadReportID = shouldRespondInThread ? generateReportID() : undefined; addAttachmentWithComment({ report: targetReport, notifyReportID: targetReportID, diff --git a/src/hooks/useConciergeAskState.ts b/src/hooks/useConciergeAskState.ts new file mode 100644 index 000000000000..1cbed019b8ff --- /dev/null +++ b/src/hooks/useConciergeAskState.ts @@ -0,0 +1,71 @@ +import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {ReportsSplitNavigatorParamList} from '@libs/Navigation/types'; + +import {useConciergeChatHistoryState} from '@pages/inbox/ConciergeChatHistoryContext'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import type {Report} from '@src/types/onyx'; +import type {ReportActions} from '@src/types/onyx/ReportAction'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import {useRoute} from '@react-navigation/native'; + +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useIsAskConciergeChat from './useIsAskConciergeChat'; +import useOnyx from './useOnyx'; +import useResponsiveLayout from './useResponsiveLayout'; + +type ConciergeAskState = { + /** Whether the earlier conversation of the Concierge chat in the Inbox is shown */ + isHistoryExpanded: boolean; + + /** Whether to show the `Ask me anything!` welcome screen in place of the messages */ + shouldShowWelcome: boolean; + + /** Whether the welcome screen and the composer are centered together on the screen */ + shouldCenterWelcomeWithComposer: boolean; + + /** Whether the Concierge chat has a message from someone else that the user hasn't read */ + hasUnreadMessageFromOthers: boolean; +}; + +function useConciergeAskState(reportID: string | undefined): ConciergeAskState { + const isAskConciergeChat = useIsAskConciergeChat(reportID); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {showFullHistory, isHistoryCollapsedByUser} = useConciergeChatHistoryState(); + const route = useRoute>(); + const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + + const hasFailedSendSelector = (actions: OnyxEntry) => { + if (!isAskConciergeChat || !actions) { + return false; + } + + // A failed send stays on screen so the user can retry or dismiss it. + return Object.values(actions).some((action) => !isEmptyObject(action.errors)); + }; + const [hasFailedSend] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + selector: hasFailedSendSelector, + }); + + const hasUnreadMessageFromOthersSelector = (report: OnyxEntry) => + isAskConciergeChat && !!report?.lastReadTime && (report.lastVisibleActionCreated ?? '') > report.lastReadTime && report.lastActorAccountID !== currentUserAccountID; + const [hasUnreadMessageFromOthers = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, { + selector: hasUnreadMessageFromOthersSelector, + }); + + const isHistoryExpanded = isAskConciergeChat && (showFullHistory || !!route.params?.reportActionID); + const shouldShowWelcome = isAskConciergeChat && !isHistoryExpanded && (isHistoryCollapsedByUser || !hasFailedSend) && !hasUnreadMessageFromOthers; + + return { + isHistoryExpanded, + shouldShowWelcome, + shouldCenterWelcomeWithComposer: shouldShowWelcome && !shouldUseNarrowLayout, + hasUnreadMessageFromOthers, + }; +} + +export default useConciergeAskState; diff --git a/src/hooks/useConciergeSessionStartTime.ts b/src/hooks/useConciergeSessionStartTime.ts deleted file mode 100644 index 627c7bde9048..000000000000 --- a/src/hooks/useConciergeSessionStartTime.ts +++ /dev/null @@ -1,18 +0,0 @@ -import {useConciergeSessionState} from '@pages/inbox/ConciergeSessionContext'; - -import useIsInSidePanel from './useIsInSidePanel'; -import useSidePanelState from './useSidePanelState'; - -/** - * Returns the Concierge session start time from the context that actually tracks it: SidePanelStateContext in the - * side panel, ConciergeSessionContext in the main DM. Each is null on the other surface, so always read via this - * hook. Only meaningful in a Concierge chat - callers gate with e.g. isConciergeHiddenHistory. - */ -function useConciergeSessionStartTime(): string | null { - const isInSidePanel = useIsInSidePanel(); - const {sessionStartTime: mainDMSessionStartTime} = useConciergeSessionState(); - const {sessionStartTime: sidePanelSessionStartTime} = useSidePanelState(); - return isInSidePanel ? sidePanelSessionStartTime : mainDMSessionStartTime; -} - -export default useConciergeSessionStartTime; diff --git a/src/hooks/useConciergeSidePanelReportActions.ts b/src/hooks/useConciergeSidePanelReportActions.ts index 42adbca81700..dbf3bae45e93 100644 --- a/src/hooks/useConciergeSidePanelReportActions.ts +++ b/src/hooks/useConciergeSidePanelReportActions.ts @@ -1,25 +1,12 @@ import DateUtils from '@libs/DateUtils'; -import {isCreatedAction, isCurrentUserPendingAddAction, isDeletedParentAction} from '@libs/ReportActionsUtils'; +import {isCreatedAction, isCurrentUserPendingAddAction} from '@libs/ReportActionsUtils'; import {buildConciergeGreetingReportAction} from '@libs/ReportUtils'; -import CONST from '@src/CONST'; import type * as OnyxTypes from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import {useLayoutEffect, useState} from 'react'; - -/** An OPEN task assigned to the current user. Canceled tasks stay OPEN optimistically, so deleted parents are excluded. */ - -function isOpenChildTaskAction(action: OnyxTypes.ReportAction, currentUserAccountID: number): boolean { - return ( - action.childType === CONST.REPORT.TYPE.TASK && - action.childManagerAccountID === currentUserAccountID && - action.childStateNum === CONST.REPORT.STATE_NUM.OPEN && - action.childStatusNum === CONST.REPORT.STATUS_NUM.OPEN && - !isDeletedParentAction(action) - ); -} +import {useState} from 'react'; type UseConciergeSidePanelReportActionsParams = { report: OnyxEntry; @@ -32,11 +19,6 @@ type UseConciergeSidePanelReportActionsParams = { currentUserAccountID: number; greetingText: string; loadOlderChats: (force?: boolean) => void; - isConciergeMainDM?: boolean; - showFullHistory?: boolean; - onSetShowFullHistory?: (show: boolean) => void; - hadMessagesAtSessionStart?: boolean; - onSetHadMessagesAtSessionStart?: (value: boolean) => void; }; function useConciergeSidePanelReportActions({ @@ -50,18 +32,8 @@ function useConciergeSidePanelReportActions({ currentUserAccountID, greetingText, loadOlderChats, - isConciergeMainDM, - showFullHistory: externalShowFullHistory, - onSetShowFullHistory, - hadMessagesAtSessionStart: externalHadMessagesAtSessionStart, - onSetHadMessagesAtSessionStart, }: UseConciergeSidePanelReportActionsParams) { - const [localShowFullHistory, setLocalShowFullHistory] = useState(false); - const [localHadMessagesAtSessionStart, setLocalHadMessagesAtSessionStart] = useState( - () => - externalHadMessagesAtSessionStart ?? - (!!isConciergeMainDM && !!sessionStartTime && visibleReportActions.some((action) => !isCreatedAction(action) && action.created >= sessionStartTime)), - ); + const [showFullHistory, setShowFullHistory] = useState(false); const [prevSessionStartTime, setPrevSessionStartTime] = useState(sessionStartTime); const [prevHasUserSentMessage, setPrevHasUserSentMessage] = useState(hasUserSentMessage); @@ -69,19 +41,13 @@ function useConciergeSidePanelReportActions({ // them sticky rather than re-deriving visibility from the timestamp. const [sessionSentActionIDs, setSessionSentActionIDs] = useState>(() => new Set()); - const hadMessagesAtSessionStart = localHadMessagesAtSessionStart; - const showFullHistory = externalShowFullHistory ?? localShowFullHistory; - const setShowFullHistory = onSetShowFullHistory ?? setLocalShowFullHistory; - if (prevSessionStartTime !== sessionStartTime) { setPrevSessionStartTime(sessionStartTime); - setLocalShowFullHistory(false); - const messagesExistAtStart = !!isConciergeMainDM && !!sessionStartTime && visibleReportActions.some((action) => !isCreatedAction(action) && action.created >= sessionStartTime); - setLocalHadMessagesAtSessionStart(messagesExistAtStart); + setShowFullHistory(false); setSessionSentActionIDs(new Set()); } else if (prevHasUserSentMessage && !hasUserSentMessage) { setPrevHasUserSentMessage(hasUserSentMessage); - setLocalShowFullHistory(false); + setShowFullHistory(false); } else if (prevHasUserSentMessage !== hasUserSentMessage) { setPrevHasUserSentMessage(hasUserSentMessage); } @@ -94,10 +60,6 @@ function useConciergeSidePanelReportActions({ setSessionSentActionIDs((prev) => new Set([...prev, ...pendingSentActionIDs])); } - useLayoutEffect(() => { - onSetHadMessagesAtSessionStart?.(localHadMessagesAtSessionStart); - }, [localHadMessagesAtSessionStart, onSetHadMessagesAtSessionStart]); - // 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. @@ -125,18 +87,10 @@ function useConciergeSidePanelReportActions({ hasPreviousMessages = hasPreSessionActionInLoadedSet || hasOlderActions; } - // Main DM only: check if there are any messages (from any actor) after sessionStartTime. - // When true, we have unread content to display and should not enter welcome mode. - const hasMessagesInSession = - !(!isConciergeMainDM || !isConciergeHiddenHistory || !sessionStartTime) && visibleReportActions.some((action) => !isCreatedAction(action) && action.created >= sessionStartTime); - - // Main DM only: the welcome state must stand down for a pinned task, or `filterActions` returns before it renders. - const hasOpenChildTask = !(!isConciergeMainDM || !isConciergeHiddenHistory) && visibleReportActions.some((action) => isOpenChildTaskAction(action, currentUserAccountID)); - // A re-stamp flips `hasUserSentMessage` too, so the sticky set is what keeps the welcome state from coming back. const hasSentInSession = hasUserSentMessage || sessionSentActionIDs.size > 0; - const showConciergeSidePanelWelcome = isConciergeHiddenHistory && hadUserMessageAtSessionStart && !hasSentInSession && !showFullHistory && !hasMessagesInSession && !hasOpenChildTask; - const showConciergeGreeting = isConciergeHiddenHistory && hadUserMessageAtSessionStart && !showFullHistory && (!isConciergeMainDM || !hadMessagesAtSessionStart); + const showConciergeSidePanelWelcome = isConciergeHiddenHistory && hadUserMessageAtSessionStart && !hasSentInSession && !showFullHistory; + const showConciergeGreeting = isConciergeHiddenHistory && hadUserMessageAtSessionStart && !showFullHistory; let conciergeGreetingAction; if (!showConciergeGreeting) { @@ -154,7 +108,7 @@ function useConciergeSidePanelReportActions({ // Used to filter actions so that only messages from the first user message // onwards are shown (upstream side panel behavior). const firstUserMessageCreated = - isConciergeMainDM || showConciergeSidePanelWelcome || !isConciergeHiddenHistory || !hasUserSentMessage || !sessionStartTime + showConciergeSidePanelWelcome || !isConciergeHiddenHistory || !hasUserSentMessage || !sessionStartTime ? undefined : reportActions.reduce((earliest, action) => { const isCurrentSessionUserMessage = @@ -175,15 +129,6 @@ function useConciergeSidePanelReportActions({ if (sessionSentActionIDs.has(action.reportActionID)) { return true; } - if (isConciergeMainDM) { - // Pin a still-open task so collapsing read history never buries something the user has to act on. - return ( - isCreatedAction(action) || - isCurrentUserPendingAddAction(action, currentUserAccountID) || - isOpenChildTaskAction(action, currentUserAccountID) || - action.created >= sessionStartTime - ); - } if (!firstUserMessageCreated) { return false; } @@ -217,7 +162,7 @@ function useConciergeSidePanelReportActions({ 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) { + if (conciergeGreetingAction) { const createdAction = actions.find(isCreatedAction); return createdAction ? [conciergeGreetingAction, createdAction] : [conciergeGreetingAction]; } diff --git a/src/hooks/useIsAskConciergeChat.ts b/src/hooks/useIsAskConciergeChat.ts new file mode 100644 index 000000000000..443bba256b9b --- /dev/null +++ b/src/hooks/useIsAskConciergeChat.ts @@ -0,0 +1,13 @@ +import ONYXKEYS from '@src/ONYXKEYS'; + +import useIsInSidePanel from './useIsInSidePanel'; +import useOnyx from './useOnyx'; + +/** Whether the report is the Concierge chat in the Inbox */ +function useIsAskConciergeChat(reportID: string | undefined): boolean { + const isInSidePanel = useIsInSidePanel(); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + return !!reportID && reportID === conciergeReportID && !isInSidePanel; +} + +export default useIsAskConciergeChat; diff --git a/src/hooks/useReportActionsListModel.ts b/src/hooks/useReportActionsListModel.ts index 6de80a82b8de..65a9a44d081e 100644 --- a/src/hooks/useReportActionsListModel.ts +++ b/src/hooks/useReportActionsListModel.ts @@ -3,7 +3,7 @@ import {canUserPerformWriteAction, isReportTransactionThread as isReportTransact import type {ReportsSplitNavigatorParamList} from '@navigation/types'; -import {useConciergeSessionActions, useConciergeSessionState} from '@pages/inbox/ConciergeSessionContext'; +import {useConciergeChatHistoryActions} from '@pages/inbox/ConciergeChatHistoryContext'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -11,6 +11,7 @@ import {reportActionsListLoadingStateSelector} from '@src/selectors/ReportMetaDa import {useRoute} from '@react-navigation/native'; +import useConciergeAskState from './useConciergeAskState'; import {useIsAppLoadPending} from './useInFlightRequests'; import useLoadReportActions from './useLoadReportActions'; import useNetworkWithOfflineStatus from './useNetworkWithOfflineStatus'; @@ -23,9 +24,9 @@ import useReportIsArchived from './useReportIsArchived'; /** * Single read/derive pipeline for the report-actions list: the subscriptions and derivations the skeleton - * decision and the list render both need, computed once. Side-effect-free (no writes/navigation/telemetry/ - * session-start). The guard calls it once and passes `state`/`actions` via `ReportActionsListStateContext` - * and `ReportActionsListActionsContext` so the content doesn't re-subscribe. + * decision and the list render both need, computed once. Side-effect-free (no writes/navigation/telemetry). + * The guard calls it once and passes `state`/`actions` via `ReportActionsListStateContext` and + * `ReportActionsListActionsContext` so the content doesn't re-subscribe. */ function useReportActionsListModel(reportID: string, isReportLoadPending: boolean) { const {isOffline} = useNetworkWithOfflineStatus(); @@ -60,8 +61,12 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea const isLoadingOlderReportActions = reportLoadingState?.isLoadingOlderReportActions; const hasLoadingOlderReportActionsError = reportLoadingState?.hasLoadingOlderReportActionsError; - const {sessionStartTime, showFullHistory: conciergeShowFullHistory, hadMessagesAtSessionStart: conciergeHadMessagesAtSessionStart} = useConciergeSessionState(); - const {setShowFullHistory: setConciergeShowFullHistory, setHadMessagesAtSessionStart: setConciergeHadMessagesAtSessionStart} = useConciergeSessionActions(); + const {setShowFullHistory: setConciergeShowFullHistory} = useConciergeChatHistoryActions(); + const { + shouldShowWelcome: shouldShowConciergeWelcome, + isHistoryExpanded: isConciergeHistoryExpanded, + hasUnreadMessageFromOthers: hasUnreadConciergeMessageFromOthers, + } = useConciergeAskState(reportID); const isReportTransactionThread = isReportTransactionThreadUtil(report); const shouldBeAlignedToTop = shouldReportAlignToTop(report, parentReportAction); @@ -83,37 +88,22 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea newestFetchedReportActionID: reportPaginationState?.newestFetchedReportActionID, }); - const { - sortedReportActions, - sortedVisibleReportActions, - isConciergeMainDM, - isConciergeHiddenHistory, - showConciergeSidePanelWelcome, - showFullHistory, - hasPreviousMessages, - handleShowPreviousMessages, - } = useReportActionsVisibility({ - reportID, - reportActions, - allReportActions, - canPerformWriteAction, - hasOlderActions, - loadOlderChats, - mainDMSessionStartTime: sessionStartTime, - conciergeShowFullHistory: conciergeShowFullHistory || !!reportActionIDFromRoute, - setConciergeShowFullHistory, - conciergeHadMessagesAtSessionStart, - setConciergeHadMessagesAtSessionStart, - }); + const {sortedReportActions, sortedVisibleReportActions, isConciergeHiddenHistory, showConciergeSidePanelWelcome, showFullHistory, hasPreviousMessages, handleShowPreviousMessages} = + useReportActionsVisibility({ + reportID, + reportActions, + allReportActions, + canPerformWriteAction, + hasOlderActions, + loadOlderChats, + setConciergeShowFullHistory, + }); const isMissingReportActions = sortedVisibleReportActions.length === 0; const isSingleExpenseReport = reportPreviewAction?.childMoneyRequestCount === 1; - // allReportActions excludes the synthetic CREATED action always injected for Concierge, so this is - // false only on a genuinely cold load with no cached history. - const hasCachedReportActions = allReportActions.length > 0; // Readiness signals for the skeleton decision (some also read by the guard's effect hooks). Off the - // context so their churn (loading, app-load, concierge-session, navigation) never re-renders the list. + // context so their churn (loading, app-load, navigation) never re-renders the list. const readinessSignals = { report, reportResult, @@ -137,14 +127,13 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea isSingleExpenseReport, isMissingReportActions, isConciergeHiddenHistory, - isConciergeMainDM, - hasCachedReportActions, showConciergeSidePanelWelcome, derivedIsEmptyReport, + hasUnreadConciergeMessageFromOthers, }; // The render state slice on `ReportActionsListStateContext`; this is what drives list re-renders. - // Ambient state (network, route, archived, concierge session) is read locally in the content instead + // Ambient state (network, route, archived, concierge chat history) is read locally in the content instead // of carried here. const state = { report, @@ -162,6 +151,8 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea showFullHistory, hasPreviousMessages, allReportActionIDs, + shouldShowConciergeWelcome, + isConciergeHistoryExpanded, }; // The command handles on `ReportActionsListActionsContext`. Referentially stable, so actions-only diff --git a/src/hooks/useReportActionsVisibility.ts b/src/hooks/useReportActionsVisibility.ts index b99e6d56d5e7..0ec24550afc0 100644 --- a/src/hooks/useReportActionsVisibility.ts +++ b/src/hooks/useReportActionsVisibility.ts @@ -24,18 +24,13 @@ type UseReportActionsVisibilityParams = { canPerformWriteAction: boolean; hasOlderActions: boolean; loadOlderChats: (force?: boolean) => void; - mainDMSessionStartTime?: string | null; - conciergeShowFullHistory?: boolean; setConciergeShowFullHistory?: (show: boolean) => void; - conciergeHadMessagesAtSessionStart?: boolean; - setConciergeHadMessagesAtSessionStart?: (value: boolean) => void; }; type UseReportActionsVisibilityResult = { sortedReportActions: ReportAction[]; sortedVisibleReportActions: ReportAction[]; isConciergeSidePanel: boolean; - isConciergeMainDM: boolean; isConciergeHiddenHistory: boolean; showConciergeSidePanelWelcome: boolean; showFullHistory: boolean; @@ -50,11 +45,7 @@ function useReportActionsVisibility({ canPerformWriteAction, hasOlderActions, loadOlderChats, - mainDMSessionStartTime, - conciergeShowFullHistory, setConciergeShowFullHistory, - conciergeHadMessagesAtSessionStart, - setConciergeHadMessagesAtSessionStart, }: UseReportActionsVisibilityParams): UseReportActionsVisibilityResult { const {isOffline} = useNetwork(); const {translate} = useLocalize(); @@ -69,10 +60,9 @@ function useReportActionsVisibility({ const isInSidePanel = useIsInSidePanel(); const isConciergeSidePanel = isInSidePanel && isConciergeChatReport(report, conciergeReportID); const isConciergeMainDM = !isInSidePanel && isConciergeChatReport(report, conciergeReportID); - const isConciergeHiddenHistory = isConciergeSidePanel || isConciergeMainDM; + const isConciergeHiddenHistory = isConciergeSidePanel; - const {sessionStartTime: sidePanelSessionStartTime} = useSidePanelState(); - const sessionStartTime = isConciergeSidePanel ? sidePanelSessionStartTime : (mainDMSessionStartTime ?? null); + const {sessionStartTime} = useSidePanelState(); const hasUserSentMessage = isConciergeHiddenHistory && sessionStartTime @@ -121,23 +111,22 @@ function useReportActionsVisibility({ currentUserAccountID, greetingText: translate('common.concierge.greeting'), loadOlderChats, - isConciergeMainDM, - showFullHistory: isConciergeMainDM ? conciergeShowFullHistory : undefined, - onSetShowFullHistory: isConciergeMainDM ? setConciergeShowFullHistory : undefined, - hadMessagesAtSessionStart: isConciergeMainDM ? conciergeHadMessagesAtSessionStart : undefined, - onSetHadMessagesAtSessionStart: isConciergeMainDM ? setConciergeHadMessagesAtSessionStart : undefined, }); + const showConciergeMainDMHistory = () => { + setConciergeShowFullHistory?.(true); + loadOlderChats(true); + }; + return { sortedReportActions: filteredReportActions, sortedVisibleReportActions: filteredVisibleActions, isConciergeSidePanel, - isConciergeMainDM, isConciergeHiddenHistory, showConciergeSidePanelWelcome, showFullHistory, hasPreviousMessages, - handleShowPreviousMessages, + handleShowPreviousMessages: isConciergeMainDM ? showConciergeMainDMHistory : handleShowPreviousMessages, }; } diff --git a/src/hooks/useShouldSuppressConciergeIndicators.tsx b/src/hooks/useShouldSuppressConciergeIndicators.tsx index 72ba49a35db4..7c58fcd9bbf1 100644 --- a/src/hooks/useShouldSuppressConciergeIndicators.tsx +++ b/src/hooks/useShouldSuppressConciergeIndicators.tsx @@ -1,7 +1,5 @@ import {isCreatedAction, isCurrentUserPendingAddAction} from '@libs/ReportActionsUtils'; -import {useConciergeSessionState} from '@pages/inbox/ConciergeSessionContext'; - import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportActions} from '@src/types/onyx/ReportAction'; @@ -13,23 +11,19 @@ import useOnyx from './useOnyx'; import useSidePanelState from './useSidePanelState'; /** - * Returns true when thinking/typing indicators should be hidden. Two cases: - * 1. The Concierge welcome state — before any real message activity occurs - * in either the side panel or the main DM session. - * 2. The followup-list pending window — between trickle completion and the - * server reply with ``. + * Returns true when thinking/typing indicators should be hidden: in the main Concierge chat, in the Concierge + * side panel before the session has any messages, and while a followup list is pending. */ function useShouldSuppressConciergeIndicators(reportID: string | undefined): boolean { const isInSidePanel = useIsInSidePanel(); const {sessionStartTime: sidePanelSessionStartTime} = useSidePanelState(); - const {sessionStartTime: mainDMSessionStartTime} = useConciergeSessionState(); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [pendingFollowupList] = useOnyx(`${ONYXKEYS.COLLECTION.CONCIERGE_PENDING_FOLLOWUP_LIST}${reportID}`); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const isConciergeChat = reportID === conciergeReportID; - const sessionStartTime = isInSidePanel ? sidePanelSessionStartTime : mainDMSessionStartTime; + const sessionStartTime = isInSidePanel ? sidePanelSessionStartTime : null; // Derive the boolean inside the Onyx selector so the indicators re-render only when session activity // flips, not on every report-action change in the Concierge chat. diff --git a/src/hooks/useStartConciergeSession.ts b/src/hooks/useStartConciergeSession.ts deleted file mode 100644 index ce9459b25fd0..000000000000 --- a/src/hooks/useStartConciergeSession.ts +++ /dev/null @@ -1,58 +0,0 @@ -import {useConciergeSessionActions} from '@pages/inbox/ConciergeSessionContext'; - -import {useLayoutEffect} from 'react'; - -import type {ReportActionsReadinessSignals} from './useReportActionsListModel'; - -import {useCurrentReportIDState} from './useCurrentReportID'; - -type UseStartConciergeSessionParams = Pick & { - /** The ID of the report to display actions for */ - reportID: string; - - /** Whether actions are already cached, excluding the synthetic CREATED action */ - hasCachedReportActions: boolean; -}; - -/** - * Starts the Concierge main-DM session. Subscribes to currentReportID/startSession itself so the pipeline - * doesn't carry them; currentReportID re-renders the guard but never the list. - */ -function useStartConciergeSession({reportID, isConciergeMainDM, oldestUnreadReportAction, hasOnceLoadedReportActions, hasCachedReportActions}: UseStartConciergeSessionParams) { - const {currentReportID} = useCurrentReportIDState(); - const {startSession} = useConciergeSessionActions(); - - // hasOnceLoadedReportActions is RAM-only and resets to falsy on a page - // refresh, but cached report actions persist in Onyx. Gating the session - // start on it alone would leave sessionStartTime null until openReport - // returns, during which filterActions collapses the cached history down to - // just the synthetic CREATED action (an almost-empty chat flash). Start the - // session as soon as cached actions exist so messages render immediately on - // refresh, matching the concierge skeleton condition. - const canStartConciergeSession = !!hasOnceLoadedReportActions || hasCachedReportActions; - - useLayoutEffect(() => { - if (!isConciergeMainDM || !canStartConciergeSession) { - return; - } - // Anchor the session to the unread action's created time, a stable pre-read - // boundary, rather than the live report.lastReadTime which readNewestAction - // bumps to `now` when the report opens. Re-runs when oldestUnreadReportAction - // resolves so a session that locked to `now` before the anchor loaded is - // pulled back to keep the unread message visible. - startSession(oldestUnreadReportAction?.created); - }, [isConciergeMainDM, startSession, canStartConciergeSession, oldestUnreadReportAction?.created]); - - // On native the component stays mounted in the navigation stack, so the - // effect above never re-fires (its isConciergeMainDM dep is always true). - // Re-trigger startSession when the globally-focused report matches this - // report so the session age check runs after navigating away and back. - useLayoutEffect(() => { - if (!isConciergeMainDM || !canStartConciergeSession || currentReportID !== reportID) { - return; - } - startSession(oldestUnreadReportAction?.created); - }, [currentReportID, reportID, isConciergeMainDM, canStartConciergeSession, startSession, oldestUnreadReportAction?.created]); -} - -export default useStartConciergeSession; diff --git a/src/languages/de.ts b/src/languages/de.ts index 50b52a7f0065..3d03b6e87fe7 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -510,7 +510,32 @@ const translations: TranslationDeepObject = { quarter: 'Quartal', vacationDelegate: 'Urlaubsvertretung', expensifyLogo: 'Expensify-Logo', - concierge: {greeting: 'Hallo, wie kann ich helfen?', showHistory: 'Verlauf anzeigen'}, + concierge: { + greeting: 'Hallo, wie kann ich helfen?', + showHistory: 'Verlauf anzeigen', + subtitle: 'Dein persönlicher KI-Assistent', + welcomeDescription: 'Concierge kann Fragen beantworten, Ausgaben aktualisieren und mehr.', + viewChatHistory: 'Chatverlauf anzeigen', + hideChatHistory: 'Chatverlauf ausblenden', + askNewQuestion: 'Neue Frage stellen', + newChat: 'Neuer Chat', + composerPlaceholders: { + spendLastMonth: 'Wie viel habe ich letzten Monat ausgegeben?', + topExpenses: 'Zeig mir meine größten Ausgaben in diesem Monat', + spendDrivers: 'Was hat meine Ausgaben im letzten Quartal getrieben?', + travelSpendChange: 'Wie haben sich meine Reisekosten seit letztem Monat verändert?', + topSpender: 'Wer hat diesen Monat am meisten ausgegeben?', + missingCategory: 'Welche Ausgaben haben keine Kategorie?', + awaitingApproval: 'Welche Berichte warten auf meine Genehmigung?', + notReimbursed: 'Was wurde mir noch nicht erstattet?', + unsubmitted: 'Welche meiner Ausgaben sind noch nicht eingereicht?', + leftToPay: 'Was ist diesen Monat noch zu zahlen?', + createExpense: 'Erstelle eine Ausgabe über 30 $ für ein Mittagessen', + combineExpenses: 'Fasse alle meine offenen Ausgaben in einem Bericht zusammen', + exportPdf: 'Exportiere meinen letzten Bericht als PDF', + exportCsv: 'Gib mir eine CSV meiner Ausgaben über 50 $', + }, + }, duplicateReport: 'Duplizierten Bericht', approver: 'Genehmiger', goToConcierge: 'Zu Concierge', diff --git a/src/languages/el.ts b/src/languages/el.ts index d971e3d7f6b7..673e0a184ee1 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -515,6 +515,28 @@ const translations: TranslationDeepObject = { concierge: { greeting: 'Γεια σας, πώς μπορώ να σας βοηθήσω;', showHistory: 'Εμφάνιση ιστορικού', + subtitle: 'Ο προσωπικός σας βοηθός AI', + welcomeDescription: 'Ο Concierge μπορεί να απαντήσει σε ερωτήσεις, να ενημερώσει δαπάνες και άλλα.', + viewChatHistory: 'Προβολή ιστορικού συνομιλιών', + hideChatHistory: 'Απόκρυψη ιστορικού συνομιλιών', + askNewQuestion: 'Κάντε μια νέα ερώτηση', + newChat: 'Νέα συνομιλία', + composerPlaceholders: { + spendLastMonth: 'Πόσα ξόδεψα τον προηγούμενο μήνα;', + topExpenses: 'Δείξε μου τις μεγαλύτερες δαπάνες μου αυτόν τον μήνα', + spendDrivers: 'Τι οδήγησε τις δαπάνες μου το προηγούμενο τρίμηνο;', + travelSpendChange: 'Πώς άλλαξαν οι δαπάνες ταξιδιού μου από τον προηγούμενο μήνα;', + topSpender: 'Ποιος ξόδεψε τα περισσότερα αυτόν τον μήνα;', + missingCategory: 'Ποιες δαπάνες δεν έχουν κατηγορία;', + awaitingApproval: 'Ποιες αναφορές περιμένουν την έγκρισή μου;', + notReimbursed: 'Τι δεν μου έχει αποζημιωθεί ακόμη;', + unsubmitted: 'Ποιες από τις δαπάνες μου δεν έχουν υποβληθεί ακόμη;', + leftToPay: 'Τι μένει να πληρωθεί αυτόν τον μήνα;', + createExpense: 'Δημιούργησε μια δαπάνη 30 $ για ένα γεύμα', + combineExpenses: 'Συνδύασε όλες τις ανοιχτές δαπάνες μου σε μία αναφορά', + exportPdf: 'Εξήγαγε την τελευταία μου αναφορά σε PDF', + exportCsv: 'Δώσε μου ένα CSV με τις δαπάνες μου άνω των 50 $', + }, }, vacationDelegate: 'Εκπρόσωπος διακοπών', expensifyLogo: 'Λογότυπο Expensify', diff --git a/src/languages/en.ts b/src/languages/en.ts index 68dbcfcf5e91..894d85d0c062 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -607,6 +607,28 @@ const translations = { concierge: { greeting: 'Hi there, how can I help?', showHistory: 'Show history', + subtitle: 'Your personal AI assistant', + welcomeDescription: 'Concierge can answer questions, update expenses, and more.', + viewChatHistory: 'View chat history', + hideChatHistory: 'Hide chat history', + askNewQuestion: 'Ask a new question', + newChat: 'New chat', + composerPlaceholders: { + spendLastMonth: 'How much did I spend last month?', + topExpenses: 'Show me my top expenses this month', + spendDrivers: 'What drove my spend last quarter?', + travelSpendChange: 'How did my travel spend change from last month?', + topSpender: 'Who spent the most this month?', + missingCategory: 'Which expenses are missing a category?', + awaitingApproval: 'Which reports are waiting on my approval?', + notReimbursed: "What haven't I been reimbursed for yet?", + unsubmitted: 'Which of my expenses are still unsubmitted?', + leftToPay: "What's left to pay this month?", + createExpense: 'Create an expense for a $30 lunch', + combineExpenses: 'Combine all my open expenses into one report', + exportPdf: 'Export my last report to PDF', + exportCsv: 'Give me a CSV of my expenses over $50', + }, }, vacationDelegate: 'Vacation delegate', expensifyLogo: 'Expensify logo', diff --git a/src/languages/es.ts b/src/languages/es.ts index ac2b51f02a8f..bb0517bce491 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -505,6 +505,28 @@ const translations: TranslationDeepObject = { concierge: { greeting: 'Hola, ¿en qué puedo ayudarte?', showHistory: 'Mostrar historial', + subtitle: 'Tu asistente de IA personal', + welcomeDescription: 'Concierge puede responder preguntas, actualizar gastos y mucho más.', + viewChatHistory: 'Ver historial del chat', + hideChatHistory: 'Ocultar historial del chat', + askNewQuestion: 'Haz una nueva pregunta', + newChat: 'Nuevo chat', + composerPlaceholders: { + spendLastMonth: '¿Cuánto gasté el mes pasado?', + topExpenses: 'Muéstrame mis mayores gastos de este mes', + spendDrivers: '¿Qué impulsó mi gasto el trimestre pasado?', + travelSpendChange: '¿Cómo cambió mi gasto en viajes desde el mes pasado?', + topSpender: '¿Quién gastó más este mes?', + missingCategory: '¿Qué gastos no tienen categoría?', + awaitingApproval: '¿Qué informes están esperando mi aprobación?', + notReimbursed: '¿Qué no se me ha reembolsado todavía?', + unsubmitted: '¿Cuáles de mis gastos siguen sin enviarse?', + leftToPay: '¿Qué queda por pagar este mes?', + createExpense: 'Crea un gasto de $30 por un almuerzo', + combineExpenses: 'Combina todos mis gastos abiertos en un informe', + exportPdf: 'Exporta mi último informe a PDF', + exportCsv: 'Dame un CSV de mis gastos de más de $50', + }, }, vacationDelegate: 'Delegado de vacaciones', expensifyLogo: 'Logo de Expensify', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index ddf946bf0993..5f1c3ab30ce0 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -509,7 +509,32 @@ const translations: TranslationDeepObject = { quarter: 'Trimestre', vacationDelegate: 'Délégué de vacances', expensifyLogo: 'Logo Expensify', - concierge: {greeting: 'Bonjour, comment puis-je vous aider ?', showHistory: 'Afficher l’historique'}, + concierge: { + greeting: 'Bonjour, comment puis-je vous aider ?', + showHistory: 'Afficher l’historique', + subtitle: 'Votre assistant IA personnel', + welcomeDescription: 'Concierge peut répondre à vos questions, mettre à jour vos dépenses et plus encore.', + viewChatHistory: 'Afficher l’historique du chat', + hideChatHistory: 'Masquer l’historique du chat', + askNewQuestion: 'Poser une nouvelle question', + newChat: 'Nouveau chat', + composerPlaceholders: { + spendLastMonth: 'Combien ai-je dépensé le mois dernier ?', + topExpenses: 'Montre-moi mes plus grosses dépenses ce mois-ci', + spendDrivers: 'Qu’est-ce qui a fait augmenter mes dépenses le trimestre dernier ?', + travelSpendChange: 'Comment mes dépenses de voyage ont-elles évolué depuis le mois dernier ?', + topSpender: 'Qui a le plus dépensé ce mois-ci ?', + missingCategory: 'Quelles dépenses n’ont pas de catégorie ?', + awaitingApproval: 'Quels rapports attendent mon approbation ?', + notReimbursed: 'Qu’est-ce qui ne m’a pas encore été remboursé ?', + unsubmitted: 'Lesquelles de mes dépenses ne sont pas encore soumises ?', + leftToPay: 'Que reste-t-il à payer ce mois-ci ?', + createExpense: 'Crée une dépense de 30 $ pour un déjeuner', + combineExpenses: 'Regroupe toutes mes dépenses ouvertes dans un seul rapport', + exportPdf: 'Exporte mon dernier rapport en PDF', + exportCsv: 'Donne-moi un CSV de mes dépenses de plus de 50 $', + }, + }, duplicateReport: 'Note de frais en double', approver: 'Approbateur', goToConcierge: 'Aller à Concierge', diff --git a/src/languages/it.ts b/src/languages/it.ts index 483c54e659da..67fe9e310481 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -509,7 +509,32 @@ const translations: TranslationDeepObject = { quarter: 'Trimestre', vacationDelegate: 'Delega ferie', expensifyLogo: 'Logo Expensify', - concierge: {greeting: 'Ciao, come posso aiutarti?', showHistory: 'Mostra cronologia'}, + concierge: { + greeting: 'Ciao, come posso aiutarti?', + showHistory: 'Mostra cronologia', + subtitle: 'Il tuo assistente AI personale', + welcomeDescription: 'Concierge può rispondere a domande, aggiornare spese e altro ancora.', + viewChatHistory: 'Mostra cronologia chat', + hideChatHistory: 'Nascondi cronologia chat', + askNewQuestion: 'Fai una nuova domanda', + newChat: 'Nuova chat', + composerPlaceholders: { + spendLastMonth: 'Quanto ho speso il mese scorso?', + topExpenses: 'Mostrami le mie spese più alte di questo mese', + spendDrivers: 'Cosa ha determinato le mie spese lo scorso trimestre?', + travelSpendChange: 'Come sono cambiate le mie spese di viaggio rispetto al mese scorso?', + topSpender: 'Chi ha speso di più questo mese?', + missingCategory: 'Quali spese non hanno una categoria?', + awaitingApproval: 'Quali report sono in attesa della mia approvazione?', + notReimbursed: 'Cosa non mi è ancora stato rimborsato?', + unsubmitted: 'Quali delle mie spese non sono ancora state inviate?', + leftToPay: 'Cosa resta da pagare questo mese?', + createExpense: 'Crea una spesa di 30 $ per un pranzo', + combineExpenses: 'Unisci tutte le mie spese aperte in un unico report', + exportPdf: 'Esporta il mio ultimo report in PDF', + exportCsv: 'Dammi un CSV delle mie spese superiori a 50 $', + }, + }, duplicateReport: 'Report duplicato', approver: 'Approvante', goToConcierge: 'Vai a Concierge', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 29d279a7f9ce..c784612e0396 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -508,7 +508,32 @@ const translations: TranslationDeepObject = { quarter: '四半期', vacationDelegate: '休暇代理人', expensifyLogo: 'Expensifyロゴ', - concierge: {greeting: 'こんにちは、どのようにお手伝いできますか?', showHistory: '履歴を表示'}, + concierge: { + greeting: 'こんにちは、どのようにお手伝いできますか?', + showHistory: '履歴を表示', + subtitle: 'あなた専用のAIアシスタント', + welcomeDescription: 'Conciergeは質問への回答や経費の更新などを行えます。', + viewChatHistory: 'チャット履歴を表示', + hideChatHistory: 'チャット履歴を非表示', + askNewQuestion: '新しい質問をする', + newChat: '新しいチャット', + composerPlaceholders: { + spendLastMonth: '先月はいくら使いましたか?', + topExpenses: '今月の支出が多い経費を見せて', + spendDrivers: '前四半期の支出の要因は何ですか?', + travelSpendChange: '先月から出張費はどう変わりましたか?', + topSpender: '今月いちばん使ったのは誰ですか?', + missingCategory: 'カテゴリーが設定されていない経費はどれですか?', + awaitingApproval: '承認待ちのレポートはどれですか?', + notReimbursed: 'まだ精算されていないものは何ですか?', + unsubmitted: 'まだ提出していない経費はどれですか?', + leftToPay: '今月の未払い分はいくらですか?', + createExpense: 'ランチの30ドルの経費を作成して', + combineExpenses: '未処理の経費をすべて1つのレポートにまとめて', + exportPdf: '直近のレポートをPDFでエクスポートして', + exportCsv: '50ドル以上の経費のCSVをください', + }, + }, duplicateReport: 'レポートを複製', approver: '承認者', goToConcierge: 'Conciergeへ移動', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 03187d0f7333..bae1116e5e3b 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -509,7 +509,32 @@ const translations: TranslationDeepObject = { quarter: 'Kwartaal', vacationDelegate: 'Vertegenwoordiger tijdens vakantie', expensifyLogo: 'Expensify-logo', - concierge: {greeting: 'Hoi, waarmee kan ik je helpen?', showHistory: 'Geschiedenis weergeven'}, + concierge: { + greeting: 'Hoi, waarmee kan ik je helpen?', + showHistory: 'Geschiedenis weergeven', + subtitle: 'Je persoonlijke AI-assistent', + welcomeDescription: 'Concierge kan vragen beantwoorden, uitgaven bijwerken en meer.', + viewChatHistory: 'Chatgeschiedenis weergeven', + hideChatHistory: 'Chatgeschiedenis verbergen', + askNewQuestion: 'Stel een nieuwe vraag', + newChat: 'Nieuwe chat', + composerPlaceholders: { + spendLastMonth: 'Hoeveel heb ik vorige maand uitgegeven?', + topExpenses: 'Laat mijn grootste uitgaven van deze maand zien', + spendDrivers: 'Waardoor stegen mijn uitgaven vorig kwartaal?', + travelSpendChange: 'Hoe zijn mijn reiskosten veranderd sinds vorige maand?', + topSpender: 'Wie heeft deze maand het meest uitgegeven?', + missingCategory: 'Welke uitgaven hebben geen categorie?', + awaitingApproval: 'Welke rapporten wachten op mijn goedkeuring?', + notReimbursed: 'Wat is nog niet aan mij vergoed?', + unsubmitted: 'Welke van mijn uitgaven zijn nog niet ingediend?', + leftToPay: 'Wat moet er deze maand nog betaald worden?', + createExpense: 'Maak een uitgave van $30 voor een lunch', + combineExpenses: 'Voeg al mijn open uitgaven samen in één rapport', + exportPdf: 'Exporteer mijn laatste rapport naar PDF', + exportCsv: 'Geef me een CSV van mijn uitgaven boven $50', + }, + }, duplicateReport: 'Dubbel rapport', approver: 'Fiatteur', goToConcierge: 'Ga naar Concierge', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 6753f10bbd05..08bece2034e0 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -514,7 +514,32 @@ const translations: TranslationDeepObject = { quarter: 'Kwartał', vacationDelegate: 'Zastępca urlopowy', expensifyLogo: 'Logo Expensify', - concierge: {greeting: 'Cześć, w czym mogę pomóc?', showHistory: 'Pokaż historię'}, + concierge: { + greeting: 'Cześć, w czym mogę pomóc?', + showHistory: 'Pokaż historię', + subtitle: 'Twój osobisty asystent AI', + welcomeDescription: 'Concierge może odpowiadać na pytania, aktualizować wydatki i nie tylko.', + viewChatHistory: 'Pokaż historię czatu', + hideChatHistory: 'Ukryj historię czatu', + askNewQuestion: 'Zadaj nowe pytanie', + newChat: 'Nowy czat', + composerPlaceholders: { + spendLastMonth: 'Ile wydałem w zeszłym miesiącu?', + topExpenses: 'Pokaż moje największe wydatki w tym miesiącu', + spendDrivers: 'Co napędzało moje wydatki w zeszłym kwartale?', + travelSpendChange: 'Jak zmieniły się moje wydatki na podróże od zeszłego miesiąca?', + topSpender: 'Kto wydał najwięcej w tym miesiącu?', + missingCategory: 'Które wydatki nie mają kategorii?', + awaitingApproval: 'Które raporty czekają na moje zatwierdzenie?', + notReimbursed: 'Co nie zostało mi jeszcze zwrócone?', + unsubmitted: 'Które z moich wydatków nie zostały jeszcze wysłane?', + leftToPay: 'Co pozostało do zapłaty w tym miesiącu?', + createExpense: 'Utwórz wydatek 30 $ na lunch', + combineExpenses: 'Połącz wszystkie moje otwarte wydatki w jeden raport', + exportPdf: 'Wyeksportuj mój ostatni raport do PDF', + exportCsv: 'Daj mi plik CSV z moimi wydatkami powyżej 50 $', + }, + }, duplicateReport: 'Zduplikowany raport', approver: 'Osoba zatwierdzająca', goToConcierge: 'Przejdź do Concierge', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index b6d0576fbb2c..e9309b10b04d 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -508,7 +508,32 @@ const translations: TranslationDeepObject = { quarter: 'Trimestre', vacationDelegate: 'Delegado de férias', expensifyLogo: 'Logo da Expensify', - concierge: {greeting: 'Oi, como posso ajudar?', showHistory: 'Mostrar histórico'}, + concierge: { + greeting: 'Oi, como posso ajudar?', + showHistory: 'Mostrar histórico', + subtitle: 'Seu assistente de IA pessoal', + welcomeDescription: 'O Concierge pode responder perguntas, atualizar despesas e muito mais.', + viewChatHistory: 'Ver histórico do chat', + hideChatHistory: 'Ocultar histórico do chat', + askNewQuestion: 'Faça uma nova pergunta', + newChat: 'Novo chat', + composerPlaceholders: { + spendLastMonth: 'Quanto gastei no mês passado?', + topExpenses: 'Mostre minhas maiores despesas deste mês', + spendDrivers: 'O que impulsionou meus gastos no último trimestre?', + travelSpendChange: 'Como meus gastos com viagens mudaram desde o mês passado?', + topSpender: 'Quem gastou mais neste mês?', + missingCategory: 'Quais despesas estão sem categoria?', + awaitingApproval: 'Quais relatórios estão aguardando minha aprovação?', + notReimbursed: 'O que ainda não foi reembolsado para mim?', + unsubmitted: 'Quais das minhas despesas ainda não foram enviadas?', + leftToPay: 'O que falta pagar neste mês?', + createExpense: 'Crie uma despesa de US$ 30 para um almoço', + combineExpenses: 'Combine todas as minhas despesas abertas em um relatório', + exportPdf: 'Exporte meu último relatório em PDF', + exportCsv: 'Me dê um CSV das minhas despesas acima de US$ 50', + }, + }, duplicateReport: 'Duplicar relatório', approver: 'Aprovador', goToConcierge: 'Ir para o Concierge', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index edd57f3d721a..0a90423a56c9 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -504,7 +504,32 @@ const translations: TranslationDeepObject = { quarter: '季度', vacationDelegate: '休假代理', expensifyLogo: 'Expensify徽标', - concierge: {greeting: '你好,我能帮你做什么?', showHistory: '显示历史'}, + concierge: { + greeting: '你好,我能帮你做什么?', + showHistory: '显示历史', + subtitle: '你的专属 AI 助手', + welcomeDescription: 'Concierge 可以回答问题、更新支出等。', + viewChatHistory: '查看聊天记录', + hideChatHistory: '隐藏聊天记录', + askNewQuestion: '提出新问题', + newChat: '新聊天', + composerPlaceholders: { + spendLastMonth: '我上个月花了多少钱?', + topExpenses: '显示我本月金额最高的支出', + spendDrivers: '上个季度我的支出主要来自哪里?', + travelSpendChange: '我的差旅支出与上个月相比有什么变化?', + topSpender: '本月谁花得最多?', + missingCategory: '哪些支出没有类别?', + awaitingApproval: '哪些报告在等待我审批?', + notReimbursed: '还有哪些费用没有报销给我?', + unsubmitted: '我的哪些支出还没有提交?', + leftToPay: '本月还有多少待付款?', + createExpense: '创建一笔 30 美元的午餐支出', + combineExpenses: '把我所有未处理的支出合并到一个报告中', + exportPdf: '将我最近的报告导出为 PDF', + exportCsv: '给我一份超过 50 美元的支出 CSV', + }, + }, duplicateReport: '重复报销单', approver: '审批人', goToConcierge: '前往 Concierge', diff --git a/src/libs/API/parameters/AddCommentOrAttachmentParams.ts b/src/libs/API/parameters/AddCommentOrAttachmentParams.ts index 86ad7fdd9c59..d4dbf44fcaed 100644 --- a/src/libs/API/parameters/AddCommentOrAttachmentParams.ts +++ b/src/libs/API/parameters/AddCommentOrAttachmentParams.ts @@ -6,7 +6,9 @@ type AddCommentOrAttachmentParams = { commentReportActionID?: string | null; reportComment?: string; attachmentID?: string; + attachmentIDs?: string; file?: FileObject; + [additionalFileKey: `file_${number}`]: FileObject; timezone?: string; clientCreatedTime?: string; isOldDotConciergeChat?: boolean; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 7b71cbe01512..d7eece0d262c 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -373,6 +373,7 @@ type BuildOptimisticAddCommentReportActionParams = { reportID?: string; reportActionID?: string; attachmentID?: string; + additionalAttachments?: Array<{file: FileObject; attachmentID: string}>; isHTML?: boolean; delegateAccountIDParam: number | undefined; /** Anchor `created` to the server clock so it is comparable to Concierge's server-stamped replies. */ @@ -7533,42 +7534,60 @@ function getUploadingAttachmentHtml(file?: FileObject, attachmentID?: string): s // ExpensiMark allows one level of brackets inside a link label, so a file named `report [Q1].csv` is one of these. const MARKDOWN_LABEL_PATTERN = '[^\\[\\]\\r\\n]*(?:\\[[^\\[\\]\\r\\n]*\\][^\\[\\]\\r\\n]*)*'; -/** - * Saving a draft captured mid-upload would persist a local URI only the author's device can resolve, so swap it - * for the synced attachment, or drop it while the upload is pending because the queued Add re-attaches the file. - */ -function replaceLocalAttachmentReferences(draftMarkdown: string, currentCommentHtml: string | undefined, reportActionID: string): string { +/** Swaps each local attachment URI in a draft for its synced attachment, or drops them while the upload is pending. */ +function replaceLocalAttachmentReferences( + draftMarkdown: string, + currentCommentHtml: string | undefined, + reportActionID: string, + attachmentIDByLocalSource: Partial> = {}, +): string { const localAttachmentReferenceRegex = new RegExp(`\\n*(?:!?\\[${MARKDOWN_LABEL_PATTERN}\\]\\((?:blob:|file:|content:)[^)]*\\)|!\\((?:blob:|file:|content:)[^)]*\\))`, 'g'); - if (!localAttachmentReferenceRegex.test(draftMarkdown)) { + const draftLabelRegex = new RegExp(`^\\n*!?\\[(${MARKDOWN_LABEL_PATTERN})\\]`); + const localReferences = draftMarkdown.match(localAttachmentReferenceRegex) ?? []; + if (localReferences.length === 0) { return draftMarkdown; } - localAttachmentReferenceRegex.lastIndex = 0; const isStillUploading = !currentCommentHtml || currentCommentHtml.includes(CONST.ATTACHMENT_OPTIMISTIC_SOURCE_ATTRIBUTE); if (isStillUploading) { - return draftMarkdown.replace(localAttachmentReferenceRegex, '').trim(); + return draftMarkdown.replaceAll(localAttachmentReferenceRegex, '').trim(); } const attachmentTags = currentCommentHtml.match(/]*>|]*>[\s\S]*?<\/video>|]*data-expensify-source="[^"]*"[^>]*>[\s\S]*?<\/a>/gi) ?? []; - const syncedAttachmentTag = attachmentTags.find((tag) => { + const syncedAttachmentTags = attachmentTags.filter((tag) => { const source = tag.match(CONST.REGEX.ATTACHMENT.ATTACHMENT_SOURCE)?.at(2) ?? ''; return source.match(CONST.REGEX.ATTACHMENT.ATTACHMENT_SOURCE_ID)?.at(1) === reportActionID; }); - if (!syncedAttachmentTag) { + if (syncedAttachmentTags.length === 0) { return draftMarkdown; } - const syncedAttachmentMarkdown = Parser.htmlToMarkdown(syncedAttachmentTag); - let isReplaced = false; - localAttachmentReferenceRegex.lastIndex = 0; - return draftMarkdown.replace(localAttachmentReferenceRegex, (match) => { - if (isReplaced) { + const fileNameAttributeRegex = new RegExp(`${CONST.ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE}="([^"]*)"`); + const getSyncedFileName = (tag: string) => tag.match(fileNameAttributeRegex)?.at(1) ?? tag.match(/\balt="([^"]*)"/)?.at(1) ?? tag.match(/>([^<]*)<\/(?:a|video)>$/)?.at(1); + + // Matching by position is only safe when the draft kept every attachment. + const canMatchByPosition = localReferences.length === syncedAttachmentTags.length; + let localReferenceIndex = 0; + const matchedTags = new Set(); + const findUnmatchedTag = (isMatch: (tag: string) => boolean) => syncedAttachmentTags.find((tag) => !matchedTags.has(tag) && isMatch(tag)); + return draftMarkdown.replaceAll(localAttachmentReferenceRegex, (match) => { + const localSource = match.match(/\(([^)]*)\)$/)?.at(1) ?? ''; + const attachmentID = attachmentIDByLocalSource[localSource]; + const draftLabel = match.match(draftLabelRegex)?.at(1); + const localFileName = draftLabel ?? localSource.split('/').pop(); + const positionTag = canMatchByPosition ? syncedAttachmentTags.at(localReferenceIndex) : undefined; + const syncedAttachmentTag = + (attachmentID ? findUnmatchedTag((tag) => tag.match(CONST.REGEX.ATTACHMENT.ATTACHMENT_ID)?.at(2) === attachmentID) : undefined) ?? + (positionTag && !matchedTags.has(positionTag) ? positionTag : undefined) ?? + (localFileName ? findUnmatchedTag((tag) => getSyncedFileName(tag) === localFileName) : undefined); + localReferenceIndex++; + if (!syncedAttachmentTag) { return ''; } - isReplaced = true; + matchedTags.add(syncedAttachmentTag); // The synced tag carries the name the file was uploaded under, so a rename made during the upload loses to it. - const draftLabel = match.match(new RegExp(`^\\n*!?\\[(${MARKDOWN_LABEL_PATTERN})\\]`))?.at(1); + const syncedAttachmentMarkdown = Parser.htmlToMarkdown(syncedAttachmentTag); const labelledAttachmentMarkdown = draftLabel ? syncedAttachmentMarkdown.replace(/^(!?)\[[^\]]*\]/, (_reference, imagePrefix: string) => `${imagePrefix}[${draftLabel}]`) : syncedAttachmentMarkdown; @@ -7584,18 +7603,42 @@ function buildEditedCommentWithAttachment(commentHtml: string, uploadingAttachme return [commentHtml, uploadingAttachmentHtml].filter(Boolean).join('

'); } -/** The still-uploading attachment tag, re-appended to an edit's optimistic message so it stays on screen. */ -function getUploadingAttachmentHtmlFromComment(currentCommentHtml: string | undefined): string | undefined { +function getUploadingAttachmentTags(currentCommentHtml: string | undefined): string[] { if (!currentCommentHtml?.includes(CONST.ATTACHMENT_OPTIMISTIC_SOURCE_ATTRIBUTE)) { - return undefined; + return []; } const attachmentTagRegex = new RegExp( `]*${CONST.ATTACHMENT_OPTIMISTIC_SOURCE_ATTRIBUTE}="[^"]*"[^>]*>` + `|]*${CONST.ATTACHMENT_OPTIMISTIC_SOURCE_ATTRIBUTE}="[^"]*"[^>]*>[\\s\\S]*?` + `|]*${CONST.ATTACHMENT_OPTIMISTIC_SOURCE_ATTRIBUTE}="[^"]*"[^>]*>[\\s\\S]*?`, - 'i', + 'gi', ); - return currentCommentHtml.match(attachmentTagRegex)?.at(0); + return currentCommentHtml.match(attachmentTagRegex) ?? []; +} + +/** The still-uploading attachment tags, re-appended to an edit's optimistic message so they stay on screen. */ +function getUploadingAttachmentHtmlFromComment( + currentCommentHtml: string | undefined, + removedAttachmentIDs: string[] = [], + labelByAttachmentID: Partial> = {}, +): string | undefined { + const keptTags = getUploadingAttachmentTags(currentCommentHtml) + .map((tag) => ({tag, attachmentID: tag.match(CONST.REGEX.ATTACHMENT.ATTACHMENT_ID)?.at(2) ?? ''})) + .filter(({attachmentID}) => !removedAttachmentIDs.includes(attachmentID)) + .map(({tag, attachmentID}) => applyLabelToUploadingAttachmentHtml(tag, labelByAttachmentID[attachmentID])); + return keptTags.length > 0 ? keptTags.join('
') : undefined; +} + +/** + * The attachment IDs of still-uploading attachments a draft dropped. Compared against the local URI, not the parsed HTML, + * because a kept reference stays plain markdown and never parses back into an attachment tag. + */ +function getUploadingAttachmentIDsRemovedFromDraft(draftMarkdown: string, currentCommentHtml: string | undefined): string[] { + const optimisticSourceRegex = new RegExp(`${CONST.ATTACHMENT_OPTIMISTIC_SOURCE_ATTRIBUTE}="([^"]+)"`); + return getUploadingAttachmentTags(currentCommentHtml) + .filter((tag) => !draftMarkdown.includes(tag.match(optimisticSourceRegex)?.at(1) ?? '')) + .map((tag) => tag.match(CONST.REGEX.ATTACHMENT.ATTACHMENT_ID)?.at(2) ?? '') + .filter((attachmentID) => !!attachmentID); } /** @@ -7639,12 +7682,23 @@ function getUploadingAttachmentSource(currentCommentHtml: string | undefined): s } /** - * Whether a draft dropped a still-uploading attachment. Compared against the local URI, not the parsed HTML, - * because a kept reference stays plain markdown and never parses back into an attachment tag. + * The new labels a draft gives still-uploading attachments, keyed by attachment ID. The server names the stored attachment + * after the uploaded file, so a rename has to travel with the queued file as well as the optimistic markup. */ -function isUploadingAttachmentRemovedFromDraft(draftMarkdown: string, currentCommentHtml: string | undefined): boolean { - const localSource = getUploadingAttachmentSource(currentCommentHtml); - return !!localSource && !draftMarkdown.includes(localSource); +function getUploadingAttachmentLabelsRenamedInDraft(draftMarkdown: string, currentCommentHtml: string | undefined): Record { + const currentLabelRegex = new RegExp(`${CONST.ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE}="([^"]*)"`, 'i'); + const labelByAttachmentID: Record = {}; + for (const tag of getUploadingAttachmentTags(currentCommentHtml)) { + const attachmentID = tag.match(CONST.REGEX.ATTACHMENT.ATTACHMENT_ID)?.at(2); + const localSource = getUploadingAttachmentSource(tag); + const draftLabel = localSource ? getUploadingAttachmentLabelFromDraft(draftMarkdown, localSource) : undefined; + + // `data-name` carries the encoded label the comment shows now, while the draft carries what the user typed. + if (attachmentID && draftLabel && draftLabel !== Str.htmlDecode(tag.match(currentLabelRegex)?.at(1) ?? '')) { + labelByAttachmentID[attachmentID] = draftLabel; + } + } + return labelByAttachmentID; } function hasAttachmentAnchorAttributes(html: string): boolean { @@ -7749,6 +7803,7 @@ function buildOptimisticAddCommentReportAction({ reportID, reportActionID = rand64(), attachmentID, + additionalAttachments = [], currentUserEmail, currentUserAccountID, isHTML = false, @@ -7757,7 +7812,12 @@ function buildOptimisticAddCommentReportAction({ lastActionCreated, }: BuildOptimisticAddCommentReportActionParams): OptimisticReportAction { const commentText = isHTML ? (text ?? '') : getParsedComment(text ?? '', {reportID}); - const attachmentHtml = getUploadingAttachmentHtml(file, attachmentID); + const attachmentHtml = [ + getUploadingAttachmentHtml(file, attachmentID), + ...additionalAttachments.map((attachment) => getUploadingAttachmentHtml(attachment.file, attachment.attachmentID)), + ] + .filter((html) => !!html) + .join('
'); const htmlForNewComment = `${commentText}${commentText && attachmentHtml ? '

' : ''}${attachmentHtml}`; const textForNewComment = Parser.htmlToText(htmlForNewComment); @@ -15821,13 +15881,13 @@ export { canMergeReports, canModifyHoldStatus, replaceLocalAttachmentReferences, - isUploadingAttachmentRemovedFromDraft, + getUploadingAttachmentIDsRemovedFromDraft, + getUploadingAttachmentLabelsRenamedInDraft, restoreAttachmentAnchorAttributes, restoreAttachmentAnchorLabels, getUploadingAttachmentHtmlFromComment, buildEditedCommentWithAttachment, getUploadingAttachmentLabelFromDraft, - getUploadingAttachmentSource, applyLabelToUploadingAttachmentHtml, parseMovedTransactionReportIDs, }; diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index c4cf37ba6a37..9c13b4fd1b59 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -115,7 +115,6 @@ import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import {updateTitleFieldToMatchPolicy} from '@libs/ReportTitleUtils'; import type {Ancestor, OptimisticAddCommentReportAction, OptimisticChatReport, SelfDMParameters} from '@libs/ReportUtils'; import { - applyLabelToUploadingAttachmentHtml, buildEditedCommentWithAttachment, buildOptimisticAddCommentReportAction, buildOptimisticChangeFieldAction, @@ -161,8 +160,6 @@ import { getReportPreviewReportActionMessage, getReportTransactions, getUploadingAttachmentHtmlFromComment, - getUploadingAttachmentLabelFromDraft, - getUploadingAttachmentSource, hasOutstandingChildRequest, isAdminRoom, isChatThread as isChatThreadReportUtils, @@ -180,7 +177,8 @@ import { isReportManuallyReimbursed, isReportNotFound, isSelfDM, - isUploadingAttachmentRemovedFromDraft, + getUploadingAttachmentIDsRemovedFromDraft, + getUploadingAttachmentLabelsRenamedInDraft, isValidReportIDFromPath, prepareOnboardingOnyxData, replaceLocalAttachmentReferences, @@ -442,6 +440,7 @@ type AddActionsParams = { currentUserAccountID: number; text?: string; file?: FileObject; + additionalFiles?: FileObject[]; isInSidePanel?: boolean; sidePanelContext?: SidePanelContext; pregeneratedResponseParams?: PregeneratedResponseParams; @@ -616,6 +615,9 @@ Onyx.connectWithoutView({ callback: (value) => (allAttachments = value), }); +// Lets an edit saved after the upload finishes find which synced attachment each local URI in the draft became. +const attachmentIDByLocalSource: Partial> = {}; + let environment: EnvironmentType; getEnvironment().then((env) => { environment = env; @@ -891,6 +893,7 @@ function addActions({ currentUserAccountID, text = '', file, + additionalFiles = [], isInSidePanel = false, sidePanelContext, pregeneratedResponseParams, @@ -926,6 +929,7 @@ function addActions({ let commandName: typeof WRITE_COMMANDS.ADD_COMMENT | typeof WRITE_COMMANDS.ADD_ATTACHMENT | typeof WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT = WRITE_COMMANDS.ADD_COMMENT; const attachmentID = rand64(); + const additionalAttachments = additionalFiles.map((additionalFile) => ({file: additionalFile, attachmentID: rand64()})); if (text && !file) { const reportComment = buildOptimisticAddCommentReportAction({ text, @@ -948,12 +952,18 @@ function addActions({ file, reportID, attachmentID, + additionalAttachments, delegateAccountIDParam: delegateAccountID, anchorCreatedToServer: isConciergeChat, lastActionCreated, }); attachmentAction = attachment.reportAction; - cacheAttachment({attachmentID, uri: file.uri ?? '', mimeType: file.type}); + for (const sentAttachment of [{file, attachmentID}, ...additionalAttachments]) { + cacheAttachment({attachmentID: sentAttachment.attachmentID, uri: sentAttachment.file.uri ?? '', mimeType: sentAttachment.file.type}); + if (sentAttachment.file.uri) { + attachmentIDByLocalSource[sentAttachment.file.uri] = sentAttachment.attachmentID; + } + } } if (text && file) { @@ -1056,6 +1066,13 @@ function addActions({ parameters.attachmentID = attachmentID; } + if (file && additionalAttachments.length > 0) { + for (const [index, additionalAttachment] of additionalAttachments.entries()) { + parameters[`file_${index + 1}`] = additionalAttachment.file; + } + parameters.attachmentIDs = [attachmentID, ...additionalAttachments.map((additionalAttachment) => additionalAttachment.attachmentID)].join(','); + } + if (isInSidePanel && (isConciergeChat || isAdminRoom(reportForAction))) { const pageHTML = capturePageHTML(); if (pageHTML) { @@ -1332,8 +1349,8 @@ function addAttachmentWithComment({ playSound(SOUNDS.DONE); }; - // Single attachment - if (!Array.isArray(attachments)) { + if (!Array.isArray(attachments) || conciergeThreadReportID) { + const [file, ...additionalFiles] = Array.isArray(attachments) ? attachments : [attachments]; addActions({ report, notifyReportID, @@ -1341,7 +1358,8 @@ function addAttachmentWithComment({ timezoneParam: timezone, currentUserAccountID, text, - file: attachments, + file, + additionalFiles, isInSidePanel, delegateAccountID, sidePanelContext, @@ -1365,10 +1383,6 @@ function addAttachmentWithComment({ isInSidePanel, delegateAccountID, conciergeReportID, - - // Several attachments post one message each, so they stay in the DM instead of opening a thread. - conciergeThreadReportID: attachments.length === 1 ? conciergeThreadReportID : undefined, - shouldNavigateToConciergeThread, }); // Remaining: attachment-only actions (no text duplication) @@ -3835,8 +3849,8 @@ function editReportComment( // https://github.com/Expensify/App/issues/13221 const originalCommentHTML = ReportActionsUtils.getReportActionHtml(originalReportAction); const originalCommentMarkdown = Parser.htmlToMarkdown(originalCommentHTML ?? '', {reportIDToName}).trim(); - const shouldRemoveQueuedAttachment = isUploadingAttachmentRemovedFromDraft(textForNewComment, originalCommentHTML); - const draftForNewComment = replaceLocalAttachmentReferences(textForNewComment, originalCommentHTML, originalReportAction.reportActionID); + const removedAttachmentIDs = getUploadingAttachmentIDsRemovedFromDraft(textForNewComment, originalCommentHTML); + const draftForNewComment = replaceLocalAttachmentReferences(textForNewComment, originalCommentHTML, originalReportAction.reportActionID, attachmentIDByLocalSource); // Skip the Edit if draft is not changed if (originalCommentMarkdown === draftForNewComment) { @@ -3871,16 +3885,8 @@ function editReportComment( const originalMessage = ReportActionsUtils.getReportActionMessage(originalReportAction); // Optimistic message only: the sent copy is stripped, so without this the attachment vanishes until upload lands. - const originalUploadingAttachmentHtml = shouldRemoveQueuedAttachment ? undefined : getUploadingAttachmentHtmlFromComment(originalCommentHTML); - const uploadingAttachmentSource = getUploadingAttachmentSource(originalUploadingAttachmentHtml); - const draftAttachmentLabel = uploadingAttachmentSource ? getUploadingAttachmentLabelFromDraft(textForNewComment, uploadingAttachmentSource) : undefined; - - // The server rebuilds the stored attachment from the uploaded file, so a rename has to travel with the queued - // file as well as the optimistic markup, otherwise it reverts as soon as the send goes through. - // `data-name` carries the encoded label the comment shows now, while the draft carries what the user typed. - const currentAttachmentLabel = originalUploadingAttachmentHtml?.match(new RegExp(`${CONST.ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE}="([^"]*)"`, 'i'))?.at(1); - const renamedAttachmentLabel = draftAttachmentLabel && draftAttachmentLabel !== Str.htmlDecode(currentAttachmentLabel ?? '') ? draftAttachmentLabel : undefined; - const uploadingAttachmentHtml = originalUploadingAttachmentHtml ? applyLabelToUploadingAttachmentHtml(originalUploadingAttachmentHtml, renamedAttachmentLabel) : undefined; + const renamedAttachmentLabels = getUploadingAttachmentLabelsRenamedInDraft(textForNewComment, originalCommentHTML); + const uploadingAttachmentHtml = getUploadingAttachmentHtmlFromComment(originalCommentHTML, removedAttachmentIDs, renamedAttachmentLabels); const optimisticHtml = buildEditedCommentWithAttachment(htmlForNewComment, uploadingAttachmentHtml); const optimisticText = uploadingAttachmentHtml ? Parser.htmlToText(optimisticHtml) : reportComment; @@ -4003,7 +4009,7 @@ function editReportComment( checkAndFixConflictingRequest: (persistedRequests) => { const addCommentIndex = persistedRequests.findIndex((request) => addNewMessageWithText.has(request.command) && request.data?.reportActionID === reportActionID); if (addCommentIndex > -1) { - return resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, addCommentIndex, shouldRemoveQueuedAttachment, renamedAttachmentLabel); + return resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, addCommentIndex, removedAttachmentIDs, renamedAttachmentLabels); } return resolveDuplicationConflictAction(persistedRequests as AnyRequest[], createUpdateCommentMatcher(reportActionID)); }, diff --git a/src/libs/actions/RequestConflictUtils.ts b/src/libs/actions/RequestConflictUtils.ts index 4bc279ee0bd4..358d5d9847eb 100644 --- a/src/libs/actions/RequestConflictUtils.ts +++ b/src/libs/actions/RequestConflictUtils.ts @@ -3,6 +3,7 @@ import {WRITE_COMMANDS} from '@libs/API/types'; import type {ApiRequestCommandParameters} from '@libs/API/types'; import {splitExtensionFromFileName} from '@libs/fileDownload/FileUtils'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type OnyxRequest from '@src/types/onyx/Request'; import type {AnyRequest, ConflictActionData} from '@src/types/onyx/Request'; @@ -309,8 +310,8 @@ function resolveEditCommentWithNewAddCommentRequest( parameters: UpdateCommentParams, reportActionID: string, addCommentIndex: number, - shouldRemoveQueuedAttachment = false, - renamedAttachmentLabel?: string, + removedAttachmentIDs: string[] = [], + renamedAttachmentLabels: Partial> = {}, ): ConflictActionData { const indicesToDelete: number[] = []; for (const [index, request] of persistedRequests.entries()) { @@ -325,13 +326,30 @@ function resolveEditCommentWithNewAddCommentRequest( if (currentAddComment) { currentAddComment.data = {...currentAddComment.data, ...parameters}; - // The queued request keeps its own file, so without dropping it the server would re-add an attachment the edit removed. - if (shouldRemoveQueuedAttachment) { - delete currentAddComment.data.file; - delete currentAddComment.data.attachmentID; - currentAddComment.command = WRITE_COMMANDS.ADD_COMMENT; - } else if (renamedAttachmentLabel && currentAddComment.data?.file) { - currentAddComment.data.file = renameQueuedAttachment(currentAddComment.data.file, renamedAttachmentLabel); + // The queued request keeps its own files, so without dropping them the server would re-add attachments the edit removed. + if (removedAttachmentIDs.length > 0 || Object.keys(renamedAttachmentLabels).length > 0) { + const data = currentAddComment.data; + const fileKeys = ['file', ...Object.keys(data).filter((key) => CONST.ADDITIONAL_ATTACHMENT_FILE_KEY_REGEX.test(key))]; + const attachmentIDs = typeof data.attachmentIDs === 'string' ? data.attachmentIDs.split(',') : [data.attachmentID]; + const keptAttachments = fileKeys + .map((key, index) => ({file: data[key], attachmentID: attachmentIDs.at(index)})) + .filter(({file, attachmentID}) => !!file && !(typeof attachmentID === 'string' && removedAttachmentIDs.includes(attachmentID))) + .map(({file, attachmentID}) => { + const renamedLabel = typeof attachmentID === 'string' ? renamedAttachmentLabels[attachmentID] : undefined; + return {file: renamedLabel ? renameQueuedAttachment(file, renamedLabel) : file, attachmentID}; + }); + + for (const key of fileKeys) { + delete data[key]; + } + for (const [index, {file}] of keptAttachments.entries()) { + data[index === 0 ? 'file' : `file_${index}`] = file; + } + data.attachmentID = keptAttachments.at(0)?.attachmentID; + data.attachmentIDs = keptAttachments.length > 1 ? keptAttachments.map(({attachmentID}) => attachmentID).join(',') : undefined; + if (keptAttachments.length === 0) { + currentAddComment.command = WRITE_COMMANDS.ADD_COMMENT; + } } nextAction = { diff --git a/src/libs/prepareRequestPayload/index.native.ts b/src/libs/prepareRequestPayload/index.native.ts index 289e4b84fc3a..9fd6a0cee0e3 100644 --- a/src/libs/prepareRequestPayload/index.native.ts +++ b/src/libs/prepareRequestPayload/index.native.ts @@ -5,6 +5,7 @@ import ReceiptStorage from '@libs/ReceiptStorage'; import {logReceiptDropped} from '@libs/telemetry/ReceiptObservability'; import validateFormDataParameter from '@libs/validateFormDataParameter'; +import CONST from '@src/CONST'; import type {Receipt} from '@src/types/onyx/Transaction'; import RNFS from 'react-native-fs'; @@ -84,7 +85,7 @@ const prepareRequestPayload: PrepareRequestPayload = (command, data, initiatedOf } } - if (key === 'file' && initiatedOffline) { + if ((key === 'file' || CONST.ADDITIONAL_ATTACHMENT_FILE_KEY_REGEX.test(key)) && initiatedOffline) { const {uri: path = '', source, name, type} = value as File; if (!source) { validateFormDataParameter(command, key, value); diff --git a/src/pages/inbox/ConciergeChatHistoryContext.tsx b/src/pages/inbox/ConciergeChatHistoryContext.tsx new file mode 100644 index 000000000000..a45820dbb846 --- /dev/null +++ b/src/pages/inbox/ConciergeChatHistoryContext.tsx @@ -0,0 +1,79 @@ +import useOnyx from '@hooks/useOnyx'; + +import {readNewestAction} from '@userActions/Report'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import type {PropsWithChildren} from 'react'; + +import {accountIDSelector} from '@selectors/Session'; +import React, {createContext, useContext, useState} from 'react'; + +type ConciergeChatHistoryStateContextType = { + /** Whether the main Concierge chat shows its earlier conversation instead of the welcome screen */ + showFullHistory: boolean; + + /** Whether the user collapsed the earlier conversation */ + isHistoryCollapsedByUser: boolean; +}; + +type ConciergeChatHistoryActionsContextType = { + setShowFullHistory: (show: boolean) => void; + + /** Hides the earlier conversation and marks the Concierge chat as read */ + collapseHistory: () => void; +}; + +const ConciergeChatHistoryStateContext = createContext({ + showFullHistory: false, + isHistoryCollapsedByUser: false, +}); + +const ConciergeChatHistoryActionsContext = createContext({ + setShowFullHistory: () => {}, + collapseHistory: () => {}, +}); + +function ConciergeChatHistoryProvider({children}: PropsWithChildren) { + const [showFullHistory, setShowFullHistoryState] = useState(false); + const [isHistoryCollapsedByUser, setIsHistoryCollapsedByUser] = useState(false); + + const setShowFullHistory = (show: boolean) => { + setShowFullHistoryState(show); + setIsHistoryCollapsedByUser(!show); + }; + + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const collapseHistory = () => { + setShowFullHistory(false); + readNewestAction(conciergeReportID, true); + }; + + // Collapse the history when the user switches accounts. + const [accountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); + const [prevAccountID, setPrevAccountID] = useState(accountID); + if (prevAccountID !== accountID) { + setPrevAccountID(accountID); + setShowFullHistoryState(false); + setIsHistoryCollapsedByUser(false); + } + + const stateValue = {showFullHistory, isHistoryCollapsedByUser}; + const actionsValue = {setShowFullHistory, collapseHistory}; + + return ( + + {children} + + ); +} + +function useConciergeChatHistoryState(): ConciergeChatHistoryStateContextType { + return useContext(ConciergeChatHistoryStateContext); +} + +function useConciergeChatHistoryActions(): ConciergeChatHistoryActionsContextType { + return useContext(ConciergeChatHistoryActionsContext); +} + +export {ConciergeChatHistoryProvider, useConciergeChatHistoryActions, useConciergeChatHistoryState}; diff --git a/src/pages/inbox/ConciergeSessionContext.tsx b/src/pages/inbox/ConciergeSessionContext.tsx deleted file mode 100644 index 921ab8d3adcf..000000000000 --- a/src/pages/inbox/ConciergeSessionContext.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import useOnyx from '@hooks/useOnyx'; - -import {getServerAnchoredDBTime} from '@libs/NetworkState'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; - -import type {PropsWithChildren} from 'react'; - -import React, {createContext, useContext, useRef, useState} from 'react'; - -type ConciergeSessionStateContextType = { - sessionStartTime: string | null; - showFullHistory: boolean; - hadMessagesAtSessionStart: boolean; -}; - -type ConciergeSessionActionsContextType = { - startSession: (unreadBoundary?: string | null) => void; - setShowFullHistory: (show: boolean) => void; - setHadMessagesAtSessionStart: (value: boolean) => void; -}; - -const ConciergeSessionStateContext = createContext({ - sessionStartTime: null, - showFullHistory: false, - hadMessagesAtSessionStart: false, -}); - -const ConciergeSessionActionsContext = createContext({ - startSession: () => {}, - setShowFullHistory: () => {}, - setHadMessagesAtSessionStart: () => {}, -}); - -const accountIDSelector = (session: {accountID?: number} | undefined) => session?.accountID; - -/** - * Provides a stable sessionStartTime for the main Concierge DM. - * Mounted at app level (App.tsx) so it never remounts. - * - * Session start is triggered eagerly by consumers (via startSession()) - * using useLayoutEffect, so the timestamp is set before the browser paints. - * - * Session reset uses a staleness check: if the session was created more - * than CONCIERGE_SESSION_EXPIRATION_MS (2 hours) ago, it is discarded and - * a new session begins. Brief detours (settings, workspace links, app - * going to background) preserve the session; closing the app or switching - * accounts resets it naturally. - */ -function ConciergeSessionProvider({children}: PropsWithChildren) { - const [sessionStartTime, setSessionStartTime] = useState(null); - const [showFullHistory, setShowFullHistory] = useState(false); - const [hadMessagesAtSessionStart, setHadMessagesAtSessionStart] = useState(false); - - // Tracks when the session was actually created (Date.now()), separate - // from sessionStartTime which may be an older unread boundary used for - // display filtering. The age check uses this value so that an old - // lastReadTime boundary doesn't cause premature expiration. - const sessionCreatedAtRef = useRef(null); - - // Reset the session when the user switches accounts. The provider is - // mounted at the app level and never remounts, so without this the - // previous user's session state would leak into the new account. - const [accountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); - const [prevAccountID, setPrevAccountID] = useState(accountID); - if (prevAccountID !== accountID) { - setPrevAccountID(accountID); - if (sessionStartTime !== null) { - setSessionStartTime(null); - setShowFullHistory(false); - setHadMessagesAtSessionStart(false); - } - } - - const startSession = (unreadBoundary?: string | null) => { - let sessionExpired = false; - setSessionStartTime((prev) => { - if (prev && sessionCreatedAtRef.current) { - const elapsed = Date.now() - sessionCreatedAtRef.current; - if (elapsed < CONST.CONCIERGE_SESSION_EXPIRATION_MS) { - // Within an active session, keep the existing boundary unless a better - // (earlier) unread boundary resolves after the session was created. On a - // cold open the session can lock to `now` before the unread anchor resolves; - // when it arrives we pull sessionStartTime back so the notification message - // isn't hidden behind "Show full history". The session age (sessionCreatedAtRef) - // is unchanged — only the display boundary is refined. - if (unreadBoundary && unreadBoundary < prev) { - return unreadBoundary; - } - return prev; - } - sessionExpired = true; - } - sessionCreatedAtRef.current = Date.now(); - const now = getServerAnchoredDBTime(); - if (unreadBoundary && unreadBoundary < now) { - return unreadBoundary; - } - return now; - }); - if (sessionExpired) { - setShowFullHistory(false); - setHadMessagesAtSessionStart(false); - } - }; - - const stateValue = {sessionStartTime, showFullHistory, hadMessagesAtSessionStart}; - const actionsValue = {startSession, setShowFullHistory, setHadMessagesAtSessionStart}; - - return ( - - {children} - - ); -} - -function useConciergeSessionState(): ConciergeSessionStateContextType { - return useContext(ConciergeSessionStateContext); -} - -function useConciergeSessionActions(): ConciergeSessionActionsContextType { - return useContext(ConciergeSessionActionsContext); -} - -export {ConciergeSessionProvider, useConciergeSessionActions, useConciergeSessionState}; diff --git a/src/pages/inbox/HeaderView.tsx b/src/pages/inbox/HeaderView.tsx index aba31bda77b9..664544eaa7a3 100644 --- a/src/pages/inbox/HeaderView.tsx +++ b/src/pages/inbox/HeaderView.tsx @@ -39,6 +39,8 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import getReportRouteForCurrentContext from '@libs/Navigation/helpers/getReportRouteForCurrentContext'; +import Navigation from '@libs/Navigation/Navigation'; import Parser from '@libs/Parser'; import {getPersonalDetailsForAccountIDs} from '@libs/PersonalDetailsUtils'; import {getHumanAgentAccountIDFromReportAction, getHumanAgentFirstName, isTransactionThread} from '@libs/ReportActionsUtils'; @@ -103,6 +105,8 @@ import {latestCalendlyCallSelector} from '@selectors/ReportNameValuePairs'; import React from 'react'; import {Keyboard, View} from 'react-native'; +import {useConciergeChatHistoryActions} from './ConciergeChatHistoryContext'; + type HeaderViewProps = { /** Toggles the navigationMenu open and closed */ onNavigationMenuButtonClicked: () => void; @@ -114,7 +118,7 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {selector: getStableReportSelector}); const parentReportAction = useParentReportAction(report, {shouldIgnoreThreadReplies: true}); - const icons = useMemoizedLazyExpensifyIcons(['BackArrow', 'Close', 'DotIndicator']); + const icons = useMemoizedLazyExpensifyIcons(['BackArrow', 'Close', 'DotIndicator', 'Plus']); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth, shouldUseNarrowLayout, isInLandscapeMode} = useResponsiveLayout(); const isInSidePanel = useIsInSidePanel(); @@ -157,6 +161,8 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) const isSelfDM = isSelfDMReportUtils(report); const isGroupChat = isGroupChatReportUtils(report) || isDeprecatedGroupDM(report, isReportArchived); const isConciergeChat = isConciergeChatReport(report, conciergeReportID); + const {collapseHistory: collapseConciergeHistory} = useConciergeChatHistoryActions(); + const isConciergeThread = !!conciergeReportID && report?.parentReportID === conciergeReportID; const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [onboarding] = useOnyx(ONYXKEYS.NVP_ONBOARDING); const allParticipants = getParticipantsAccountIDsForDisplay(report, reportMetadata, false, true); @@ -186,7 +192,8 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); // Use sorted display names for the title for group chats on native small screen widths const title = getReportName(reportHeaderData, derivedReportHeaderName); - const subtitle = getChatRoomSubtitle(reportHeaderData, reportHeaderDataPolicy, conciergeReportID, translate, rules, false, isReportHeaderDataArchived); + const chatRoomSubtitle = getChatRoomSubtitle(reportHeaderData, reportHeaderDataPolicy, conciergeReportID, translate, rules, false, isReportHeaderDataArchived); + const subtitle = !isInSidePanel && isConciergeChat ? translate('common.concierge.subtitle') : chatRoomSubtitle; // This is used to get the status badge for invoice report subtitle. const statusTextForInvoiceReport = isParentInvoiceAndIsTransactionThread ? getReportStatusTranslation({stateNum: reportHeaderData?.stateNum, statusNum: reportHeaderData?.statusNum, translate}) @@ -314,6 +321,21 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) ); + const startNewConciergeChat = () => { + collapseConciergeHistory(); + Navigation.navigate(getReportRouteForCurrentContext({reportID: conciergeReportID})); + }; + + const newConciergeChatButton = ( + + ); + const renderAdditionalText = () => { if (shouldShowSubtitle() || isPersonalExpenseChat || !policyName || !isEmptyObject(parentNavigationSubtitleData) || isSelfDM) { return null; @@ -494,6 +516,7 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) )} + {!isInSidePanel && isConciergeThread && !shouldUseNarrowLayout && newConciergeChatButton} {shouldShowBookCall && !shouldStackBookCall && bookCallButton} {shouldShowOnBoardingHelpDropdownButton && !shouldUseNarrowLayout && onboardingHelpDropdownButton} {!shouldUseNarrowLayout && !shouldShowDiscount && isChatUsedForOnboarding && ( @@ -529,6 +552,7 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) )} {!isParentReportLoading && !isLoading && canJoin && shouldUseNarrowLayout && {joinButton}} + {!isLoading && !isInSidePanel && isConciergeThread && shouldUseNarrowLayout && {newConciergeChatButton}} {shouldShowBookCall && shouldStackBookCall && {bookCallButton}} {!shouldShowEarlyDiscountBanner && shouldShowOnBoardingHelpDropdownButton && shouldUseNarrowLayout && ( diff --git a/src/pages/inbox/ReportActions.tsx b/src/pages/inbox/ReportActions.tsx index e864155f7bc3..71d15523c1d2 100644 --- a/src/pages/inbox/ReportActions.tsx +++ b/src/pages/inbox/ReportActions.tsx @@ -64,8 +64,7 @@ function ReportActions() { // branches below) — matching the previous behavior, where this skeleton lived inside the // chat-only ReportActionsView. // - // Concierge is excluded so the body still mounts under the app-load skeleton, seeding sessionStartTime - // before content appeared. + // Concierge is excluded so its welcome screen shows while the app loads. const isConciergeMainDM = isConciergeChatReport(report, conciergeReportID); const shouldShowAppLoadSkeleton = isAppLoadPending && !isOffline && !!report && !shouldWaitForTransactions && !shouldDisplayMoneyRequestActionsList && !isConciergeMainDM; diff --git a/src/pages/inbox/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx index 13553611777b..aa28ab7c6ac9 100644 --- a/src/pages/inbox/ReportScreen.tsx +++ b/src/pages/inbox/ReportScreen.tsx @@ -3,6 +3,7 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import WideRHPOverlayWrapper from '@components/WideRHPOverlayWrapper'; +import useConciergeAskState from '@hooks/useConciergeAskState'; import {useCurrentReportIDState} from '@hooks/useCurrentReportID'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -75,6 +76,7 @@ function ReportScreenEditMessageProvider({reportID, children}: ReportScreenEditM function ReportScreen({route, navigation, shouldDeferReportActions = false}: ReportScreenProps) { const styles = useThemeStyles(); const reportIDFromRoute = getNonEmptyStringOnyxID(route.params?.reportID); + const {shouldCenterWelcomeWithComposer} = useConciergeAskState(reportIDFromRoute); const {isInNarrowPaneModal} = useResponsiveLayout(); const {currentReportID: currentReportIDValue} = useCurrentReportIDState(); const viewportOffsetTop = useViewportOffsetTop(); @@ -146,7 +148,11 @@ function ReportScreen({route, navigation, shouldDeferReportActions = false}: Rep + + + {translate('reportActionsView.askMeAnything')} + + + {translate('common.concierge.welcomeDescription')} + + + ); +} + +export default AskConciergeEmptyState; diff --git a/src/pages/inbox/report/AskConciergeWelcome.tsx b/src/pages/inbox/report/AskConciergeWelcome.tsx new file mode 100644 index 000000000000..ef7d0d2fca3b --- /dev/null +++ b/src/pages/inbox/report/AskConciergeWelcome.tsx @@ -0,0 +1,46 @@ +import ScrollView from '@components/ScrollView'; + +import useOnyx from '@hooks/useOnyx'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import React from 'react'; + +import AskConciergeEmptyState from './AskConciergeEmptyState'; +import ConciergeChatHistoryToggle from './ConciergeChatHistoryToggle'; + +type AskConciergeWelcomeProps = { + /** The ID of the report being displayed */ + reportID: string; + + /** Called when the user presses `View chat history` */ + onShowPreviousMessages: () => void; +}; + +function AskConciergeWelcome({reportID, onShowPreviousMessages}: AskConciergeWelcomeProps) { + const styles = useThemeStyles(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + + return ( + markOpenReportEnd(reportID, report, {warm: true})} + > + + + + ); +} + +export default AskConciergeWelcome; diff --git a/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx b/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx new file mode 100644 index 000000000000..9953db9445fd --- /dev/null +++ b/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx @@ -0,0 +1,45 @@ +import Button from '@components/Button'; + +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; + +import type {StyleProp, ViewStyle} from 'react-native'; + +import React from 'react'; +import {View} from 'react-native'; + +type ConciergeChatHistoryToggleProps = { + /** Whether the earlier conversation is shown */ + isHistoryShown: boolean; + + /** Called when the button is pressed */ + onPress: () => void; + + /** Styles applied to the row wrapping the divider and the button */ + containerStyles?: StyleProp; +}; + +function ConciergeChatHistoryToggle({isHistoryShown, onPress, containerStyles}: ConciergeChatHistoryToggleProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['UpArrow', 'DownArrow']); + + return ( + + + + + + ); +} + +export default ConciergeChatHistoryToggle; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx index 88a07846f3fa..46a93c6bec81 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx @@ -1,5 +1,6 @@ import type {LocalizedTranslate} from '@components/LocaleContextProvider'; +import useIsAskConciergeChat from '@hooks/useIsAskConciergeChat'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useReportIsArchived from '@hooks/useReportIsArchived'; @@ -22,7 +23,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {MeasureInWindowOnSuccessCallback} from 'react-native'; -import React from 'react'; +import React, {useState} from 'react'; import {useComposerActions, useComposerEditState, useComposerMeta, useComposerSendState, useComposerState} from './ComposerContext'; import ComposerWithSuggestions from './ComposerWithSuggestions'; @@ -31,6 +32,24 @@ import useComposerSubmit from './useComposerSubmit'; const AI_PLACEHOLDER_KEYS = ['reportActionCompose.askConciergeToUpdate', 'reportActionCompose.askConciergeToCorrect', 'reportActionCompose.askConciergeForHelp'] as const; +const ASK_CONCIERGE_PLACEHOLDER_KEYS = [ + 'common.concierge.composerPlaceholders.spendLastMonth', + 'common.concierge.composerPlaceholders.topExpenses', + 'common.concierge.composerPlaceholders.spendDrivers', + 'common.concierge.composerPlaceholders.travelSpendChange', + 'common.concierge.composerPlaceholders.topSpender', + 'common.concierge.composerPlaceholders.missingCategory', + 'common.concierge.composerPlaceholders.awaitingApproval', + 'common.concierge.composerPlaceholders.notReimbursed', + 'common.concierge.composerPlaceholders.unsubmitted', + 'common.concierge.composerPlaceholders.leftToPay', + 'common.concierge.composerPlaceholders.createExpense', + 'common.concierge.composerPlaceholders.combineExpenses', + 'common.concierge.composerPlaceholders.exportPdf', + 'common.concierge.composerPlaceholders.exportCsv', + 'reportActionCompose.askConciergeForHelp', +] as const; + function getRandomPlaceholder(translate: LocalizedTranslate): string { const randomIndex = Math.floor(Math.random() * AI_PLACEHOLDER_KEYS.length); return translate(AI_PLACEHOLDER_KEYS[randomIndex]); @@ -66,6 +85,8 @@ function ComposerInput() { const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const isReportArchived = useReportIsArchived(report?.reportID); + const isAskConciergeChat = useIsAskConciergeChat(reportID); + const [askConciergePlaceholderKey] = useState(() => ASK_CONCIERGE_PLACEHOLDER_KEYS[Math.floor(Math.random() * ASK_CONCIERGE_PLACEHOLDER_KEYS.length)]); const includesConcierge = chatIncludesConcierge({participants: report?.participants}); const isGroupPolicyReport = !!report?.policyID && report.policyID !== CONST.POLICY.ID_FAKE; @@ -76,6 +97,8 @@ function ComposerInput() { let inputPlaceholder = translate('reportActionCompose.writeSomething'); if (includesConcierge && userBlockedFromConcierge) { inputPlaceholder = translate('reportActionCompose.blockedFromConcierge'); + } else if (isAskConciergeChat) { + inputPlaceholder = translate(askConciergePlaceholderKey); } else if (isExpenseRelatedReport && canUserPerformWriteAction && isEnglishLocale) { inputPlaceholder = getRandomPlaceholder(translate); } diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts index 85af5a536bf4..11525a0cb329 100644 --- a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts +++ b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts @@ -3,7 +3,6 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useIsInSidePanel from '@hooks/useIsInSidePanel'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useSidePanelActions from '@hooks/useSidePanelActions'; @@ -17,6 +16,7 @@ import getSendMessageSource from '@libs/telemetry/getSendMessageSource'; import {startSendMessageVisibleSpan} from '@libs/telemetry/sendMessageSpans'; import {useActionListContext} from '@pages/inbox/ActionListContext'; +import {useConciergeChatHistoryActions} from '@pages/inbox/ConciergeChatHistoryContext'; import {setIsComposerFullSize} from '@userActions/Report'; @@ -37,7 +37,6 @@ function useComposerSubmit(reportID: string) { const route = useRoute(); const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); - const {isBetaEnabled} = usePermissions(); const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); const delegateAccountID = useDelegateAccountID(); @@ -47,6 +46,7 @@ function useComposerSubmit(reportID: string) { const {isEditingInComposer, effectiveDraft, didResetComposerHeightWhileEditing, editingState} = useComposerEditState(); const {publishDraft, setDidResetComposerHeightWhileEditing} = useComposerEditActions(); const {scrollOffsetRef} = useActionListContext(); + const {setShowFullHistory} = useConciergeChatHistoryActions(); const {report, effectiveTransactionThreadReportID} = useComposerReportData(reportID); const [targetReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${effectiveTransactionThreadReportID ?? reportID}`); @@ -79,12 +79,11 @@ function useComposerSubmit(reportID: string) { clearAgentZeroProcessingIndicator(reportID, CONST.ACCOUNT_ID.CONCIERGE); } - const shouldRespondInThread = reportID === conciergeReportID && isBetaEnabled(CONST.BETAS.CONCIERGE_RESPOND_IN_THREAD); + const shouldRespondInThread = reportID === conciergeReportID; if (attachmentFileRef.current) { const attachments = attachmentFileRef.current; - const willOpenThread = shouldRespondInThread && (!Array.isArray(attachments) || attachments.length === 1); - const conciergeThreadReportID = willOpenThread ? generateReportID() : undefined; + const conciergeThreadReportID = shouldRespondInThread ? generateReportID() : undefined; addAttachmentWithComment({ report: targetReport, notifyReportID: reportID, @@ -109,6 +108,9 @@ function useComposerSubmit(reportID: string) { } if (createTaskFromMarkdown({text: draftMessageTrimmed, parentReport: report, currentUserPersonalDetails, quickAction, delegateAccountID, ancestors: reportAncestors})) { + if (shouldRespondInThread && !isInSidePanel) { + setShowFullHistory(true); + } return; } diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 43e3541fc350..10b62b114cba 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -4,7 +4,6 @@ import MerchantRuleSuggestionBanner from '@components/MerchantRuleSuggestionBann import {ReportActionsAnimatedSkeletonCover} from '@components/ReportActionsSkeletonCover'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; -import useConciergeSessionStartTime from '@hooks/useConciergeSessionStartTime'; import useEnvironment from '@hooks/useEnvironment'; import useLinkedMessageOfflineLoading from '@hooks/useLinkedMessageOfflineLoading'; import useLocalize from '@hooks/useLocalize'; @@ -17,6 +16,7 @@ import useReportScrollManager from '@hooks/useReportScrollManager'; import useReportsParentHierarchy from '@hooks/useReportsParentHierarchy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useRetireMerchantRuleSuggestionOnLeave from '@hooks/useRetireMerchantRuleSuggestionOnLeave'; +import useSidePanelState from '@hooks/useSidePanelState'; import useThemeStyles from '@hooks/useThemeStyles'; import useUnreadMarker from '@hooks/useUnreadMarker'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -24,6 +24,7 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import {isConsecutiveChronosAutomaticTimerAction} from '@libs/ChronosUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; +import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; import { @@ -55,6 +56,7 @@ import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; import type {ReportsSplitNavigatorParamList} from '@navigation/types'; import {useActionListContext, useActionListRef} from '@pages/inbox/ActionListContext'; +import {useConciergeChatHistoryActions} from '@pages/inbox/ConciergeChatHistoryContext'; import {useConciergeDraft, useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext'; import CONST from '@src/CONST'; @@ -74,6 +76,8 @@ import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import {accountIDSelector} from '@selectors/Session'; import React, {useEffect, useRef, useState} from 'react'; +import AskConciergeWelcome from './AskConciergeWelcome'; +import ConciergeChatHistoryToggle from './ConciergeChatHistoryToggle'; import FloatingMessageCounter from './FloatingMessageCounter'; import {ReportActionPositionContextProvider} from './ReportActionIndexContext'; import {useReportActionsListActions, useReportActionsListState} from './ReportActionsListContext'; @@ -137,6 +141,8 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct showFullHistory, hasPreviousMessages, allReportActionIDs, + shouldShowConciergeWelcome, + isConciergeHistoryExpanded, } = useReportActionsListState(); const {setTreatAsNoPaginationAnchor, loadOlderChats, loadNewerChats, handleShowPreviousMessages} = useReportActionsListActions(); @@ -144,7 +150,8 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const {isOffline} = useNetwork(); const route = useRoute>(); const reportActionIDFromRoute = route?.params?.reportActionID; - const sessionStartTime = useConciergeSessionStartTime(); + const {sessionStartTime} = useSidePanelState(); + const {collapseHistory: collapseConciergeHistory} = useConciergeChatHistoryActions(); const didLayout = useRef(false); @@ -501,10 +508,23 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct ]; const listHeaderComponent = ( - + <> + {isConciergeHistoryExpanded && ( + { + collapseConciergeHistory(); + if (reportActionIDFromRoute) { + Navigation.setParams({reportActionID: ''}, route.key); + } + }} + /> + )} + + ); const shouldShowOfflineSkeleton = isOffline && !sortedVisibleReportActions.some((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); @@ -539,6 +559,15 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct return ; } + if (shouldShowConciergeWelcome) { + return ( + + ); + } + return ( <> {/* Pinned over the top of the list rather than laid out inside it, so scrolling the expense detail view diff --git a/src/pages/inbox/report/ReportActionsListContext.tsx b/src/pages/inbox/report/ReportActionsListContext.tsx index 4910aa0793e7..c29fb76a9049 100644 --- a/src/pages/inbox/report/ReportActionsListContext.tsx +++ b/src/pages/inbox/report/ReportActionsListContext.tsx @@ -60,8 +60,6 @@ function computeReportActionsSkeletonState(readinessSignals: ReportActionsReadin isSingleExpenseReport, isMissingReportActions, isConciergeHiddenHistory, - isConciergeMainDM, - hasCachedReportActions, showConciergeSidePanelWelcome, derivedIsEmptyReport, } = readinessSignals; @@ -94,18 +92,11 @@ function computeReportActionsSkeletonState(readinessSignals: ReportActionsReadin const shouldShowSkeletonForAppLoad = !!isLoadingApp && !isOffline; - // Show skeleton for the Concierge chat (side panel or main DM) until report - // data has been loaded at least once. Before the first openReport response, - // hasOlderActions is unreliable, so we can't determine whether to show the - // greeting or onboarding messages. The skeleton avoids flashing wrong content. - // hasOnceLoadedReportActions is RAM-only and resets on a page refresh, but - // cached report actions persist in Onyx. For the main DM, render those cached - // actions immediately (matching production) instead of flashing a skeleton on - // every refresh; the side panel always opens fresh so it keeps gating on - // hasOnceLoadedReportActions only. hasCachedReportActions is false only on a - // genuinely cold load with no cached history (it excludes the synthetic CREATED - // action that is always injected for Concierge). - const shouldShowSkeletonForConciergePanel = isConciergeHiddenHistory && !hasOnceLoadedReportActions && !(isConciergeMainDM && hasCachedReportActions) && !isOffline; + // Show skeleton for the Concierge side panel until report data has been loaded + // at least once. Before the first openReport response, hasOlderActions is + // unreliable, so we can't determine whether to show the greeting or onboarding + // messages. The skeleton avoids flashing wrong content. + const shouldShowSkeletonForConciergePanel = isConciergeHiddenHistory && !hasOnceLoadedReportActions && !isOffline; // When opening a linked message online, wait for the first load before rendering the list: the batch of // actions that arrives right after the initial load shifts the list and breaks the anchor to the linked action. diff --git a/src/pages/inbox/report/ReportActionsListPaddingView.tsx b/src/pages/inbox/report/ReportActionsListPaddingView.tsx index 6c64d51cd2b1..c6b03b3624bb 100644 --- a/src/pages/inbox/report/ReportActionsListPaddingView.tsx +++ b/src/pages/inbox/report/ReportActionsListPaddingView.tsx @@ -1,3 +1,4 @@ +import useConciergeAskState from '@hooks/useConciergeAskState'; import useReportRecipientLocalTime from '@hooks/useReportRecipientLocalTime'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -23,11 +24,12 @@ function ReportActionsListPaddingView({report, isReportArchived, children}: Repo const reportActionsListFSClass = FS.getChatFSClass(report); const shouldShowComposerForActiveEditDraft = useShouldShowComposerForActiveEditDraft(); + const {isHistoryExpanded: isConciergeHistoryExpanded} = useConciergeAskState(report.reportID); const hideComposer = !canUserPerformWriteAction(report, isReportArchived) && !shouldShowComposerForActiveEditDraft; return ( {children} diff --git a/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx b/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx index f53fa5721a39..c92e3355a529 100644 --- a/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx +++ b/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx @@ -3,14 +3,16 @@ import useCopySelectionEvent from '@hooks/useCopySelectionEvent'; import {useIsReportLoadPending} from '@hooks/useInFlightRequests'; import usePendingConciergeResponse from '@hooks/usePendingConciergeResponse'; import useReportActionsListModel from '@hooks/useReportActionsListModel'; -import useStartConciergeSession from '@hooks/useStartConciergeSession'; + +import {useConciergeChatHistoryActions} from '@pages/inbox/ConciergeChatHistoryContext'; import CONST from '@src/CONST'; import type {ReactNode} from 'react'; -import React from 'react'; +import React, {useEffect} from 'react'; +import AskConciergeWelcome from './AskConciergeWelcome'; import {computeReportActionsSkeletonState, ReportActionsListActionsContext, ReportActionsListStateContext} from './ReportActionsListContext'; import ReportActionsLoadingSkeleton from './ReportActionsLoadingSkeleton'; @@ -35,31 +37,21 @@ function ReportActionsSkeletonGuard({reportID, children}: ReportActionsSkeletonG const {readinessSignals, state, actions} = useReportActionsListModel(reportID, isReportLoadPending); const {shouldShowLoadingSkeleton, shouldShowDerivedTimingSkeleton} = computeReportActionsSkeletonState(readinessSignals); - const { - isConciergeMainDM, - oldestUnreadReportAction, - hasOnceLoadedReportActions, - hasCachedReportActions, - isMissingReportActions, - hasOlderActions, - hasNewerActions, - isOffline, - isLoadingOlderReportActions, - hasLoadingOlderReportActionsError, - oldestReportActionID, - } = readinessSignals; + const {isMissingReportActions, hasOlderActions, hasNewerActions, isOffline, isLoadingOlderReportActions, hasLoadingOlderReportActionsError, oldestReportActionID} = readinessSignals; // Side effects that must run whenever the chat list is shown, including while the skeleton renders. useCopySelectionEvent(); usePendingConciergeResponse(reportID); - useStartConciergeSession({ - reportID, - isConciergeMainDM, - oldestUnreadReportAction, - hasOnceLoadedReportActions, - hasCachedReportActions, - }); + // Opening the chat marks the unread message as read, so expand the history to keep it on screen afterwards. + const {setShowFullHistory: setConciergeShowFullHistory} = useConciergeChatHistoryActions(); + const {hasUnreadConciergeMessageFromOthers} = readinessSignals; + useEffect(() => { + if (!hasUnreadConciergeMessageFromOthers) { + return; + } + setConciergeShowFullHistory(true); + }, [hasUnreadConciergeMessageFromOthers, setConciergeShowFullHistory]); useBackfillWhenNoVisibleActions({ reportID, @@ -74,6 +66,15 @@ function ReportActionsSkeletonGuard({reportID, children}: ReportActionsSkeletonG loadOlderChats: actions.loadOlderChats, }); + if ((shouldShowLoadingSkeleton || shouldShowDerivedTimingSkeleton) && state.shouldShowConciergeWelcome) { + return ( + + ); + } + if (shouldShowLoadingSkeleton) { return ( ); return ( - + {merchantRuleBanner} + {isConciergeHistoryExpanded && !isComposerFullSize && {translate('common.concierge.askNewQuestion')}} {shouldShowEnableNotificationsBanner ? ( <> diff --git a/src/styles/index.ts b/src/styles/index.ts index 0a91cf9766fa..e86b83072189 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -2480,6 +2480,12 @@ const staticStyles = (theme: ThemeColors) => paddingTop: 20, }, + conciergeAskColumn: { + width: '100%', + maxWidth: variables.conciergeAskColumnMaxWidth, + alignSelf: 'center', + }, + chatItemDraft: { display: 'flex', flexDirection: 'row', diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 2c8c5997d9af..bde0f22a131d 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -174,6 +174,7 @@ export default { receiptPreviewWidth: 380, widgetHeaderTitleLineHeight: 20, homePageLeftColumnMaxWidth: 680, + conciergeAskColumnMaxWidth: 720, centeredContentMaxWidth: 1200, insightsEmptyStateIllustrationSize: 136, minScanTooltipWidth: 320, diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index ada8743685db..01922fd2a874 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -45,6 +45,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; +import type {FileObject} from '@src/types/utils/Attachment'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; @@ -2307,6 +2308,48 @@ describe('actions/Report', () => { expect(relevant.slice(1).every((r) => r.command === WRITE_COMMANDS.ADD_ATTACHMENT)).toBe(true); }); + it('should post several attachments as one action when Concierge answers in a thread', async () => { + setHasRadio(false); + await waitForBatchedUpdates(); + + // Given two files sent with a caption to Concierge, which answers each question in a thread + const REPORT: OnyxTypes.Report = createRandomReport(1, undefined); + const fileA: FileObject = {name: 'a.jpg', type: 'image/jpeg', uri: 'file://a.jpg'}; + const fileB: FileObject = {name: 'b.jpg', type: 'image/jpeg', uri: 'file://b.jpg'}; + const conciergeThreadReportID = '9001'; + + // When they are sent together + Report.addAttachmentWithComment({ + report: REPORT, + notifyReportID: REPORT.reportID, + ancestors: [], + attachments: [fileA, fileB], + currentUserAccountID: 1, + text: 'Hello world', + delegateAccountID: undefined, + conciergeReportID: undefined, + conciergeThreadReportID, + shouldNavigateToConciergeThread: false, + }); + await waitForBatchedUpdates(); + + // Then one request carries both files with an attachment ID for each, so Concierge reads them together + const requests = PersistedRequests.getAll().filter((request) => request.command === WRITE_COMMANDS.ADD_ATTACHMENT || request.command === WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT); + expect(requests).toHaveLength(1); + const data = requests.at(0)?.data; + expect(data?.file).toEqual(fileA); + expect(data?.file_1).toEqual(fileB); + expect(String(data?.attachmentIDs).split(',')).toEqual([String(data?.attachmentID), expect.any(String)]); + + // And one report action shows both files and opens the thread + const reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT.reportID}` as const); + const reportAction = reportActions?.[String(data?.reportActionID)]; + const message = Array.isArray(reportAction?.message) ? reportAction?.message.at(0) : undefined; + expect(message?.html).toContain('file://a.jpg'); + expect(message?.html).toContain('file://b.jpg'); + expect(reportAction?.childReportID).toBe(conciergeThreadReportID); + }); + it('should create attachment only actions when adding multiple attachments without a comment', async () => { global.fetch = TestHelper.createGlobalFetchMock({ headers: new Headers({ diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 76f9ec963a27..58ee605d31ca 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -17,8 +17,8 @@ import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViol import DateUtils from '@libs/DateUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import {useConciergeChatHistoryActions, useConciergeChatHistoryState} from '@pages/inbox/ConciergeChatHistoryContext'; import {useConciergeDraft, useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext'; -import {useConciergeSessionActions, useConciergeSessionState} from '@pages/inbox/ConciergeSessionContext'; import ReportActionsList from '@pages/inbox/report/ReportActionsList'; import CONST from '@src/CONST'; @@ -27,6 +27,7 @@ import {reportActionsListLoadingStateSelector} from '@src/selectors/ReportMetaDa import type * as OnyxTypes from '@src/types/onyx'; import type * as ReactNavigation from '@react-navigation/native'; +import type ReactNative from 'react-native'; import React from 'react'; import Onyx from 'react-native-onyx'; @@ -40,7 +41,7 @@ jest.mock('@react-navigation/native', () => { ...actualNav, // eslint-disable-next-line @typescript-eslint/no-unsafe-return useIsFocused: () => mockUseIsFocused(), - useRoute: jest.fn(), + useRoute: () => ({key: 'Report-test', name: 'Report', params: {}}), useNavigationState: jest.fn(), createNavigationContainerRef: () => ({ getState: () => jest.fn(), @@ -49,6 +50,10 @@ jest.mock('@react-navigation/native', () => { }); jest.mock('@hooks/useNetwork', () => jest.fn()); +jest.mock('@pages/inbox/report/AskConciergeWelcome', () => { + const {View} = jest.requireActual('react-native'); + return () => ; +}); jest.mock('@hooks/useInFlightRequests', () => ({ ...jest.requireActual('@hooks/useInFlightRequests'), useIsReportLoadPending: jest.fn(), @@ -61,9 +66,9 @@ jest.mock('@hooks/useParentReportAction', () => jest.fn()); jest.mock('@hooks/useIsInSidePanel', () => jest.fn()); jest.mock('@hooks/useSidePanelState', () => jest.fn()); jest.mock('@hooks/useReportTransactionsCollection', () => jest.fn()); -jest.mock('@pages/inbox/ConciergeSessionContext', () => ({ - useConciergeSessionState: jest.fn(), - useConciergeSessionActions: jest.fn(), +jest.mock('@pages/inbox/ConciergeChatHistoryContext', () => ({ + useConciergeChatHistoryState: jest.fn(), + useConciergeChatHistoryActions: jest.fn(), })); jest.mock('@pages/inbox/ConciergeDraftContext', () => ({ useConciergeDraft: jest.fn(), @@ -82,8 +87,8 @@ const mockUseSidePanelState = useSidePanelState as jest.MockedFunction; const mockUseConciergeDraft = useConciergeDraft as jest.MockedFunction; const mockUseConciergeDraftActions = useConciergeDraftActions as jest.MockedFunction; -const mockUseConciergeSessionState = useConciergeSessionState as jest.MockedFunction; -const mockUseConciergeSessionActions = useConciergeSessionActions as jest.MockedFunction; +const mockUseConciergeChatHistoryState = useConciergeChatHistoryState as jest.MockedFunction; +const mockUseConciergeChatHistoryActions = useConciergeChatHistoryActions as jest.MockedFunction; function getMockReportLoadingState(selector: unknown, hasOnceLoadedReportActions = true) { return selector === reportActionsListLoadingStateSelector ? {hasOnceLoadedReportActions, isLoadingInitialReportActions: false} : undefined; @@ -329,8 +334,8 @@ describe('ReportActionsList (body)', () => { dispatchLocalDraftEvent: jest.fn(), revealDraftFromReportAction: jest.fn(), }); - mockUseConciergeSessionState.mockReturnValue({sessionStartTime: null, showFullHistory: false, hadMessagesAtSessionStart: false}); - mockUseConciergeSessionActions.mockReturnValue({startSession: jest.fn(), setShowFullHistory: jest.fn(), setHadMessagesAtSessionStart: jest.fn()}); + mockUseConciergeChatHistoryState.mockReturnValue({showFullHistory: false, isHistoryCollapsedByUser: false}); + mockUseConciergeChatHistoryActions.mockReturnValue({setShowFullHistory: jest.fn(), collapseHistory: jest.fn()}); mockUseOnyx.mockImplementation(getMockOnyxValue); }); @@ -1059,10 +1064,9 @@ describe('ReportActionsList (body)', () => { }); }); - describe('Concierge Main DM Hidden History', () => { + describe('Concierge Main DM', () => { const CONCIERGE_REPORT_ID = '123'; const CURRENT_USER_ACCOUNT_ID = 100; - const SESSION_START = '2024-06-01 12:00:00.000'; const oldReportActions: OnyxTypes.ReportAction[] = [ { @@ -1103,37 +1107,13 @@ describe('ReportActionsList (body)', () => { }, ]; - // The session filter keys off the child* fields the backend stamps on a task's parent action. - const buildTaskAction = ( - reportActionID: string, - created: string, - stateNum: OnyxTypes.ReportAction['childStateNum'], - statusNum: OnyxTypes.ReportAction['childStatusNum'], - ): OnyxTypes.ReportAction => ({ - reportActionID, - actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, - created, - actorAccountID: 456, - message: [{type: 'COMMENT', html: 'Take a test drive', text: 'Take a test drive'}], - originalMessage: {}, - childType: CONST.REPORT.TYPE.TASK, - childReportID: `task-${reportActionID}`, - childManagerAccountID: CURRENT_USER_ACCOUNT_ID, - childStateNum: stateNum, - childStatusNum: statusNum, - shouldShow: true, - person: [{type: 'TEXT', style: 'strong', text: 'Concierge'}], - pendingAction: null, - errors: {}, - }); - - const setupMainDMConciergeMocks = (sessionStartTime: string | null = SESSION_START, showFullHistory = false, hasOnceLoadedReportActions = true, hasOutstandingChildTask = false) => { + const setupMainDMConciergeMocks = (showFullHistory = false) => { jest.spyOn(ReportActionsUtils, 'shouldReportActionBeVisible').mockReturnValue(true); mockUseNetwork.mockReturnValue({isOffline: false}); mockUseIsInSidePanel.mockReturnValue(false); mockUseSidePanelState.mockReturnValue(defaultSidePanelState); - mockUseConciergeSessionState.mockReturnValue({sessionStartTime, showFullHistory, hadMessagesAtSessionStart: false}); - mockUseConciergeSessionActions.mockReturnValue({startSession: jest.fn(), setShowFullHistory: jest.fn(), setHadMessagesAtSessionStart: jest.fn()}); + mockUseConciergeChatHistoryState.mockReturnValue({showFullHistory, isHistoryCollapsedByUser: false}); + mockUseConciergeChatHistoryActions.mockReturnValue({setShowFullHistory: jest.fn(), collapseHistory: jest.fn()}); mockUseOnyx.mockImplementation((key: string, options) => { if (key === ONYXKEYS.CONCIERGE_REPORT_ID) { @@ -1146,13 +1126,21 @@ describe('ReportActionsList (body)', () => { return [false, {status: 'loaded'}]; } if (key.includes('reportLoadingState')) { - return [getMockReportLoadingState(options?.selector, hasOnceLoadedReportActions), {status: 'loaded'}]; + return [getMockReportLoadingState(options?.selector), {status: 'loaded'}]; + } + if (key === `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}` && options?.selector) { + const reportActions = Object.fromEntries(oldReportActions.map((action) => [action.reportActionID, action])); + return [!!(options.selector as (actions: OnyxTypes.ReportActions) => unknown)(reportActions), {status: 'loaded'}]; } if (key.includes('reportActions')) { return [[], {status: 'loaded'}]; } if (key === `${ONYXKEYS.COLLECTION.REPORT}${CONCIERGE_REPORT_ID}`) { - return [{...mockReport, reportID: CONCIERGE_REPORT_ID, hasOutstandingChildTask}, {status: 'loaded'}]; + const conciergeReport = {...mockReport, reportID: CONCIERGE_REPORT_ID}; + return [ + options?.selector ? (options.selector as (report: OnyxTypes.Report) => OnyxTypes.Report | boolean | undefined)(conciergeReport) : conciergeReport, + {status: 'loaded'}, + ]; } if (key.includes('report')) { return [undefined, {status: 'loaded'}]; @@ -1161,267 +1149,41 @@ describe('ReportActionsList (body)', () => { }); }; - it('should show greeting and filter old messages when session is active with prior user messages', () => { + it('should show the welcome screen in place of the conversation by default', () => { + // Given the main Concierge chat where the user asked before, with its history collapsed setupMainDMConciergeMocks(); - - mockUsePaginatedReportActions.mockReturnValue({ - ...defaultPaginatedReportActionsResult, - reportActions: oldReportActions, - hasOlderActions: false, - }); - - renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - - expect(mockInvertedFlashList).toHaveBeenCalled(); - const passedActions = getCapturedVisibleActions(); - expect(passedActions?.some((a) => a.reportActionID === CONST.CONCIERGE_GREETING_ACTION_ID)).toBe(true); - expect(passedActions?.some((a) => a.reportActionID === 'old-user-msg')).toBe(false); - expect(passedActions?.some((a) => a.reportActionID === 'old-concierge-msg')).toBe(false); - }); - - it('should keep read history hidden when the Concierge DM still has an outstanding child task', () => { - // Regression guard: an incomplete onboarding task used to force `showFullHistory` on permanently, - // which both un-hid the history and suppressed the "Show history" button. - setupMainDMConciergeMocks(SESSION_START, false, true, true); - mockUsePaginatedReportActions.mockReturnValue({ ...defaultPaginatedReportActionsResult, reportActions: oldReportActions, hasOlderActions: false, }); + // When the list renders renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(mockInvertedFlashList).toHaveBeenCalled(); - const passedActions = getCapturedVisibleActions(); - expect(passedActions?.some((a) => a.reportActionID === CONST.CONCIERGE_GREETING_ACTION_ID)).toBe(true); - expect(passedActions?.some((a) => a.reportActionID === 'old-user-msg')).toBe(false); - expect(passedActions?.some((a) => a.reportActionID === 'old-concierge-msg')).toBe(false); - }); - - it('should keep a still-open child task visible while the rest of the read history stays hidden', () => { - setupMainDMConciergeMocks(SESSION_START, false, true, true); - - // An in-session message keeps the list out of welcome mode, so the session filter actually runs. - const newUserMessage: OnyxTypes.ReportAction = { - reportActionID: 'new-user-msg', - actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, - created: '2024-06-01 12:05:00.000', - actorAccountID: CURRENT_USER_ACCOUNT_ID, - message: [{type: 'COMMENT', html: 'Hello', text: 'Hello'}], - originalMessage: {}, - shouldShow: true, - person: [{type: 'TEXT', style: 'strong', text: 'Test User'}], - pendingAction: null, - errors: {}, - }; - - mockUsePaginatedReportActions.mockReturnValue({ - ...defaultPaginatedReportActionsResult, - reportActions: [ - ...oldReportActions, - buildTaskAction('open-task', '2023-06-15 10:02:00.000', CONST.REPORT.STATE_NUM.OPEN, CONST.REPORT.STATUS_NUM.OPEN), - buildTaskAction('completed-task', '2023-06-15 10:03:00.000', CONST.REPORT.STATE_NUM.APPROVED, CONST.REPORT.STATUS_NUM.APPROVED), - newUserMessage, - ], - hasOlderActions: false, - }); - - renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - - expect(mockInvertedFlashList).toHaveBeenCalled(); - const passedActions = getCapturedVisibleActions(); - expect(passedActions?.some((a) => a.reportActionID === 'new-user-msg')).toBe(true); - expect(passedActions?.some((a) => a.reportActionID === 'open-task')).toBe(true); - expect(passedActions?.some((a) => a.reportActionID === 'completed-task')).toBe(false); - expect(passedActions?.some((a) => a.reportActionID === 'old-user-msg')).toBe(false); - expect(passedActions?.some((a) => a.reportActionID === 'old-concierge-msg')).toBe(false); - }); - - it('should keep a still-open child task visible in the fresh-session welcome view', () => { - // Opening the DM without sending anything puts the list in welcome mode, which returns early before the - // session filter runs. An open task must still survive that path, or it stays hidden until "Show history". - setupMainDMConciergeMocks(SESSION_START, false, true, true); - - mockUsePaginatedReportActions.mockReturnValue({ - ...defaultPaginatedReportActionsResult, - reportActions: [...oldReportActions, buildTaskAction('open-task', '2023-06-15 10:02:00.000', CONST.REPORT.STATE_NUM.OPEN, CONST.REPORT.STATUS_NUM.OPEN)], - hasOlderActions: false, - }); - - renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - - expect(mockInvertedFlashList).toHaveBeenCalled(); - const passedActions = getCapturedVisibleActions(); - expect(passedActions?.some((a) => a.reportActionID === 'open-task')).toBe(true); - expect(passedActions?.some((a) => a.reportActionID === 'old-user-msg')).toBe(false); - expect(passedActions?.some((a) => a.reportActionID === 'old-concierge-msg')).toBe(false); + // Then the welcome screen shows and the earlier conversation stays behind `View chat history` + expect(screen.getByTestId('AskConciergeWelcome')).toBeTruthy(); + expect(mockInvertedFlashList).not.toHaveBeenCalled(); }); - it('should show all actions when showFullHistory is true', () => { - setupMainDMConciergeMocks(SESSION_START, true); - + it('should show the whole conversation without a greeting once the history is expanded', () => { + // Given the user expanded the main Concierge chat history + setupMainDMConciergeMocks(true); mockUsePaginatedReportActions.mockReturnValue({ ...defaultPaginatedReportActionsResult, reportActions: oldReportActions, hasOlderActions: false, }); + // When the list renders renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); + // Then every earlier message shows, because only the side panel collapses its history expect(mockInvertedFlashList).toHaveBeenCalled(); const passedActions = getCapturedVisibleActions(); + expect(passedActions?.some((a) => a.reportActionID === CONST.CONCIERGE_GREETING_ACTION_ID)).toBe(false); expect(passedActions?.some((a) => a.reportActionID === 'old-user-msg')).toBe(true); expect(passedActions?.some((a) => a.reportActionID === 'old-concierge-msg')).toBe(true); }); - - it('should show all actions unfiltered when user sends a message in current session', () => { - setupMainDMConciergeMocks(); - - const actionsWithNewMsg: OnyxTypes.ReportAction[] = [ - ...oldReportActions, - { - reportActionID: 'new-user-msg', - actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, - created: '2024-06-01 12:05:00.000', - actorAccountID: CURRENT_USER_ACCOUNT_ID, - message: [{type: 'COMMENT', html: 'Hello', text: 'Hello'}], - originalMessage: {}, - shouldShow: true, - person: [{type: 'TEXT', style: 'strong', text: 'Test User'}], - pendingAction: null, - errors: {}, - }, - ]; - - mockUsePaginatedReportActions.mockReturnValue({ - ...defaultPaginatedReportActionsResult, - reportActions: actionsWithNewMsg, - hasOlderActions: false, - }); - - renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - - expect(mockInvertedFlashList).toHaveBeenCalled(); - const passedActions = getCapturedVisibleActions(); - // After user sends a message, the greeting stays visible alongside session actions - expect(passedActions?.some((a) => a.reportActionID === CONST.CONCIERGE_GREETING_ACTION_ID)).toBe(true); - expect(passedActions?.some((a) => a.reportActionID === 'new-user-msg')).toBe(true); - }); - - it('should not show old messages when sessionStartTime is null (session not started)', () => { - setupMainDMConciergeMocks(null); - - mockUsePaginatedReportActions.mockReturnValue({ - ...defaultPaginatedReportActionsResult, - reportActions: oldReportActions, - hasOlderActions: false, - }); - - renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - - expect(mockInvertedFlashList).toHaveBeenCalled(); - const passedActions = getCapturedVisibleActions(); - // With no session, old messages should not be shown - expect(passedActions?.some((a) => a.reportActionID === 'old-user-msg')).toBe(false); - expect(passedActions?.some((a) => a.reportActionID === 'old-concierge-msg')).toBe(false); - }); - - it('should pass through onboarding messages for a new user with no prior messages', () => { - setupMainDMConciergeMocks(); - - // Only onboarding messages from Concierge, no user messages before session - const onboardingActions: OnyxTypes.ReportAction[] = [ - { - reportActionID: 'created-1', - actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, - created: '2024-06-01 11:59:00.000', - actorAccountID: 123, - message: [{type: 'COMMENT', html: '', text: ''}], - originalMessage: {}, - shouldShow: true, - person: [{type: 'TEXT', style: 'strong', text: 'System'}], - pendingAction: null, - errors: {}, - }, - { - reportActionID: 'onboarding-msg', - actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, - created: '2024-06-01 11:59:30.000', - actorAccountID: 456, - message: [{type: 'COMMENT', html: 'Welcome to Expensify!', text: 'Welcome to Expensify!'}], - originalMessage: {}, - shouldShow: true, - person: [{type: 'TEXT', style: 'strong', text: 'Concierge'}], - pendingAction: null, - errors: {}, - }, - ]; - - mockUsePaginatedReportActions.mockReturnValue({ - ...defaultPaginatedReportActionsResult, - reportActions: onboardingActions, - hasOlderActions: false, - }); - - renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - - expect(mockInvertedFlashList).toHaveBeenCalled(); - const passedActions = getCapturedVisibleActions(); - // New user with no prior messages — onboarding messages pass through (no filtering) - expect(passedActions?.some((a) => a.reportActionID === 'onboarding-msg')).toBe(true); - }); - - it('should call startSession on mount for main DM concierge', () => { - const mockStartSession = jest.fn(); - mockUseConciergeSessionActions.mockReturnValue({startSession: mockStartSession, setShowFullHistory: jest.fn(), setHadMessagesAtSessionStart: jest.fn()}); - setupMainDMConciergeMocks(); - mockUseConciergeSessionActions.mockReturnValue({startSession: mockStartSession, setShowFullHistory: jest.fn(), setHadMessagesAtSessionStart: jest.fn()}); - - mockUsePaginatedReportActions.mockReturnValue({ - ...defaultPaginatedReportActionsResult, - reportActions: oldReportActions, - hasOlderActions: false, - }); - - renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - - expect(mockStartSession).toHaveBeenCalled(); - }); - - it('should render cached actions without a skeleton on refresh when hasOnceLoadedReportActions resets but actions are cached', () => { - // Simulates a page refresh: hasOnceLoadedReportActions is RAM-only and resets to false, - // but report actions persist in Onyx cache. We should render them immediately (production behavior). - setupMainDMConciergeMocks(SESSION_START, false, false); - - mockUsePaginatedReportActions.mockReturnValue({ - ...defaultPaginatedReportActionsResult, - reportActions: oldReportActions, - hasOlderActions: false, - }); - - renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - - expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); - expect(mockInvertedFlashList).toHaveBeenCalled(); - }); - - it('should show a skeleton on a cold load when hasOnceLoadedReportActions is false and there are no cached actions', () => { - setupMainDMConciergeMocks(SESSION_START, false, false); - - mockUsePaginatedReportActions.mockReturnValue({ - ...defaultPaginatedReportActionsResult, - reportActions: [], - hasOlderActions: false, - }); - - renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - - expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); - // The concierge-hidden-history skeleton shows while the report is otherwise "ready"; the content - // is still not mounted, so the UI-close hooks never run here either. - expect(mockUseMarkAsRead).not.toHaveBeenCalled(); - expect(mockUseReportActionsScroll).not.toHaveBeenCalled(); - }); }); }); diff --git a/tests/ui/ReportFooterTest.tsx b/tests/ui/ReportFooterTest.tsx index 3aa3c85779a0..9a5d3d86c083 100644 --- a/tests/ui/ReportFooterTest.tsx +++ b/tests/ui/ReportFooterTest.tsx @@ -4,6 +4,7 @@ import ComposeProviders from '@components/ComposeProviders'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import useConciergeAskState from '@hooks/useConciergeAskState'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import ReportFooter from '@pages/inbox/report/ReportFooter'; @@ -28,10 +29,21 @@ jest.mock('@react-navigation/native', () => { return { ...actualNav, useRoute: jest.fn(), + useIsFocused: () => true, }; }); const mockedUseRoute = jest.mocked(useRoute); +jest.mock('@hooks/useConciergeAskState', () => ({ + __esModule: true, + default: jest.fn(jest.requireActual<{default: typeof useConciergeAskState}>('@hooks/useConciergeAskState').default), +})); + +jest.mock('@pages/inbox/report/ReportActionCompose/ReportActionCompose', () => { + const MockReportActionCompose = Object.assign(() => null, {EditOnly: () => null}); + return {__esModule: true, default: MockReportActionCompose}; +}); + const REPORT_ID = '1'; const OFFLINE_MESSAGE = 'You appear to be offline.'; @@ -92,6 +104,28 @@ describe('ReportFooter', () => { await Onyx.clear(); }); jest.clearAllMocks(); + jest.mocked(useConciergeAskState).mockImplementation(jest.requireActual<{default: typeof useConciergeAskState}>('@hooks/useConciergeAskState').default); + }); + + it('labels the composer `Ask a new question` once the Concierge chat history is expanded', async () => { + // Given the main Concierge chat with its history expanded + mockLayout({isSmallScreenWidth: false, isInNarrowPaneModal: false}); + jest.mocked(useConciergeAskState).mockReturnValue({ + isHistoryExpanded: true, + shouldShowWelcome: false, + shouldCenterWelcomeWithComposer: false, + hasUnreadMessageFromOthers: false, + }); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {reportID: REPORT_ID, type: CONST.REPORT.TYPE.CHAT}); + }); + + // When the footer renders + renderReportFooter(); + await waitForBatchedUpdatesWithAct(); + + // Then the composer is labeled, so the user knows a new message starts a new question + expect(screen.getByText('Ask a new question')).toBeOnTheScreen(); }); describe.each([ diff --git a/tests/ui/components/HeaderViewTest.tsx b/tests/ui/components/HeaderViewTest.tsx index 227411d07ab9..ea5dab8a2db4 100644 --- a/tests/ui/components/HeaderViewTest.tsx +++ b/tests/ui/components/HeaderViewTest.tsx @@ -8,6 +8,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; import type Navigation from '@libs/Navigation/Navigation'; +import NavigationActions from '@libs/Navigation/Navigation'; import {buildOptimisticCreatedReportForUnapprovedAction} from '@libs/ReportUtils'; import HeaderView from '@pages/inbox/HeaderView'; @@ -48,6 +49,8 @@ const mockUseCurrentUserPersonalDetails = jest.mocked(useCurrentUserPersonalDeta const currentUserAccountID = 1; describe('HeaderView', () => { + let navigateSpy: jest.SpyInstance | undefined; + beforeEach(() => { // Set up default mock return value mockUseCurrentUserPersonalDetails.mockReturnValue({ @@ -56,6 +59,7 @@ describe('HeaderView', () => { }); afterEach(async () => { + navigateSpy?.mockRestore(); jest.clearAllMocks(); await Onyx.clear(); }); @@ -124,6 +128,46 @@ describe('HeaderView', () => { await waitFor(() => expect(screen.getByTestId('DisplayNames')).toHaveTextContent(displayName)); }); + it('should go back to the Concierge chat when New chat is pressed in a Concierge thread', async () => { + // Given the header of a thread that Concierge opened for a question + const conciergeReportID = '10'; + const questionAction: ReportAction = { + reportActionID: '100', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + created: '2024-06-01 10:00:00.000', + actorAccountID: currentUserAccountID, + childReportID: '11', + message: [{type: CONST.REPORT.MESSAGE.TYPE.COMMENT, html: 'How do I submit an expense?', text: 'How do I submit an expense?'}], + }; + const threadReport: Report = { + ...createRandomReport(11, undefined), + type: CONST.REPORT.TYPE.CHAT, + parentReportID: conciergeReportID, + parentReportActionID: questionAction.reportActionID, + }; + await Onyx.set(ONYXKEYS.CONCIERGE_REPORT_ID, conciergeReportID); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${conciergeReportID}`, {[questionAction.reportActionID]: questionAction}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${threadReport.reportID}`, threadReport); + await waitForBatchedUpdates(); + navigateSpy = jest.spyOn(NavigationActions, 'navigate').mockImplementation(() => {}); + + render( + + {}} + reportID={threadReport.reportID} + /> + , + ); + await waitForBatchedUpdatesWithAct(); + + // When the user presses New chat + fireEvent.press(screen.getByText(translateLocal('common.concierge.newChat'))); + + // Then the user is taken back to the Concierge chat to ask another question + expect(navigateSpy).toHaveBeenCalledWith(expect.stringContaining(`r/${conciergeReportID}`)); + }); + it('should display join button', async () => { // Given an policy room header const report = { diff --git a/tests/unit/ConciergeChatHistoryToggleTest.tsx b/tests/unit/ConciergeChatHistoryToggleTest.tsx new file mode 100644 index 000000000000..d9c741fdc36a --- /dev/null +++ b/tests/unit/ConciergeChatHistoryToggleTest.tsx @@ -0,0 +1,55 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import {LocaleContextProvider} from '@components/LocaleContextProvider'; + +import ConciergeChatHistoryToggle from '@pages/inbox/report/ConciergeChatHistoryToggle'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; + +import React from 'react'; + +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: () => ({UpArrow: 'UpArrow', DownArrow: 'DownArrow'}), +})); + +const renderToggle = (isHistoryShown: boolean) => { + const onPress = jest.fn(); + render( + + + , + ); + return {onPress}; +}; + +describe('ConciergeChatHistoryToggle', () => { + beforeAll(async () => { + await IntlStore.load(CONST.LOCALES.EN); + }); + + it('offers to show the earlier conversation while it is hidden', () => { + // Given the earlier conversation is hidden + const {onPress} = renderToggle(false); + + // When the user presses `View chat history` + fireEvent.press(screen.getByText('View chat history')); + + // Then the press is handled + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('offers to hide the earlier conversation while it is shown', () => { + // Given the earlier conversation is shown + const {onPress} = renderToggle(true); + + // When the user presses `Hide chat history` + fireEvent.press(screen.getByText('Hide chat history')); + + // Then the press is handled + expect(onPress).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/ReportActionsListContextTest.ts b/tests/unit/ReportActionsListContextTest.ts index d23ca6dc7830..fe5b75acacca 100644 --- a/tests/unit/ReportActionsListContextTest.ts +++ b/tests/unit/ReportActionsListContextTest.ts @@ -50,10 +50,9 @@ function createReadinessSignals(overrides: Partial ({ canUserPerformWriteAction: jest.fn(), })); +jest.mock('@hooks/useConciergeAskState', () => () => ({isHistoryExpanded: false})); + jest.mock('@hooks/useThemeStyles', () => () => ({ flex1: {flex: 1}, pb4: {paddingBottom: PB4_PADDING_BOTTOM}, diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 43f7c1f81f1e..6fb070d5fad5 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -212,7 +212,7 @@ import { isSortableColumnName, isThreadMemberProtectedByParentReport, isUnread, - isUploadingAttachmentRemovedFromDraft, + getUploadingAttachmentIDsRemovedFromDraft, isWorkspaceMemberLeavingWorkspaceRoom, parseMovedTransactionReportIDs, parseReportRouteParams, @@ -7937,7 +7937,7 @@ describe('ReportUtils', () => { describe('replaceLocalAttachmentReferences', () => { const reportActionID = '6076606438659303644'; const syncedImageHtml = `Hello

photo.jpg`; - const uploadingImageHtml = `Hello

photo.jpg`; + const uploadingImageHtml = `Hello

photo.jpg`; it('drops the local reference while the upload is still pending, since the queued send re-attaches the file', () => { const draft = 'Hello edited\n\n!(blob:https://dev.new.expensify.com:8082/uuid-1)'; @@ -7985,6 +7985,82 @@ describe('ReportUtils', () => { ); }); + describe('several synced attachments', () => { + const firstSource = 'blob:https://dev.new.expensify.com:8082/uuid-1'; + const secondSource = 'blob:https://dev.new.expensify.com:8082/uuid-2'; + const syncedTwoImagesHtml = `Hello

photo.jpgreceipt.jpg`; + const attachmentIDByLocalSource = {[firstSource]: '11', [secondSource]: '12'}; + + it('keeps every attachment the draft kept', () => { + // Given a message with two images that synced after the user started editing + const draft = `Hello edited\n\n!(${firstSource})\n\n!(${secondSource})`; + + // When the edit is saved + const savedDraft = replaceLocalAttachmentReferences(draft, syncedTwoImagesHtml, reportActionID, attachmentIDByLocalSource); + + // Then both images stay in the message + expect(savedDraft).toBe( + `Hello edited\n\n![photo.jpg](https://www.expensify.com/chat-attachments/${reportActionID}/w_abc.jpg)\n\n![receipt.jpg](https://www.expensify.com/chat-attachments/${reportActionID}/w_def.jpg)`, + ); + }); + + it('matches by position when the synced attachments have no attachment ID', () => { + // Given synced images without an attachment ID + const syncedHtmlWithoutIDs = syncedTwoImagesHtml.replaceAll(/ data-attachment-id="\d+"/g, ''); + const draft = `Hello edited\n\n!(${firstSource})\n\n!(${secondSource})`; + + // When the edit is saved + const savedDraft = replaceLocalAttachmentReferences(draft, syncedHtmlWithoutIDs, reportActionID, attachmentIDByLocalSource); + + // Then both images stay in the message + expect(savedDraft).toBe( + `Hello edited\n\n![photo.jpg](https://www.expensify.com/chat-attachments/${reportActionID}/w_abc.jpg)\n\n![receipt.jpg](https://www.expensify.com/chat-attachments/${reportActionID}/w_def.jpg)`, + ); + }); + + it('keeps the image the user kept when they deleted the other one', () => { + // Given the user deleted the first image while both were uploading + const draft = `Hello edited\n\n!(${secondSource})`; + + // When the edit is saved after the upload synced + const savedDraft = replaceLocalAttachmentReferences(draft, syncedTwoImagesHtml, reportActionID, attachmentIDByLocalSource); + + // Then the second image stays, not the first + expect(savedDraft).toBe(`Hello edited\n\n![receipt.jpg](https://www.expensify.com/chat-attachments/${reportActionID}/w_def.jpg)`); + }); + + it('keeps the image the user kept by its file name after the app restarted', () => { + // Given the user deleted the first image, and the app restarted so it no longer knows which upload is which + const syncedHtmlWithoutIDs = syncedTwoImagesHtml.replaceAll(/ data-attachment-id="\d+"/g, ''); + const draft = 'Hello edited\n\n!(file:///var/mobile/Documents/receipt.jpg)'; + + // When the edit is saved after the upload synced + const savedDraft = replaceLocalAttachmentReferences(draft, syncedHtmlWithoutIDs, reportActionID, {}); + + // Then the second image stays, matched by its file name + expect(savedDraft).toBe(`Hello edited\n\n![receipt.jpg](https://www.expensify.com/chat-attachments/${reportActionID}/w_def.jpg)`); + }); + + it('matches same-named images to different synced attachments', () => { + // Given three synced images with the same file name, and a draft that kept two of them after the app restarted + const syncedSameNameHtml = ['a', 'b', 'c'] + .map( + (name) => + `image.png`, + ) + .join(''); + const draft = 'Hello edited\n\n![image.png](file:///var/mobile/Documents/image.png)\n\n![image.png](file:///var/mobile/Documents/image.png)'; + + // When the edit is saved + const savedDraft = replaceLocalAttachmentReferences(draft, `Hello

${syncedSameNameHtml}`, reportActionID, {}); + + // Then each kept image gets its own synced attachment instead of both getting the first one + expect(savedDraft).toBe( + `Hello edited\n\n![image.png](https://www.expensify.com/chat-attachments/${reportActionID}/w_a.png)\n\n![image.png](https://www.expensify.com/chat-attachments/${reportActionID}/w_b.png)`, + ); + }); + }); + it('swaps a document reference for the synced attachment link', () => { const syncedDocHtml = `Hello

file.doc`; const draft = 'Hello edited\n\n[file.doc](blob:https://dev.new.expensify.com:8082/uuid-1)'; @@ -8019,17 +8095,17 @@ describe('ReportUtils', () => { }); it('reports the uploading attachment as removed once the draft drops its local reference', () => { - expect(isUploadingAttachmentRemovedFromDraft('Hello edited', uploadingImageHtml)).toBe(true); + expect(getUploadingAttachmentIDsRemovedFromDraft('Hello edited', uploadingImageHtml)).toEqual(['1']); }); it('does not report a removal while the draft still references the uploading attachment', () => { const draft = 'Hello edited\n\n!(blob:https://dev.new.expensify.com:8082/uuid-1)'; - expect(isUploadingAttachmentRemovedFromDraft(draft, uploadingImageHtml)).toBe(false); + expect(getUploadingAttachmentIDsRemovedFromDraft(draft, uploadingImageHtml)).toEqual([]); }); it('does not report a removal when the attachment already synced', () => { - expect(isUploadingAttachmentRemovedFromDraft('Hello edited', syncedImageHtml)).toBe(false); + expect(getUploadingAttachmentIDsRemovedFromDraft('Hello edited', syncedImageHtml)).toEqual([]); }); it('returns the uploading attachment tag so the optimistic message can keep showing it', () => { @@ -8039,6 +8115,45 @@ describe('ReportUtils', () => { expect(tag?.startsWith(' { + const firstSource = 'blob:https://dev.new.expensify.com:8082/uuid-1'; + const secondSource = 'blob:https://dev.new.expensify.com:8082/uuid-2'; + const uploadingImagesHtml = `Hello


`; + + it('reports only the attachments the draft dropped', () => { + // Given a draft that keeps the second image but not the first + const draft = `Hello edited\n\n!(${secondSource})`; + + // When the removed uploads are looked up + const removedAttachmentIDs = getUploadingAttachmentIDsRemovedFromDraft(draft, uploadingImagesHtml); + + // Then only the first image counts as removed + expect(removedAttachmentIDs).toEqual(['11']); + }); + + it('keeps showing every attachment the edit kept', () => { + // Given a message with two uploading images + + // When the first image is removed + const html = getUploadingAttachmentHtmlFromComment(uploadingImagesHtml, ['11']); + + // Then only the second image is re-appended to the edited message + expect(html).toContain(secondSource); + expect(html).not.toContain(firstSource); + }); + + it('re-appends all the attachments when none were removed', () => { + // Given a message with two uploading images + + // When the edit keeps both images + const html = getUploadingAttachmentHtmlFromComment(uploadingImagesHtml); + + // Then both images are re-appended to the edited message + expect(html).toContain(firstSource); + expect(html).toContain(secondSource); + }); + }); + it('returns nothing to re-append once the attachment has synced', () => { expect(getUploadingAttachmentHtmlFromComment(syncedImageHtml)).toBeUndefined(); }); @@ -8086,7 +8201,7 @@ describe('ReportUtils', () => { const secondEditDraft = Parser.htmlToMarkdown(storedAfterFirstEdit).trim(); expect(secondEditDraft).toContain(videoSource); - expect(isUploadingAttachmentRemovedFromDraft(secondEditDraft, storedAfterFirstEdit)).toBe(false); + expect(getUploadingAttachmentIDsRemovedFromDraft(secondEditDraft, storedAfterFirstEdit)).toEqual([]); }); }); diff --git a/tests/unit/RequestConflictUtilsTest.ts b/tests/unit/RequestConflictUtilsTest.ts index 831de0a5846c..1e8daea67244 100644 --- a/tests/unit/RequestConflictUtilsTest.ts +++ b/tests/unit/RequestConflictUtilsTest.ts @@ -152,7 +152,7 @@ describe('RequestConflictUtils', () => { const reportActionID = '2'; const persistedRequests = [{command: 'AddTextAndAttachment', data: {reportActionID, reportComment: 'test', file: {uri: 'blob:local'}, attachmentID: '5'}}, {command: 'OpenReport'}]; const parameters = {reportID: '1', reportActionID, reportComment: 'attachment removed'}; - const result = resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, 0, true); + const result = resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, 0, ['5']); expect(result).toEqual({ conflictAction: { type: 'replace', @@ -162,17 +162,71 @@ describe('RequestConflictUtils', () => { }); }); + it('resolveEditCommentWithNewAddCommentRequest should drop only the queued attachments the edit removed', () => { + // Given a queued message with three files and an attachment ID for each + const reportActionID = '2'; + const persistedRequests = [ + { + command: 'AddTextAndAttachment', + // eslint-disable-next-line @typescript-eslint/naming-convention -- the API names extra files file_1, file_2, ... + data: {reportActionID, reportComment: 'test', file: {uri: 'blob:a'}, file_1: {uri: 'blob:b'}, file_2: {uri: 'blob:c'}, attachmentID: '5', attachmentIDs: '5,6,7'}, + }, + ]; + const parameters = {reportID: '1', reportActionID, reportComment: 'b removed'}; + + // When the edit removes the middle file + const result = resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, 0, ['6']); + + // Then the request still sends the other two files, renumbered with their own IDs + expect(result.conflictAction).toEqual({ + type: 'replace', + index: 0, + request: { + command: 'AddTextAndAttachment', + // eslint-disable-next-line @typescript-eslint/naming-convention -- the API names extra files file_1, file_2, ... + data: {reportID: '1', reportActionID, reportComment: 'b removed', file: {uri: 'blob:a'}, file_1: {uri: 'blob:c'}, attachmentID: '5', attachmentIDs: '5,7'}, + }, + }); + }); + + it('resolveEditCommentWithNewAddCommentRequest should move the remaining queued attachment into the first slot', () => { + // Given a queued message with three files + const reportActionID = '2'; + const persistedRequests = [ + { + command: 'AddTextAndAttachment', + // eslint-disable-next-line @typescript-eslint/naming-convention -- the API names extra files file_1, file_2, ... + data: {reportActionID, reportComment: 'test', file: {uri: 'blob:a'}, file_1: {uri: 'blob:b'}, file_2: {uri: 'blob:c'}, attachmentID: '5', attachmentIDs: '5,6,7'}, + }, + ]; + const parameters = {reportID: '1', reportActionID, reportComment: 'only b left'}; + + // When the edit removes the first and last files + const result = resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, 0, ['5', '7']); + + // Then the request sends the one file left as a single attachment + expect(result.conflictAction).toEqual({ + type: 'replace', + index: 0, + request: { + command: 'AddTextAndAttachment', + data: {reportID: '1', reportActionID, reportComment: 'only b left', file: {uri: 'blob:b'}, attachmentID: '6'}, + }, + }); + }); + it('resolveEditCommentWithNewAddCommentRequest should rename the queued attachment when the edit renamed it', () => { // Given a text-and-attachment request still queued, with the file picked on native as a plain object const reportActionID = '2'; + const attachmentID = '5'; const persistedRequests = [ - {command: 'AddTextAndAttachment', data: {reportActionID, reportComment: 'test', file: {uri: 'blob:local', name: 'data.csv'}, attachmentID: '5'}}, + {command: 'AddTextAndAttachment', data: {reportActionID, reportComment: 'test', file: {uri: 'blob:local', name: 'data.csv'}, attachmentID}}, {command: 'OpenReport'}, ]; const parameters = {reportID: '1', reportActionID, reportComment: 'text edited'}; // When an edit renames the attachment before the request is sent - const result = resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, 0, false, 'renamed.csv'); + const result = resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, 0, [], {[attachmentID]: 'renamed.csv'}); // Then the queued request carries the edited text and the file under its new name, since the server names the attachment after the upload expect(result).toEqual({ @@ -181,7 +235,7 @@ describe('RequestConflictUtils', () => { index: 0, request: { command: 'AddTextAndAttachment', - data: {reportID: '1', reportActionID, reportComment: 'text edited', file: {uri: 'blob:local', name: 'renamed.csv'}, attachmentID: '5'}, + data: {reportID: '1', reportActionID, reportComment: 'text edited', file: {uri: 'blob:local', name: 'renamed.csv'}, attachmentID}, }, }, }); @@ -190,14 +244,15 @@ describe('RequestConflictUtils', () => { it('resolveEditCommentWithNewAddCommentRequest should keep the extension when the new name has none', () => { // Given a queued attachment the editor renames to a bare word const reportActionID = '2'; + const attachmentID = '5'; const persistedRequests = [ - {command: 'AddTextAndAttachment', data: {reportActionID, reportComment: 'test', file: {uri: 'blob:local', name: 'data.csv'}, attachmentID: '5'}}, + {command: 'AddTextAndAttachment', data: {reportActionID, reportComment: 'test', file: {uri: 'blob:local', name: 'data.csv'}, attachmentID}}, {command: 'OpenReport'}, ]; const parameters = {reportID: '1', reportActionID, reportComment: 'text edited'}; // When the rename is applied to the queued file - const result = resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, 0, false, 'report'); + const result = resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, 0, [], {[attachmentID]: 'report'}); // Then the original extension travels with it, because the server names the stored file after the upload and // a download can only add back an extension the stored copy already has @@ -207,7 +262,7 @@ describe('RequestConflictUtils', () => { index: 0, request: { command: 'AddTextAndAttachment', - data: {reportID: '1', reportActionID, reportComment: 'text edited', file: {uri: 'blob:local', name: 'report.csv'}, attachmentID: '5'}, + data: {reportID: '1', reportActionID, reportComment: 'text edited', file: {uri: 'blob:local', name: 'report.csv'}, attachmentID}, }, }, }); @@ -216,13 +271,14 @@ describe('RequestConflictUtils', () => { it('resolveEditCommentWithNewAddCommentRequest should rebuild a renamed File, whose name cannot be reassigned', () => { // Given a queued request whose attachment is a web File, where the name is read-only const reportActionID = '2'; + const attachmentID = '5'; const file = new File(['id,total\n1,2'], 'data.csv', {type: 'text/csv'}); - const queuedRequest = {command: 'AddTextAndAttachment', data: {reportActionID, reportComment: 'test', file, attachmentID: '5'}}; + const queuedRequest = {command: 'AddTextAndAttachment', data: {reportActionID, reportComment: 'test', file, attachmentID}}; const persistedRequests = [queuedRequest, {command: 'OpenReport'}]; const parameters = {reportID: '1', reportActionID, reportComment: 'text edited'}; // When an edit renames the attachment before the request is sent - resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, 0, false, 'renamed.csv'); + resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, 0, [], {[attachmentID]: 'renamed.csv'}); const renamedFile = queuedRequest.data.file; // Then a new File under the new name replaces it and keeps the original type @@ -234,14 +290,15 @@ describe('RequestConflictUtils', () => { it('resolveEditCommentWithNewAddCommentRequest should keep the native upload properties when it renames a File', () => { // Given a queued attachment shaped the way readFileAsync builds one for the native share flow const reportActionID = '2'; + const attachmentID = '5'; const localUri = 'file:///storage/emulated/0/Android/data/com.expensify.chat/files/Download/Receipts-Upload/data.csv'; const file = Object.assign(new File(['id,total\n1,2'], 'data.csv', {type: 'text/csv'}), {uri: localUri, source: localUri}); - const queuedRequest = {command: 'AddTextAndAttachment', data: {reportActionID, reportComment: 'test', file, attachmentID: '5'}}; + const queuedRequest = {command: 'AddTextAndAttachment', data: {reportActionID, reportComment: 'test', file, attachmentID}}; const persistedRequests = [queuedRequest, {command: 'OpenReport'}]; const parameters = {reportID: '1', reportActionID, reportComment: 'text edited'}; // When an offline edit renames it - resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, 0, false, 'renamed.csv'); + resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, 0, [], {[attachmentID]: 'renamed.csv'}); const renamedFile = queuedRequest.data.file; // Then the properties the native payload reads to find the file on disk survive the rename diff --git a/tests/unit/hooks/useAskConcierge.test.tsx b/tests/unit/hooks/useAskConcierge.test.tsx index 943fdf4f3cc1..32df52a43f45 100644 --- a/tests/unit/hooks/useAskConcierge.test.tsx +++ b/tests/unit/hooks/useAskConcierge.test.tsx @@ -1,4 +1,4 @@ -import {renderHook} from '@testing-library/react-native'; +import {renderHook, waitFor} from '@testing-library/react-native'; import useAskConcierge from '@components/Search/SearchRouter/useAskConcierge'; @@ -19,8 +19,8 @@ const ADMINS_ROOM_REPORT_ID = '200'; let mockSidePanelReportID: string | undefined; let mockIsInSidePanel = false; -let mockIsBetaEnabled = false; const mockOpenConciergeAnywhere = jest.fn(); +const mockSetShowFullHistory = jest.fn(); jest.mock('@hooks/useSidePanelReportID', () => ({ __esModule: true, @@ -32,6 +32,10 @@ jest.mock('@hooks/useOpenConciergeAnywhere', () => ({ default: () => ({openConciergeAnywhere: mockOpenConciergeAnywhere, isInSidePanel: mockIsInSidePanel}), })); +jest.mock('@pages/inbox/ConciergeChatHistoryContext', () => ({ + useConciergeChatHistoryActions: () => ({setShowFullHistory: mockSetShowFullHistory}), +})); + jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ __esModule: true, default: () => ({accountID: 1, timezone: {selected: 'Europe/Warsaw'}, login: 'user@domain.com', email: 'user@domain.com', displayName: 'User', avatar: undefined}), @@ -42,11 +46,6 @@ jest.mock('@hooks/useDelegateAccountID', () => ({ default: () => '99', })); -jest.mock('@hooks/usePermissions', () => ({ - __esModule: true, - default: () => ({isBetaEnabled: () => mockIsBetaEnabled, isBetaEnabledOrUnknown: () => mockIsBetaEnabled}), -})); - jest.mock('@userActions/Report', () => ({ addComment: jest.fn(), addAttachmentWithComment: jest.fn(), @@ -80,7 +79,6 @@ describe('useAskConcierge', () => { beforeEach(async () => { mockSidePanelReportID = undefined; mockIsInSidePanel = false; - mockIsBetaEnabled = false; jest.clearAllMocks(); await Onyx.clear(); }); @@ -93,10 +91,19 @@ describe('useAskConcierge', () => { await seedReports(); // When the hook resolves its target - const {result} = renderAskConcierge(); + const {result, rerender} = renderAskConcierge(); // Then it targets that report, not Concierge expect(result.current.conciergeTargetReportID).toBe(ADMINS_ROOM_REPORT_ID); + + // When the side panel shows the thread of an earlier Concierge question + const conciergeThread = {reportID: '300', parentReportID: CONCIERGE_REPORT_ID} as Report; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${conciergeThread.reportID}`, conciergeThread); + mockSidePanelReportID = conciergeThread.reportID; + rerender({}); + + // Then it targets Concierge, so the next question opens a new thread + await waitFor(() => expect(result.current.conciergeTargetReportID).toBe(CONCIERGE_REPORT_ID)); }); it('targets Concierge when forced, even though the side panel maps elsewhere', async () => { @@ -126,7 +133,8 @@ describe('useAskConcierge', () => { describe('askConcierge', () => { it('opens Concierge and sends the trimmed message to the resolved report', async () => { - // Given a loaded Concierge report + // Given web, with a loaded Concierge report + mockIsInSidePanel = true; await seedReports(); const {result} = renderAskConcierge({forceConcierge: true}); expect(result.current.shouldShowAskConcierge).toBe(true); @@ -134,8 +142,10 @@ describe('useAskConcierge', () => { // When a padded message is sent result.current.askConcierge(' Where is my expense? '); - // Then Concierge is opened and the trimmed text is added as a comment - expect(mockOpenConciergeAnywhere).toHaveBeenCalledWith({forceConcierge: true}); + // Then Concierge is opened on the new thread and the trimmed text is added as a comment + const conciergeThreadReportID = mockAddComment.mock.calls.at(0)?.at(0)?.conciergeThreadReportID; + expect(conciergeThreadReportID).toEqual(expect.any(String)); + expect(mockOpenConciergeAnywhere).toHaveBeenCalledWith({forceConcierge: true, reportID: conciergeThreadReportID}); expect(mockAddComment).toHaveBeenCalledWith( expect.objectContaining({ report: CONCIERGE_REPORT, @@ -144,7 +154,7 @@ describe('useAskConcierge', () => { currentUserAccountID: ACCOUNT_ID, timezoneParam: {selected: TIMEZONE}, shouldPlaySound: true, - isInSidePanel: false, + isInSidePanel: true, delegateAccountID: DELEGATE_ACCOUNT_ID, conciergeReportID: CONCIERGE_REPORT_ID, }), @@ -161,9 +171,10 @@ describe('useAskConcierge', () => { // When the shorthand is sent result.current.askConcierge(' [] Buy milk '); - // Then Concierge is still opened, the detection gets the trimmed text and the target report, - // and the text is not also posted as a plain comment + // Then Concierge is still opened with its history expanded so the task shows, the detection gets the + // trimmed text and the target report, and the text is not also posted as a plain comment expect(mockOpenConciergeAnywhere).toHaveBeenCalledWith({forceConcierge: true}); + expect(mockSetShowFullHistory).toHaveBeenCalledWith(true); expect(mockCreateTaskFromMarkdown).toHaveBeenCalledWith(expect.objectContaining({text: '[] Buy milk', parentReport: CONCIERGE_REPORT})); expect(mockAddComment).not.toHaveBeenCalled(); }); @@ -201,7 +212,8 @@ describe('useAskConcierge', () => { const FILES: FileObject[] = [{name: 'receipt.jpg', type: 'image/jpeg', uri: 'file://receipt.jpg'}]; it('opens Concierge and sends the attachments with the trimmed text', async () => { - // Given a loaded Concierge report + // Given web, with a loaded Concierge report + mockIsInSidePanel = true; await seedReports(); const {result} = renderAskConcierge({forceConcierge: true}); expect(result.current.shouldShowAskConcierge).toBe(true); @@ -209,8 +221,10 @@ describe('useAskConcierge', () => { // When attachments are sent with a padded caption result.current.askConciergeWithAttachment(FILES, ' Here it is '); - // Then Concierge is opened and the attachments are added with the trimmed text - expect(mockOpenConciergeAnywhere).toHaveBeenCalledWith({forceConcierge: true}); + // Then Concierge is opened on the new thread and the attachments are added with the trimmed text + const conciergeThreadReportID = mockAddAttachmentWithComment.mock.calls.at(0)?.at(0)?.conciergeThreadReportID; + expect(conciergeThreadReportID).toEqual(expect.any(String)); + expect(mockOpenConciergeAnywhere).toHaveBeenCalledWith({forceConcierge: true, reportID: conciergeThreadReportID}); expect(mockAddAttachmentWithComment).toHaveBeenCalledWith( expect.objectContaining({ report: CONCIERGE_REPORT, @@ -220,7 +234,7 @@ describe('useAskConcierge', () => { currentUserAccountID: ACCOUNT_ID, timezone: {selected: TIMEZONE}, shouldPlaySound: true, - isInSidePanel: false, + isInSidePanel: true, delegateAccountID: DELEGATE_ACCOUNT_ID, conciergeReportID: CONCIERGE_REPORT_ID, }), @@ -265,7 +279,6 @@ describe('useAskConcierge', () => { it('shows the new thread in the side panel instead of sending the user to the Inbox', async () => { // Given web, where Concierge answers each question in its own thread mockIsInSidePanel = true; - mockIsBetaEnabled = true; await seedReports(); const {result} = renderAskConcierge({forceConcierge: true}); expect(result.current.shouldShowAskConcierge).toBe(true); @@ -284,7 +297,6 @@ describe('useAskConcierge', () => { it('sends the user to the thread on native, where there is no side panel', async () => { // Given native, where Concierge answers each question in its own thread - mockIsBetaEnabled = true; await seedReports(); const {result} = renderAskConcierge({forceConcierge: true}); expect(result.current.shouldShowAskConcierge).toBe(true); @@ -298,25 +310,9 @@ describe('useAskConcierge', () => { expect(mockOpenConciergeAnywhere).not.toHaveBeenCalled(); }); - it('opens the Concierge chat itself when the beta is off', async () => { - // Given web without the thread beta - mockIsInSidePanel = true; - await seedReports(); - const {result} = renderAskConcierge({forceConcierge: true}); - expect(result.current.shouldShowAskConcierge).toBe(true); - - // When a question is asked - result.current.askConcierge('Where is my expense?'); - - // Then no thread is created and the side panel falls back to the Concierge chat - expect(mockAddComment).toHaveBeenCalledWith(expect.objectContaining({conciergeThreadReportID: undefined})); - expect(mockOpenConciergeAnywhere).toHaveBeenCalledWith({forceConcierge: true, reportID: undefined}); - }); - it('shows the thread in the side panel for a single attachment', async () => { // Given web, where Concierge answers each question in its own thread mockIsInSidePanel = true; - mockIsBetaEnabled = true; await seedReports(); const {result} = renderAskConcierge({forceConcierge: true}); expect(result.current.shouldShowAskConcierge).toBe(true); @@ -330,10 +326,9 @@ describe('useAskConcierge', () => { expect(mockOpenConciergeAnywhere).toHaveBeenCalledWith({forceConcierge: true, reportID: conciergeThreadReportID}); }); - it('keeps several attachments in the Concierge chat', async () => { + it('shows the thread in the side panel for several attachments', async () => { // Given web, where Concierge answers each question in its own thread mockIsInSidePanel = true; - mockIsBetaEnabled = true; await seedReports(); const {result} = renderAskConcierge({forceConcierge: true}); expect(result.current.shouldShowAskConcierge).toBe(true); @@ -341,9 +336,10 @@ describe('useAskConcierge', () => { // When two attachments are sent at once result.current.askConciergeWithAttachment([...FILES, {name: 'second.jpg', type: 'image/jpeg', uri: 'file://second.jpg'}], 'Here they are'); - // Then no thread is created and the side panel stays on the Concierge chat - expect(mockAddAttachmentWithComment).toHaveBeenCalledWith(expect.objectContaining({conciergeThreadReportID: undefined})); - expect(mockOpenConciergeAnywhere).toHaveBeenCalledWith({forceConcierge: true, reportID: undefined}); + // Then the side panel shows the thread they were posted to + const conciergeThreadReportID = mockAddAttachmentWithComment.mock.calls.at(0)?.at(0)?.conciergeThreadReportID; + expect(conciergeThreadReportID).toEqual(expect.any(String)); + expect(mockOpenConciergeAnywhere).toHaveBeenCalledWith({forceConcierge: true, reportID: conciergeThreadReportID}); }); }); }); diff --git a/tests/unit/hooks/useConciergeAskState.test.tsx b/tests/unit/hooks/useConciergeAskState.test.tsx new file mode 100644 index 000000000000..0c8890878920 --- /dev/null +++ b/tests/unit/hooks/useConciergeAskState.test.tsx @@ -0,0 +1,161 @@ +import {renderHook} from '@testing-library/react-native'; + +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import useConciergeAskState from '@hooks/useConciergeAskState'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportAction} from '@src/types/onyx'; + +import type * as NativeNavigation from '@react-navigation/native'; + +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +const wrapper = ({children}: {children: React.ReactNode}) => {children}; + +const CONCIERGE_REPORT_ID = '1'; +const CURRENT_USER_ACCOUNT_ID = 100; + +let mockShowFullHistory = false; +let mockRouteParams: {reportActionID?: string} = {}; + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useRoute: () => ({key: 'Report-test', name: 'Report', params: mockRouteParams}), +})); + +jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ + __esModule: true, + default: jest.fn(() => ({accountID: 100})), +})); + +jest.mock('@pages/inbox/ConciergeChatHistoryContext', () => ({ + useConciergeChatHistoryState: () => ({showFullHistory: mockShowFullHistory}), +})); + +const createdAction: ReportAction = {reportActionID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, created: '2024-06-01 10:00:00.000'}; +const conciergeMessage: ReportAction = { + reportActionID: '2', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + actorAccountID: CONST.ACCOUNT_ID.CONCIERGE, + created: '2024-06-01 11:00:00.000', +}; +const userMessage: ReportAction = {reportActionID: '3', actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, actorAccountID: CURRENT_USER_ACCOUNT_ID, created: '2024-06-01 11:05:00.000'}; + +async function setReportActions(actions: ReportAction[]) { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, Object.fromEntries(actions.map((action) => [action.reportActionID, action]))); + await waitForBatchedUpdates(); +} + +describe('useConciergeAskState', () => { + beforeAll(async () => { + Onyx.init({keys: ONYXKEYS}); + await Onyx.set(ONYXKEYS.CONCIERGE_REPORT_ID, CONCIERGE_REPORT_ID); + await waitForBatchedUpdates(); + }); + + afterEach(async () => { + mockShowFullHistory = false; + mockRouteParams = {}; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, null); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${CONCIERGE_REPORT_ID}`, null); + await waitForBatchedUpdates(); + }); + + it('shows the welcome screen in place of the conversation', async () => { + // Given a chat with only Concierge's messages, such as onboarding on a new account + await setReportActions([createdAction, conciergeMessage]); + + // When the Concierge chat renders + const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); + + // Then the welcome screen hides the conversation behind `View chat history` + expect(result.current.shouldShowWelcome).toBe(true); + expect(result.current.isHistoryExpanded).toBe(false); + }); + + it('keeps the welcome screen when a question arrives from another device', async () => { + // Given the welcome screen is showing + await setReportActions([createdAction, conciergeMessage, userMessage]); + const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); + + // When a question sent from another device arrives + const newQuestion: ReportAction = {...userMessage, reportActionID: '4', created: '2024-06-01 12:00:00.000'}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, {[newQuestion.reportActionID]: newQuestion}); + await waitForBatchedUpdates(); + + // Then the welcome screen stays, so this device doesn't flicker + expect(result.current.shouldShowWelcome).toBe(true); + }); + + it('keeps the messages on screen while Concierge has an unread message', async () => { + // Given the user asked Concierge before and has not read Concierge's newest message + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${CONCIERGE_REPORT_ID}`, { + reportID: CONCIERGE_REPORT_ID, + lastReadTime: '2024-06-01 11:30:00.000', + lastVisibleActionCreated: '2024-06-01 12:00:00.000', + lastActorAccountID: CONST.ACCOUNT_ID.CONCIERGE, + }); + await setReportActions([createdAction, conciergeMessage, userMessage]); + + // When the Concierge chat renders + const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); + + // Then the unread message shows instead of the welcome screen + expect(result.current.shouldShowWelcome).toBe(false); + expect(result.current.hasUnreadMessageFromOthers).toBe(true); + }); + + it('keeps the messages on screen when a message failed to send', async () => { + // Given the user's newest message failed to send + const failedMessage: ReportAction = {...userMessage, reportActionID: '4', created: '2024-06-01 12:00:00.000', errors: {error: 'Failed to send'}}; + await setReportActions([createdAction, conciergeMessage, userMessage, failedMessage]); + + // When the Concierge chat renders + const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); + + // Then the failed message shows so the user can retry or dismiss it + expect(result.current.shouldShowWelcome).toBe(false); + }); + + it('shows the conversation once the user expands the history', async () => { + // Given the user pressed `View chat history` + mockShowFullHistory = true; + await setReportActions([createdAction, conciergeMessage, userMessage]); + + // When the Concierge chat renders + const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); + + // Then the conversation shows instead of the welcome screen + expect(result.current.shouldShowWelcome).toBe(false); + expect(result.current.isHistoryExpanded).toBe(true); + }); + + it('shows the conversation when opened from a link to a message', async () => { + // Given the user asked Concierge before + await setReportActions([createdAction, conciergeMessage, userMessage]); + + // When the Concierge chat opens from a link to Concierge's message + mockRouteParams = {reportActionID: conciergeMessage.reportActionID}; + const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); + + // Then the linked message shows instead of the welcome screen + expect(result.current.shouldShowWelcome).toBe(false); + expect(result.current.isHistoryExpanded).toBe(true); + }); + + it('stays off outside the Concierge report', () => { + // Given the history is expanded and a report that isn't the Concierge chat + mockShowFullHistory = true; + + // When that report renders + const {result} = renderHook(() => useConciergeAskState('999'), {wrapper}); + + // Then none of the Ask Concierge screen applies to it + expect(result.current.shouldShowWelcome).toBe(false); + expect(result.current.isHistoryExpanded).toBe(false); + }); +}); diff --git a/tests/unit/hooks/useConciergeSidePanelReportActions.test.ts b/tests/unit/hooks/useConciergeSidePanelReportActions.test.ts index e6f97c8848e6..e80dd1d529b1 100644 --- a/tests/unit/hooks/useConciergeSidePanelReportActions.test.ts +++ b/tests/unit/hooks/useConciergeSidePanelReportActions.test.ts @@ -60,7 +60,6 @@ function renderSidePanel(sessionStartTime: string, question: ReportAction, reply currentUserAccountID: CURRENT_USER_ACCOUNT_ID, greetingText: 'Hi there, how can I help?', loadOlderChats: jest.fn(), - isConciergeMainDM: false, }), ); } @@ -130,130 +129,7 @@ describe('useConciergeSidePanelReportActions (clock-skew reply visibility)', () }); }); -describe('useConciergeSidePanelReportActions (main DM open-task pinning)', () => { - const SESSION_START = toDBTime(CLIENT_OPEN_MS); - - /** Builds the parent action of a task, carrying the child* fields the backend stamps on it. */ - function buildTaskAction( - reportActionID: string, - stateNum: ReportAction['childStateNum'], - statusNum: ReportAction['childStatusNum'], - overrides: Partial = {}, - ): ReportAction { - return buildAction(reportActionID, { - actorAccountID: CONCIERGE_ACCOUNT_ID, - created: toDBTime(CLIENT_OPEN_MS - 3_400_000), - childType: CONST.REPORT.TYPE.TASK, - childReportID: `task-${reportActionID}`, - childManagerAccountID: CURRENT_USER_ACCOUNT_ID, - childStateNum: stateNum, - childStatusNum: statusNum, - ...overrides, - }); - } - - /** @param hasSessionActivity when false, nothing has happened yet this session — the welcome state. */ - function renderMainDM(taskAction: ReportAction, hasSessionActivity = true) { - const createdAction = buildAction('10', {actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, created: toDBTime(CLIENT_OPEN_MS - 7_200_000)}); - const preSessionUser = buildAction('11', {created: toDBTime(CLIENT_OPEN_MS - 3_600_000)}); - const preSessionConcierge = buildAction('12', {actorAccountID: CONCIERGE_ACCOUNT_ID, created: toDBTime(CLIENT_OPEN_MS - 3_500_000)}); - const inSessionUser = buildAction('20', {created: toDBTime(CLIENT_OPEN_MS + 1000)}); - const reportActions = [createdAction, preSessionUser, preSessionConcierge, taskAction, ...(hasSessionActivity ? [inSessionUser] : [])]; - - const report: Report = {...createRandomReport(Number(REPORT_ID)), reportID: REPORT_ID, lastReadTime: SESSION_START, hasOutstandingChildTask: true}; - - return renderHook(() => - useConciergeSidePanelReportActions({ - report, - reportActions, - visibleReportActions: reportActions, - isConciergeHiddenHistory: true, - hasUserSentMessage: hasSessionActivity, - hasOlderActions: false, - sessionStartTime: SESSION_START, - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - greetingText: 'Hi there, how can I help?', - loadOlderChats: jest.fn(), - isConciergeMainDM: true, - showFullHistory: false, - hadMessagesAtSessionStart: hasSessionActivity, - }), - ); - } - - it('keeps a still-open child task visible while the rest of the read history stays hidden', () => { - // Given a pre-session task that the user has not completed yet - const {result} = renderMainDM(buildTaskAction('30', CONST.REPORT.STATE_NUM.OPEN, CONST.REPORT.STATUS_NUM.OPEN)); - - // When the main DM filters the session's actions - const visibleIDs = result.current.filteredReportActions.map((action) => action.reportActionID); - - // Then the task stays pinned, the read history stays hidden, and "Show history" is still offered. - expect(visibleIDs).toContain('30'); - expect(visibleIDs).toContain('20'); - expect(visibleIDs).not.toContain('11'); - expect(visibleIDs).not.toContain('12'); - expect(result.current.showFullHistory).toBe(false); - expect(result.current.hasPreviousMessages).toBe(true); - }); - - it('keeps a still-open child task visible in the fresh-session welcome state', () => { - // Given the DM is opened with nothing sent yet — the welcome state, which used to drop the pinned task - const {result} = renderMainDM(buildTaskAction('30', CONST.REPORT.STATE_NUM.OPEN, CONST.REPORT.STATUS_NUM.OPEN), false); - - // When the main DM filters the session's actions - const visibleIDs = result.current.filteredReportActions.map((action) => action.reportActionID); - - // Then the welcome state stands down so the task renders, while the read history stays hidden. - expect(result.current.showConciergeSidePanelWelcome).toBe(false); - expect(visibleIDs).toContain('30'); - expect(visibleIDs).not.toContain('11'); - expect(visibleIDs).not.toContain('12'); - }); - - it('does not pin an open child task assigned to someone else', () => { - // Given an open task assigned to another account - const {result} = renderMainDM(buildTaskAction('30', CONST.REPORT.STATE_NUM.OPEN, CONST.REPORT.STATUS_NUM.OPEN, {childManagerAccountID: CONCIERGE_ACCOUNT_ID}), false); - - // When the main DM filters the session's actions - const visibleIDs = result.current.filteredReportActions.map((action) => action.reportActionID); - - // Then it is ordinary read history: not pinned, and the welcome state still applies. - expect(visibleIDs).not.toContain('30'); - expect(result.current.showConciergeSidePanelWelcome).toBe(true); - }); - - it('does not pin a canceled task whose parent action is still optimistically OPEN', () => { - // Given a canceled task, which `deleteTask` leaves at OPEN until the server responds - const canceledTask = buildTaskAction('30', CONST.REPORT.STATE_NUM.OPEN, CONST.REPORT.STATUS_NUM.OPEN, { - message: [{type: 'COMMENT', html: '', text: '', isDeletedParentAction: true}], - childVisibleActionCount: 1, - }); - const {result} = renderMainDM(canceledTask, false); - - // When the main DM filters the session's actions - const visibleIDs = result.current.filteredReportActions.map((action) => action.reportActionID); - - // Then the canceled task is not pinned and the welcome state still applies. - expect(visibleIDs).not.toContain('30'); - expect(result.current.showConciergeSidePanelWelcome).toBe(true); - }); - - it('hides a completed child task along with the rest of the read history', () => { - // Given a pre-session task that has already been completed - const {result} = renderMainDM(buildTaskAction('30', CONST.REPORT.STATE_NUM.APPROVED, CONST.REPORT.STATUS_NUM.APPROVED)); - - // When the main DM filters the session's actions - const visibleIDs = result.current.filteredReportActions.map((action) => action.reportActionID); - - // Then it is treated as ordinary read history and collapses behind "Show history". - expect(visibleIDs).not.toContain('30'); - expect(visibleIDs).not.toContain('11'); - expect(visibleIDs).toContain('20'); - }); -}); - -describe('useConciergeSidePanelReportActions (main DM sent-message retention)', () => { +describe('useConciergeSidePanelReportActions (sent-message retention)', () => { const SESSION_START = toDBTime(CLIENT_OPEN_MS); const createdAction = buildAction('10', {actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, created: toDBTime(CLIENT_OPEN_MS - 7_200_000)}); @@ -261,7 +137,7 @@ describe('useConciergeSidePanelReportActions (main DM sent-message retention)', const preSessionConcierge = buildAction('12', {actorAccountID: CONCIERGE_ACCOUNT_ID, created: toDBTime(CLIENT_OPEN_MS - 3_500_000)}); /** Mirrors how `useReportActionsVisibility` calls the hook: the side panel drives its own local state. */ - function buildProps(sentMessage: ReportAction, hasUserSentMessage: boolean, isConciergeMainDM: boolean) { + function buildProps(sentMessage: ReportAction, hasUserSentMessage: boolean) { const reportActions = [createdAction, preSessionUser, preSessionConcierge, sentMessage]; const report: Report = {...createRandomReport(Number(REPORT_ID)), reportID: REPORT_ID, lastReadTime: SESSION_START}; @@ -276,9 +152,6 @@ describe('useConciergeSidePanelReportActions (main DM sent-message retention)', currentUserAccountID: CURRENT_USER_ACCOUNT_ID, greetingText: 'Hi there, how can I help?', loadOlderChats: jest.fn(), - isConciergeMainDM, - showFullHistory: isConciergeMainDM ? false : undefined, - hadMessagesAtSessionStart: isConciergeMainDM ? false : undefined, }; } @@ -286,23 +159,20 @@ describe('useConciergeSidePanelReportActions (main DM sent-message retention)', const pendingMessage = () => buildAction('20', {created: toDBTime(CLIENT_OPEN_MS + 500), pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}); const restampedMessage = () => buildAction('20', {created: toDBTime(CLIENT_OPEN_MS - 3000), pendingAction: undefined}); - function renderSurface(isConciergeMainDM: boolean) { + function renderSurface() { const {result, rerender} = renderHook((props: Parameters[0]) => useConciergeSidePanelReportActions(props), { - initialProps: buildProps(pendingMessage(), true, isConciergeMainDM), + initialProps: buildProps(pendingMessage(), true), }); return { result, // The server response clears the pending flag and re-stamps `created`, which also flips `hasUserSentMessage`. - landServerResponse: () => rerender(buildProps(restampedMessage(), false, isConciergeMainDM)), + landServerResponse: () => rerender(buildProps(restampedMessage(), false)), }; } - it.each([ - ['main DM', true], - ['side panel', false], - ])('keeps the message the user just sent in the %s when the server stamps it below the session boundary', (_surface, isConciergeMainDM) => { + it('keeps the message the user just sent when the server stamps it below the session boundary', () => { // Given the user's message is still pending, so it is visible on the strength of its pending state - const {result, landServerResponse} = renderSurface(isConciergeMainDM); + const {result, landServerResponse} = renderSurface(); expect(result.current.filteredReportActions.map((action) => action.reportActionID)).toContain('20'); // When the server response lands and re-stamps `created` behind the boundary diff --git a/tests/unit/hooks/useShouldSuppressConciergeIndicators.test.ts b/tests/unit/hooks/useShouldSuppressConciergeIndicators.test.ts index 260e233fa049..694a29901ccd 100644 --- a/tests/unit/hooks/useShouldSuppressConciergeIndicators.test.ts +++ b/tests/unit/hooks/useShouldSuppressConciergeIndicators.test.ts @@ -1,23 +1,27 @@ import {renderHook, waitFor} from '@testing-library/react-native'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useIsInSidePanel from '@hooks/useIsInSidePanel'; import useShouldSuppressConciergeIndicators from '@hooks/useShouldSuppressConciergeIndicators'; - -import {useConciergeSessionState} from '@pages/inbox/ConciergeSessionContext'; +import useSidePanelState from '@hooks/useSidePanelState'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportAction} from '@src/types/onyx'; +// eslint-disable-next-line no-restricted-imports -- useSidePanelState types its refs as React Native Animated.Value, so the mock needs the same class. +import {Animated} from 'react-native'; import Onyx from 'react-native-onyx'; import createRandomReportAction from '../../utils/collections/reportActions'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; -jest.mock('@pages/inbox/ConciergeSessionContext'); jest.mock('@hooks/useCurrentUserPersonalDetails'); +jest.mock('@hooks/useIsInSidePanel'); +jest.mock('@hooks/useSidePanelState'); -const mockUseConciergeSessionState = jest.mocked(useConciergeSessionState); +const mockUseIsInSidePanel = jest.mocked(useIsInSidePanel); +const mockUseSidePanelState = jest.mocked(useSidePanelState); const mockUseCurrentUserPersonalDetails = jest.mocked(useCurrentUserPersonalDetails); const REPORT_ID = '1'; @@ -26,6 +30,18 @@ const CURRENT_USER_ACCOUNT_ID = 1; const OTHER_ACCOUNT_ID = 2; const SESSION_START_TIME = '2026-06-29 10:00:00.000'; +const sidePanelState: ReturnType = { + sessionStartTime: null, + isSidePanelTransitionEnded: true, + isSidePanelHiddenOrLargeScreen: true, + shouldHideSidePanel: true, + shouldHideSidePanelBackdrop: true, + shouldHideHelpButton: false, + shouldHideToolTip: false, + sidePanelOffset: {current: new Animated.Value(0)}, + sidePanelTranslateX: {current: new Animated.Value(0)}, +}; + function buildAction(overrides: Partial): ReportAction { return { ...createRandomReportAction(1), @@ -47,8 +63,9 @@ describe('useShouldSuppressConciergeIndicators', () => { beforeEach(async () => { await Onyx.clear(); await waitForBatchedUpdates(); - // Safe defaults: no active session, so the existing followup-list tests behave as before. - mockUseConciergeSessionState.mockReturnValue({sessionStartTime: null, showFullHistory: false, hadMessagesAtSessionStart: false}); + // Safe defaults: outside the side panel with no active session, so the followup-list tests only exercise the flag. + mockUseIsInSidePanel.mockReturnValue(false); + mockUseSidePanelState.mockReturnValue(sidePanelState); mockUseCurrentUserPersonalDetails.mockReturnValue({accountID: CURRENT_USER_ACCOUNT_ID}); }); @@ -121,10 +138,28 @@ describe('useShouldSuppressConciergeIndicators', () => { }); }); - describe('session activity (Concierge welcome state)', () => { + it('suppresses indicators in the main Concierge chat even with messages in it', async () => { + // Given the main Concierge chat with a Concierge reply in it + await Onyx.set(ONYXKEYS.CONCIERGE_REPORT_ID, REPORT_ID); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, { + [REPORT_ACTION_ID]: buildAction({actorAccountID: OTHER_ACCOUNT_ID, created: '2026-06-29 10:05:00.000'}), + }); + await waitForBatchedUpdates(); + + // When the hook evaluates suppression outside the side panel + const {result} = renderHook(() => useShouldSuppressConciergeIndicators(REPORT_ID)); + + // Then the indicators stay hidden, because Concierge answers each question in its own thread + await waitFor(() => { + expect(result.current).toBe(true); + }); + }); + + describe('side panel session activity (Concierge welcome state)', () => { beforeEach(() => { - // An active session for the main Concierge DM. - mockUseConciergeSessionState.mockReturnValue({sessionStartTime: SESSION_START_TIME, showFullHistory: false, hadMessagesAtSessionStart: false}); + // An active side panel session. + mockUseIsInSidePanel.mockReturnValue(true); + mockUseSidePanelState.mockReturnValue({...sidePanelState, sessionStartTime: SESSION_START_TIME}); }); it('suppresses indicators in a Concierge chat with no session activity', async () => { diff --git a/tests/unit/libs/prepareRequestPayloadNativeTest.ts b/tests/unit/libs/prepareRequestPayloadNativeTest.ts index e49d02fbc8d7..821591859402 100644 --- a/tests/unit/libs/prepareRequestPayloadNativeTest.ts +++ b/tests/unit/libs/prepareRequestPayloadNativeTest.ts @@ -194,6 +194,24 @@ describe('prepareRequestPayload (native)', () => { expect(formData.has('undefinedField')).toBe(false); }); + it('should read every attachment of a message sent offline', async () => { + // Given a message with two attachments queued while offline + mockReadFileAsync.mockImplementation((_source, fileName) => Promise.resolve(new File([], String(fileName)))); + const firstAttachment = {source: 'file:///tmp/first.jpg', uri: 'file:///tmp/first.jpg', name: 'first.jpg', type: 'image/jpeg'}; + const secondAttachment = {source: 'file:///tmp/second.jpg', uri: 'file:///tmp/second.jpg', name: 'second.jpg', type: 'image/jpeg'}; + + // When the queued request is sent + // eslint-disable-next-line @typescript-eslint/naming-convention -- `file_1` is the API parameter name for the second attachment. + const formData = await prepareRequestPayload('AddAttachment', {file: firstAttachment, file_1: secondAttachment}, true); + + // Then both attachments are read from disk and sent + expect(mockReadFileAsync).toHaveBeenCalledWith(firstAttachment.source, 'first.jpg', expect.any(Function), undefined, 'image/jpeg'); + expect(mockReadFileAsync).toHaveBeenCalledWith(secondAttachment.source, 'second.jpg', expect.any(Function), undefined, 'image/jpeg'); + expect(formData.has('file')).toBe(true); + expect(formData.has('file_1')).toBe(true); + mockReadFileAsync.mockImplementation(() => Promise.resolve(null)); + }); + describe('a receipt sent as a file object without a source, as a request queued by an older app version sends it', () => { /** `clearAllMocks` keeps a `mockReturnValue`, so a deferred one would leak into the next test. */ beforeEach(() => {