Skip to content

Commit 73665dd

Browse files
authored
Merge pull request #90363 from gijoe0295/App-89686
Add suggested follow-up skeleton
2 parents fd0ee33 + 810e995 commit 73665dd

15 files changed

Lines changed: 582 additions & 9 deletions

src/ONYXKEYS.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,7 @@ const ONYXKEYS = {
778778
REPORT_IS_COMPOSER_FULL_SIZE: 'reportIsComposerFullSize_',
779779
REPORT_USER_IS_TYPING: 'reportUserIsTyping_',
780780
PENDING_CONCIERGE_RESPONSE: 'pendingConciergeResponse_',
781+
CONCIERGE_PENDING_FOLLOWUP_LIST: 'conciergePendingFollowupList_',
781782
REPORT_USER_IS_LEAVING_ROOM: 'reportUserIsLeavingRoom_',
782783
SECURITY_GROUP: 'securityGroup_',
783784
TRANSACTION: 'transactions_',
@@ -1344,6 +1345,7 @@ type OnyxCollectionValuesMapping = {
13441345
[ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE]: boolean;
13451346
[ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING]: OnyxTypes.ReportUserIsTyping;
13461347
[ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE]: OnyxTypes.PendingConciergeResponse;
1348+
[ONYXKEYS.COLLECTION.CONCIERGE_PENDING_FOLLOWUP_LIST]: OnyxTypes.ConciergePendingFollowupList;
13471349
[ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM]: boolean;
13481350
[ONYXKEYS.COLLECTION.SECURITY_GROUP]: OnyxTypes.SecurityGroup;
13491351
[ONYXKEYS.COLLECTION.TRANSACTION]: OnyxTypes.Transaction;
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import React from 'react';
2+
import {View} from 'react-native';
3+
import Animated, {FadeIn} from 'react-native-reanimated';
4+
import SkeletonRect from '@components/SkeletonRect';
5+
import SkeletonViewContentLoader from '@components/SkeletonViewContentLoader';
6+
import useTheme from '@hooks/useTheme';
7+
import useThemeStyles from '@hooks/useThemeStyles';
8+
import useSkeletonSpan from '@libs/telemetry/useSkeletonSpan';
9+
10+
const BAR_HEIGHT = 40;
11+
const BAR_PADDING = 16;
12+
const BAR_WIDTH = 180;
13+
const BAR_COUNT = 3;
14+
15+
function ActionableItemSkeleton() {
16+
const styles = useThemeStyles();
17+
const theme = useTheme();
18+
19+
return (
20+
<View style={styles.actionableItemButtonSkeleton}>
21+
<SkeletonViewContentLoader
22+
height={BAR_HEIGHT}
23+
width={BAR_WIDTH + BAR_PADDING * 2}
24+
backgroundColor={theme.buttonHoveredBG}
25+
foregroundColor={theme.skeletonLHNOut}
26+
>
27+
<SkeletonRect
28+
transform={[{translateX: BAR_PADDING}, {translateY: BAR_PADDING}]}
29+
width={BAR_WIDTH}
30+
height="8"
31+
/>
32+
</SkeletonViewContentLoader>
33+
</View>
34+
);
35+
}
36+
37+
function FollowupListSkeleton() {
38+
const styles = useThemeStyles();
39+
useSkeletonSpan('FollowupListSkeleton', {context: 'ReportScreen.ChatActionableButtons'});
40+
41+
return (
42+
<Animated.View entering={FadeIn}>
43+
<View style={[styles.gap2, styles.mt2, styles.flexColumn, styles.alignItemsStart]}>
44+
{Array.from({length: BAR_COUNT}, (_, index) => (
45+
<ActionableItemSkeleton key={index} />
46+
))}
47+
</View>
48+
</Animated.View>
49+
);
50+
}
51+
52+
export default FollowupListSkeleton;

src/hooks/usePendingConciergeResponse.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
import {useEffect, useRef} from 'react';
22
import type {OnyxEntry} from 'react-native-onyx';
3-
import {applyPendingConciergeAction, discardPendingConciergeAction} from '@libs/actions/Report/SuggestedFollowup';
3+
import {clearAgentZeroProcessingIndicator} from '@libs/actions/Report';
4+
import {applyPendingConciergeAction, clearPendingFollowupList, discardPendingConciergeAction, hidePendingFollowupList} from '@libs/actions/Report/SuggestedFollowup';
5+
import AgentZeroOptimisticStore, {MAX_AGE_MS} from '@libs/AgentZeroOptimisticStore';
46
import Log from '@libs/Log';
57
import {rand64} from '@libs/NumberUtils';
68
import type {ConciergeDraftEvent} from '@libs/Pusher/types';
9+
import {parseFollowupsFromHtml} from '@libs/ReportActionFollowupUtils';
710
import tokenizeForReveal from '@libs/ReportActionFollowupUtils/tokenizeForReveal';
811
import {getReportActionHtml} from '@libs/ReportActionsUtils';
912
import {useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext';
1013
import ONYXKEYS from '@src/ONYXKEYS';
1114
import type {ReportAction, ReportActions} from '@src/types/onyx';
15+
import useNetwork from './useNetwork';
1216
import useOnyx from './useOnyx';
1317

1418
/** Default trickle duration. Targets ~19 chars/sec start (~7/sec end after ease-out) across a typical multi-paragraph response — visibly streaming without dragging the user past the moment they want to read. */
@@ -21,6 +25,8 @@ const TRICKLE_HARD_CAP_MS = 60_000;
2125
const ACCELERATED_REMAINING_MS = 1_500;
2226
/** Minimum char-level anchors before we opt into the trickle reveal. Replies under this fall back to the binary reveal at `displayAfter`. */
2327
const MIN_TRICKLE_TOKEN_COUNT = 100;
28+
/** Hard cap on a pending followup-list skeleton. If the server never appends a real followup-list within this window, drop the marker so the UI stops showing a perpetual skeleton. */
29+
const PENDING_FOLLOWUP_LIST_HARD_CAP_MS = MAX_AGE_MS;
2430

2531
function easeOut(t: number): number {
2632
const clamped = Math.max(0, Math.min(1, t));
@@ -32,13 +38,19 @@ function easeOut(t: number): number {
3238
* the binary reveal at `displayAfter`. `REPORT_ACTIONS` is written at completion.
3339
*/
3440
function usePendingConciergeResponse(reportID: string | undefined) {
41+
const {isOffline} = useNetwork();
3542
const [pendingResponse] = useOnyx(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${reportID}`);
43+
const [pendingFollowupList] = useOnyx(`${ONYXKEYS.COLLECTION.CONCIERGE_PENDING_FOLLOWUP_LIST}${reportID}`);
3644
const reportActionID = pendingResponse?.reportAction?.reportActionID;
3745
const fullHtml = pendingResponse?.reportAction ? getReportActionHtml(pendingResponse.reportAction) : '';
3846
// React Compiler auto-memoizes the selector closure and the tokenize result;
3947
// explicit useCallback/useMemo would just shadow the compiler's analysis.
4048
const persistedActionSelector = (actions: OnyxEntry<ReportActions>): ReportAction | undefined => (reportActionID && actions ? actions[reportActionID] : undefined);
4149
const [persistedAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {selector: persistedActionSelector});
50+
const pendingFollowupActionID = pendingFollowupList?.reportActionID;
51+
const pendingFollowupActionSelector = (actions: OnyxEntry<ReportActions>): ReportAction | undefined =>
52+
pendingFollowupActionID && actions ? actions[pendingFollowupActionID] : undefined;
53+
const [pendingFollowupAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {selector: pendingFollowupActionSelector});
4254
const {dispatchLocalDraftEvent} = useConciergeDraftActions();
4355

4456
const tokens = tokenizeForReveal(fullHtml);
@@ -65,6 +77,58 @@ function usePendingConciergeResponse(reportID: string | undefined) {
6577
accelerateRef.current(Date.now());
6678
}, [persistedAction]);
6779

80+
const lastOnlineTransitionAtRef = useRef<number>(0);
81+
const wasOfflineRef = useRef<boolean>(isOffline);
82+
useEffect(() => {
83+
if (wasOfflineRef.current && !isOffline) {
84+
lastOnlineTransitionAtRef.current = Date.now();
85+
}
86+
wasOfflineRef.current = isOffline;
87+
}, [isOffline]);
88+
89+
// Hide the followup-list skeleton when the user is offline.
90+
useEffect(() => {
91+
if (!reportID || !pendingFollowupList || !!pendingFollowupList.hidden === isOffline) {
92+
return;
93+
}
94+
hidePendingFollowupList(reportID, isOffline || null);
95+
}, [reportID, isOffline, pendingFollowupList]);
96+
97+
// Clear the pending followup-list skeleton flag as soon as the server reply
98+
// (with <followup-list>) overwrites the optimistic action.
99+
// A TTL fallback guards against the case where no followup-list ever arrives
100+
// so the skeleton won't get stuck.
101+
useEffect(() => {
102+
if (!reportID || !pendingFollowupList) {
103+
return;
104+
}
105+
const html = pendingFollowupAction ? getReportActionHtml(pendingFollowupAction) : '';
106+
const hardClearIndicator = () => {
107+
// Skip clearing agent thinking indicator when a newer agent request has kicked off.
108+
const optimisticEntry = AgentZeroOptimisticStore.getEntry(reportID);
109+
const hasNewerRequest = !!optimisticEntry && optimisticEntry.startedAt > pendingFollowupList.createdAt;
110+
if (!hasNewerRequest) {
111+
clearAgentZeroProcessingIndicator(reportID);
112+
}
113+
clearPendingFollowupList(reportID);
114+
};
115+
if (parseFollowupsFromHtml(html)?.length) {
116+
hardClearIndicator();
117+
return;
118+
}
119+
if (isOffline) {
120+
return;
121+
}
122+
const effectiveStart = Math.max(pendingFollowupList.createdAt, lastOnlineTransitionAtRef.current);
123+
const remainingTTL = effectiveStart + PENDING_FOLLOWUP_LIST_HARD_CAP_MS - Date.now();
124+
if (remainingTTL <= 0) {
125+
hardClearIndicator();
126+
return;
127+
}
128+
const ttlTimer = setTimeout(hardClearIndicator, remainingTTL);
129+
return () => clearTimeout(ttlTimer);
130+
}, [reportID, pendingFollowupList, pendingFollowupAction, isOffline]);
131+
68132
useEffect(() => {
69133
if (!reportID || !reportActionID) {
70134
return;

src/hooks/useShouldSuppressConciergeIndicators.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,18 @@ import useOnyx from './useOnyx';
88
import useSidePanelState from './useSidePanelState';
99

1010
/**
11-
* Returns true when thinking/typing indicators should be hidden in the side-panel
12-
* welcome state — specifically for Concierge DMs before the user sends their first message.
11+
* Returns true when thinking/typing indicators should be hidden. Two cases:
12+
* 1. The side-panel welcome state — specifically Concierge DMs before the
13+
* user sends their first message.
14+
* 2. The followup-list pending window — between trickle completion and the
15+
* server reply with `<followup-list>`.
1316
*/
1417
function useShouldSuppressConciergeIndicators(reportID: string | undefined): boolean {
1518
const isInSidePanel = useIsInSidePanel();
1619
const {sessionStartTime} = useSidePanelState();
1720
const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails();
1821
const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID);
22+
const [pendingFollowupList] = useOnyx(`${ONYXKEYS.COLLECTION.CONCIERGE_PENDING_FOLLOWUP_LIST}${reportID}`);
1923

2024
const isConciergeChat = reportID === conciergeReportID;
2125

@@ -29,6 +33,10 @@ function useShouldSuppressConciergeIndicators(reportID: string | undefined): boo
2933
selector: hasUserSentMessageSelector,
3034
});
3135

36+
if (pendingFollowupList) {
37+
return true;
38+
}
39+
3240
return isConciergeChat && isInSidePanel && !hasUserSentMessage;
3341
}
3442

src/libs/actions/Report/SuggestedFollowup.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,30 @@ function addOptimisticConciergeActionWithDelay(reportID: string, optimisticConci
128128
* Called when the response has been pending too long (e.g. app was killed and restarted).
129129
*/
130130
function discardPendingConciergeAction(reportID: string | undefined) {
131-
Onyx.set(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${reportID}`, null);
131+
Onyx.multiSet({
132+
[`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${reportID}`]: null,
133+
[`${ONYXKEYS.COLLECTION.CONCIERGE_PENDING_FOLLOWUP_LIST}${reportID}`]: null,
134+
});
135+
}
136+
137+
/**
138+
* Clears the pending followup-list marker for a report so the skeleton stops rendering.
139+
*/
140+
function clearPendingFollowupList(reportID: string | undefined) {
141+
if (!reportID) {
142+
return;
143+
}
144+
Onyx.set(`${ONYXKEYS.COLLECTION.CONCIERGE_PENDING_FOLLOWUP_LIST}${reportID}`, null);
145+
}
146+
147+
/**
148+
* Temporarily hides the pending followup-list skeleton.
149+
*/
150+
function hidePendingFollowupList(reportID: string | undefined, hidden: boolean | null) {
151+
if (!reportID) {
152+
return;
153+
}
154+
Onyx.merge(`${ONYXKEYS.COLLECTION.CONCIERGE_PENDING_FOLLOWUP_LIST}${reportID}`, {hidden});
132155
}
133156

134157
/**
@@ -147,7 +170,12 @@ function applyPendingConciergeAction(reportID: string | undefined, reportAction:
147170
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
148171
value: {[reportAction.reportActionID]: reportAction},
149172
},
173+
{
174+
onyxMethod: Onyx.METHOD.SET,
175+
key: `${ONYXKEYS.COLLECTION.CONCIERGE_PENDING_FOLLOWUP_LIST}${reportID}`,
176+
value: {reportActionID: reportAction.reportActionID, createdAt: Date.now()},
177+
},
150178
]);
151179
}
152180

153-
export {resolveSuggestedFollowup, discardPendingConciergeAction, applyPendingConciergeAction, CONCIERGE_RESPONSE_DELAY_MS};
181+
export {resolveSuggestedFollowup, discardPendingConciergeAction, applyPendingConciergeAction, clearPendingFollowupList, hidePendingFollowupList, CONCIERGE_RESPONSE_DELAY_MS};

src/pages/inbox/report/actionContents/ChatActionableButtons.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft';
22
import React from 'react';
33
import type {ActionableItem} from '@components/ReportActionItem/ActionableItemButtons';
44
import ActionableItemButtons from '@components/ReportActionItem/ActionableItemButtons';
5+
import FollowupListSkeleton from '@components/ReportActionItem/FollowupListSkeleton';
56
import useActivePolicy from '@hooks/useActivePolicy';
67
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
78
import useDelegateAccountID from '@hooks/useDelegateAccountID';
@@ -37,9 +38,10 @@ type ChatActionableButtonsProps = {
3738
action: OnyxTypes.ReportAction;
3839
originalReportID: string | undefined;
3940
reportID: string | undefined;
41+
hasPendingFollowupListSkeleton: boolean;
4042
};
4143

42-
function ChatActionableButtons({action, originalReportID, reportID}: ChatActionableButtonsProps) {
44+
function ChatActionableButtons({action, originalReportID, reportID, hasPendingFollowupListSkeleton}: ChatActionableButtonsProps) {
4345
const styles = useThemeStyles();
4446
const actionOwnerReportID = originalReportID ?? reportID;
4547
const [originalReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(originalReportID)}`);
@@ -208,6 +210,9 @@ function ChatActionableButtons({action, originalReportID, reportID}: ChatActiona
208210
})();
209211

210212
if (actionableItemButtons.length === 0) {
213+
if (hasPendingFollowupListSkeleton) {
214+
return <FollowupListSkeleton />;
215+
}
211216
return null;
212217
}
213218

src/pages/inbox/report/actionContents/ChatMessageContent.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import {hasPendingFollowupListSkeletonSelector} from '@selectors/AgentZeroChat';
12
import React from 'react';
23
import {View} from 'react-native';
34
import {AttachmentContext} from '@components/AttachmentContext';
45
import Button from '@components/Button';
56
import MentionReportContext from '@components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext';
67
import Text from '@components/Text';
78
import useLocalize from '@hooks/useLocalize';
9+
import useOnyx from '@hooks/useOnyx';
810
import useResponsiveLayout from '@hooks/useResponsiveLayout';
911
import useThemeStyles from '@hooks/useThemeStyles';
1012
import {parseFollowupsFromHtml} from '@libs/ReportActionFollowupUtils';
@@ -19,6 +21,7 @@ import {
1921
import ReportActionItemMessage from '@pages/inbox/report/ReportActionItemMessage';
2022
import ReportActionItemMessageEdit from '@pages/inbox/report/ReportActionItemMessageEdit';
2123
import CONST from '@src/CONST';
24+
import ONYXKEYS from '@src/ONYXKEYS';
2225
import type * as OnyxTypes from '@src/types/onyx';
2326
import ChatActionableButtons from './ChatActionableButtons';
2427

@@ -46,13 +49,18 @@ function ChatMessageContent({action, policyID, reportID, originalReportID, displ
4649

4750
const {hasBeenFlagged} = getModerationFlagState(action);
4851

52+
const [hasPendingFollowupListSkeleton = false] = useOnyx(`${ONYXKEYS.COLLECTION.CONCIERGE_PENDING_FOLLOWUP_LIST}${reportID}`, {
53+
selector: hasPendingFollowupListSkeletonSelector(action.reportActionID),
54+
});
55+
4956
const messageHtml = getReportActionMessage(action)?.html;
5057
const mayHaveActionableButtons =
5158
isActionableAddPaymentCard(action) ||
5259
isConciergeCategoryOptions(action) ||
5360
isConciergeDescriptionOptions(action) ||
5461
isActionableTrackExpense(action) ||
55-
!!(messageHtml && parseFollowupsFromHtml(messageHtml)?.length);
62+
!!(messageHtml && parseFollowupsFromHtml(messageHtml)?.length) ||
63+
hasPendingFollowupListSkeleton;
5664

5765
return (
5866
<MentionReportContext.Provider value={mentionReportContextValue}>
@@ -92,6 +100,7 @@ function ChatMessageContent({action, policyID, reportID, originalReportID, displ
92100
action={action}
93101
originalReportID={originalReportID}
94102
reportID={reportID}
103+
hasPendingFollowupListSkeleton={hasPendingFollowupListSkeleton}
95104
/>
96105
)}
97106
</View>

src/selectors/AgentZeroChat.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
22
import ONYXKEYS from '@src/ONYXKEYS';
3-
import type {AgentPrompt} from '@src/types/onyx';
3+
import type {AgentPrompt, ConciergePendingFollowupList} from '@src/types/onyx';
44
import type Report from '@src/types/onyx/Report';
55

66
const getReportParticipantAccountIDs = (report: OnyxEntry<Report>): number[] => (report?.participants ? Object.keys(report.participants).map(Number) : []);
@@ -23,4 +23,9 @@ const getAgentAccountIDFlags = (agentPrompts: OnyxCollection<AgentPrompt>): Reco
2323
return flags;
2424
};
2525

26-
export {getReportParticipantAccountIDs, getAgentAccountIDFlags};
26+
const hasPendingFollowupListSkeletonSelector =
27+
(reportActionID: string) =>
28+
(pending: OnyxEntry<ConciergePendingFollowupList>): boolean =>
29+
!pending?.hidden && pending?.reportActionID === reportActionID;
30+
31+
export {getReportParticipantAccountIDs, getAgentAccountIDFlags, hasPendingFollowupListSkeletonSelector};

src/styles/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -972,6 +972,12 @@ const staticStyles = (theme: ThemeColors) =>
972972
...wordBreak.breakWord,
973973
},
974974

975+
actionableItemButtonSkeleton: {
976+
alignItems: 'flex-start',
977+
borderRadius: 20,
978+
backgroundColor: theme.buttonDefaultBG,
979+
},
980+
975981
hoveredComponentBG: {
976982
backgroundColor: theme.hoverComponentBG,
977983
},
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/** Marks a Concierge report action whose followup list is still pending arrival from the server */
2+
type ConciergePendingFollowupList = {
3+
/** The reportActionID of the optimistic Concierge action awaiting its followup list */
4+
reportActionID: string;
5+
6+
/** Timestamp (ms) when the pending flag was created, used for TTL cleanup */
7+
createdAt: number;
8+
9+
/** Whether the skeleton should be visually hidden (e.g., user is offline) */
10+
hidden?: boolean;
11+
};
12+
13+
export default ConciergePendingFollowupList;

0 commit comments

Comments
 (0)