diff --git a/cspell.json b/cspell.json index e7d0d02593dc..572cc6e74e02 100644 --- a/cspell.json +++ b/cspell.json @@ -191,6 +191,7 @@ "Dishoom", "displaystatus", "DocuSign", + "domelementtype", "domhandler", "domparser", "dont", diff --git a/src/hooks/usePendingConciergeResponse.ts b/src/hooks/usePendingConciergeResponse.ts index 2fc5b1e2147c..daf820b607ec 100644 --- a/src/hooks/usePendingConciergeResponse.ts +++ b/src/hooks/usePendingConciergeResponse.ts @@ -1,42 +1,229 @@ -import {useEffect} from 'react'; +import {useEffect, useRef} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; import {applyPendingConciergeAction, discardPendingConciergeAction} from '@libs/actions/Report/SuggestedFollowup'; +import Log from '@libs/Log'; +import {rand64} from '@libs/NumberUtils'; +import type {ConciergeDraftEvent} from '@libs/Pusher/types'; +import tokenizeForReveal from '@libs/ReportActionFollowupUtils/tokenizeForReveal'; +import {getReportActionHtml} from '@libs/ReportActionsUtils'; +import {useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportAction, ReportActions} from '@src/types/onyx'; import useOnyx from './useOnyx'; -/** If displayAfter is more than this far in the past, the response is stale (e.g. app was killed and restarted) */ -const STALE_THRESHOLD_MS = 10_000; +/** 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. */ +const DEFAULT_STREAM_DURATION_MS = 15_000; +/** Trickle tick cadence. 80ms targets ~1 char per tick at char-level granularity — fast enough that the reveal feels continuous, slow enough that the synthetic-bubble re-render budget stays comfortable on RNW (~12 dispatches/sec). */ +const TICK_INTERVAL_MS = 80; +/** Hard cap on a running trickle and staleness gate on revisit. Past this many ms after `displayAfter`, the canonical reportComment is expected to be in REPORT_ACTIONS already, so we discard the optimistic rather than resume a doomed reveal. */ +const TRICKLE_HARD_CAP_MS = 60_000; +/** Once the real reportComment lands in REPORT_ACTIONS, finish the remaining reveal within this window. */ +const ACCELERATED_REMAINING_MS = 1_500; +/** Minimum char-level anchors before we opt into the trickle reveal. Replies under this fall back to the binary reveal at `displayAfter`. */ +const MIN_TRICKLE_TOKEN_COUNT = 100; + +function easeOut(t: number): number { + const clamped = Math.max(0, Math.min(1, t)); + return 1 - (1 - clamped) ** 2; +} /** - * Processes pending concierge responses stored in Onyx for a given report. - * When a pending response exists, schedules the action to be moved to REPORT_ACTIONS - * after the remaining delay, with automatic cleanup on unmount via useEffect. + * Long Concierge replies trickle into `ConciergeDraftContext`; short ones keep + * the binary reveal at `displayAfter`. `REPORT_ACTIONS` is written at completion. */ function usePendingConciergeResponse(reportID: string | undefined) { const [pendingResponse] = useOnyx(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${reportID}`); + const reportActionID = pendingResponse?.reportAction?.reportActionID; + const fullHtml = pendingResponse?.reportAction ? getReportActionHtml(pendingResponse.reportAction) : ''; + // React Compiler auto-memoizes the selector closure and the tokenize result; + // explicit useCallback/useMemo would just shadow the compiler's analysis. + const persistedActionSelector = (actions: OnyxEntry): ReportAction | undefined => (reportActionID && actions ? actions[reportActionID] : undefined); + const [persistedAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {selector: persistedActionSelector}); + const {dispatchLocalDraftEvent} = useConciergeDraftActions(); + + const tokens = tokenizeForReveal(fullHtml); + const accelerateRef = useRef<((nowMs: number) => void) | null>(null); + + // Captured into a ref so the trickle effect can re-run only on the IDs that + // identify a distinct Concierge reply. Composer typing, unrelated Onyx emits, + // and ConciergeDraftActions context refreshes all produce reference churn for + // pendingResponse/tokens/fullHtml — without this snapshot, those non-content + // updates would cancel the running interval and restart the reveal. The + // useEffect keeps ref writes in the commit phase (React-Compiler-safe). + const trickleInputsRef = useRef({pendingResponse, fullHtml, tokens, dispatchLocalDraftEvent, persistedAction}); + useEffect(() => { + trickleInputsRef.current = {pendingResponse, fullHtml, tokens, dispatchLocalDraftEvent, persistedAction}; + }); + // Reconciliation: when the canonical reportComment lands in REPORT_ACTIONS + // mid-trickle, fire the running loop's accelerator so the remaining reveal + // finishes in ~1.5s instead of snapping the synthetic bubble closed. useEffect(() => { - if (!pendingResponse) { + if (!persistedAction || !accelerateRef.current) { return; } + accelerateRef.current(Date.now()); + }, [persistedAction]); - const remaining = pendingResponse.displayAfter - Date.now(); + useEffect(() => { + if (!reportID || !reportActionID) { + return; + } + // Snapshot inputs at effect start. The trickle commits to the content it had + // when it began; subsequent updates that share this same reportActionID don't + // disturb the in-progress reveal. A genuinely new Concierge reply produces a + // new reportActionID and re-enters this effect via the deps below. + const {pendingResponse: snapshot, fullHtml: snapshotHtml, tokens: snapshotTokens} = trickleInputsRef.current; + if (!snapshot) { + return; + } + const {reportAction, displayAfter} = snapshot; + const remainingDelay = displayAfter - Date.now(); - // If the pending response is stale (e.g. app was killed/restarted), discard it - // instead of displaying a phantom message that was never confirmed by the server. - if (remaining < -STALE_THRESHOLD_MS) { + // Past the hard cap from displayAfter, the server-side canonical reply + // is expected to be in REPORT_ACTIONS already. Skip the trickle. + if (remainingDelay < -TRICKLE_HARD_CAP_MS) { discardPendingConciergeAction(reportID); return; } - const timer = setTimeout( - () => { - applyPendingConciergeAction(reportID, pendingResponse.reportAction); - }, - Math.max(0, remaining), - ); + // Anchors are character-level. Short replies (~50–100 chars) keep the + // binary reveal; longer ones (paragraphs / lists) cross the threshold + // and get the smooth trickle. + const shouldTrickle = snapshotTokens.length >= MIN_TRICKLE_TOKEN_COUNT && !!snapshotHtml; + if (!shouldTrickle) { + const timer = setTimeout(() => applyPendingConciergeAction(reportID, reportAction), Math.max(0, remainingDelay)); + return () => clearTimeout(timer); + } + + const session = rand64(); + let sequence = 0; + let intervalID: ReturnType | null = null; + let trickleStart = 0; + let effectiveDuration = DEFAULT_STREAM_DURATION_MS; + let lastStage = 0; + let cancelled = false; + // Snapshot of trickle progress at the moment the canonical reportComment + // arrives. Presence (`arrival !== undefined`) doubles as the + // "acceleration fired" check that selects the completion reason below. + let arrival: {progress: number; elapsedMs: number} | undefined; + + const dispatch = (status: ConciergeDraftEvent['status'], finalRenderedHTML: string) => { + if (cancelled) { + return; + } + sequence += 1; + // Read dispatch fn from the ref so a context-provider refresh doesn't pin + // the trickle to a stale handler. The ref always points at the latest. + trickleInputsRef.current.dispatchLocalDraftEvent({ + reportID, + reportActionID, + streamSessionID: session, + sequence, + status, + created: reportAction.created, + finalRenderedHTML, + }); + }; + + const completeAndApply = () => { + if (intervalID) { + clearInterval(intervalID); + intervalID = null; + } + const totalElapsedMs = trickleStart === 0 ? 0 : Date.now() - trickleStart; + let reason: 'natural' | 'accelerated' | 'stale_cap' = 'natural'; + if (arrival) { + reason = 'accelerated'; + } else if (totalElapsedMs >= TRICKLE_HARD_CAP_MS) { + reason = 'stale_cap'; + } + Log.info('[ConciergeTrickle] complete', false, { + reportActionID, + reason, + tokenCount: snapshotTokens.length, + durationMs: effectiveDuration, + totalElapsedMs, + arrivedAtProgress: arrival?.progress, + arrivedAtElapsedMs: arrival?.elapsedMs, + }); + dispatch('completed', snapshotTokens.at(-1) ?? snapshotHtml); + // Don't reapply our older optimistic when the canonical is already there — + // it would clobber server-added markup (follow-up buttons, deep-link + // Pressables). `arrival` covers the accelerator path; the live ref read + // catches arrivals during the pre-trickle setTimeout where the accelerator + // no-ops on null intervalID. + if (arrival || trickleInputsRef.current.persistedAction) { + discardPendingConciergeAction(reportID); + } else { + applyPendingConciergeAction(reportID, reportAction); + } + }; + + accelerateRef.current = (nowMs: number) => { + if (!intervalID || trickleStart === 0) { + return; + } + const elapsed = nowMs - trickleStart; + // Compressing effectiveDuration is what makes progress hit 1 within + // ACCELERATED_REMAINING_MS — the next tick observes progress >= 1 + // and runs completeAndApply via the normal path. + arrival = {progress: easeOut(elapsed / effectiveDuration), elapsedMs: elapsed}; + effectiveDuration = elapsed + ACCELERATED_REMAINING_MS; + }; + + const startTrickle = () => { + if (cancelled) { + return; + } + // Anchor to displayAfter so revisit resumes at the wall-clock-correct + // stage instead of restarting the reveal from char 0. + trickleStart = displayAfter; + const lastIndex = snapshotTokens.length - 1; + const elapsedAtStart = Date.now() - trickleStart; + const initialProgress = easeOut(elapsedAtStart / effectiveDuration); + // Floor at 1 so a fresh trickle (elapsed ≈ 0) still reveals the leading chunk on the first dispatch. + const initialStage = Math.max(1, Math.min(lastIndex, Math.ceil(initialProgress * lastIndex))); + Log.info('[ConciergeTrickle] start', false, { + reportActionID, + tokenCount: snapshotTokens.length, + durationMs: effectiveDuration, + initialStage, + elapsedAtStart, + }); + dispatch('started', snapshotTokens.at(initialStage) ?? ''); + lastStage = initialStage; + // If revisited past the duration / cap, finish without scheduling ticks. + if (initialProgress >= 1 || elapsedAtStart >= TRICKLE_HARD_CAP_MS) { + completeAndApply(); + return; + } + intervalID = setInterval(() => { + const elapsed = Date.now() - trickleStart; + const progress = easeOut(elapsed / effectiveDuration); + // progress ∈ [0,1] (easeOut clamps) and lastIndex ≥ 99 (shouldTrickle gate), + // so `progress * lastIndex` is always non-negative — only the upper bound needs clamping. + const stage = Math.min(lastIndex, Math.ceil(progress * lastIndex)); + if (stage > lastStage) { + lastStage = stage; + dispatch('updated', snapshotTokens.at(stage) ?? ''); + } + if (progress >= 1 || elapsed >= TRICKLE_HARD_CAP_MS) { + completeAndApply(); + } + }, TICK_INTERVAL_MS); + }; - return () => clearTimeout(timer); - }, [pendingResponse, reportID]); + const startTimer = setTimeout(startTrickle, Math.max(0, remainingDelay)); + return () => { + cancelled = true; + clearTimeout(startTimer); + if (intervalID) { + clearInterval(intervalID); + } + accelerateRef.current = null; + }; + }, [reportID, reportActionID]); } export default usePendingConciergeResponse; diff --git a/src/libs/Pusher/EventType.ts b/src/libs/Pusher/EventType.ts index 2298662040d6..2e59fa68f5aa 100644 --- a/src/libs/Pusher/EventType.ts +++ b/src/libs/Pusher/EventType.ts @@ -9,6 +9,11 @@ export default { USER_IS_TYPING: 'client-userIsTyping', MULTIPLE_EVENTS: 'multipleEvents', CONCIERGE_REASONING: 'conciergeReasoning', + CONCIERGE_DRAFT_STARTED: 'conciergeDraftStarted', + CONCIERGE_DRAFT_UPDATED: 'conciergeDraftUpdated', + CONCIERGE_DRAFT_COMPLETED: 'conciergeDraftCompleted', + CONCIERGE_DRAFT_FAILED: 'conciergeDraftFailed', + CONCIERGE_DRAFT_CLEARED: 'conciergeDraftCleared', // An event that the server sends back to the client in response to a "ping" API command PONG: 'pong', diff --git a/src/libs/Pusher/types.ts b/src/libs/Pusher/types.ts index ad9fa1d4f154..b7cf87d69e7e 100644 --- a/src/libs/Pusher/types.ts +++ b/src/libs/Pusher/types.ts @@ -39,11 +39,30 @@ type ConciergeReasoningEvent = { loopCount: number; }; +type ConciergeDraftEvent = { + reportID: string; + reportActionID: string; + streamSessionID: string; + sequence: number; + status: 'started' | 'updated' | 'completed' | 'failed' | 'cleared'; + created: string; + bodyMarkdown?: string; + finalRenderedHTML?: string; + startedAt?: string; + terminalReason?: string; + updatedAt?: string; +}; + type PusherEventMap = { [TYPE.USER_IS_TYPING]: UserIsTypingEvent; [TYPE.USER_IS_LEAVING_ROOM]: UserIsLeavingRoomEvent; [TYPE.PONG]: PingPongEvent; [TYPE.CONCIERGE_REASONING]: ConciergeReasoningEvent; + [TYPE.CONCIERGE_DRAFT_STARTED]: ConciergeDraftEvent; + [TYPE.CONCIERGE_DRAFT_UPDATED]: ConciergeDraftEvent; + [TYPE.CONCIERGE_DRAFT_COMPLETED]: ConciergeDraftEvent; + [TYPE.CONCIERGE_DRAFT_FAILED]: ConciergeDraftEvent; + [TYPE.CONCIERGE_DRAFT_CLEARED]: ConciergeDraftEvent; }; type EventData = {chunk?: string; id?: string; index?: number; final?: boolean} & (EventName extends keyof PusherEventMap @@ -103,6 +122,7 @@ export type { UserIsLeavingRoomEvent, PingPongEvent, ConciergeReasoningEvent, + ConciergeDraftEvent, EventData, EventCallbackError, ChunkedDataEvents, diff --git a/src/libs/ReportActionFollowupUtils/index.ts b/src/libs/ReportActionFollowupUtils/index.ts index dd591ea2a79c..c0091c2f451c 100644 --- a/src/libs/ReportActionFollowupUtils/index.ts +++ b/src/libs/ReportActionFollowupUtils/index.ts @@ -3,6 +3,7 @@ import {DomUtils, parseDocument} from 'htmlparser2'; import {getReportActionMessage, isActionOfType} from '@libs/ReportActionsUtils'; import CONST from '@src/CONST'; import type {OnyxInputOrEntry, ReportAction} from '@src/types/onyx'; +import tokenizeForReveal from './tokenizeForReveal'; type Followup = { text: string; @@ -58,5 +59,6 @@ function parseFollowupsFromHtml(html: string): Followup[] | null { return {text, response}; }); } -export {containsActionableFollowUps, parseFollowupsFromHtml}; + +export {containsActionableFollowUps, parseFollowupsFromHtml, tokenizeForReveal}; export type {Followup}; diff --git a/src/libs/ReportActionFollowupUtils/tokenizeForReveal.ts b/src/libs/ReportActionFollowupUtils/tokenizeForReveal.ts new file mode 100644 index 000000000000..7b0e73172e1c --- /dev/null +++ b/src/libs/ReportActionFollowupUtils/tokenizeForReveal.ts @@ -0,0 +1,110 @@ +import render from 'dom-serializer'; +import {ElementType} from 'domelementtype'; +import type {AnyNode, Document, Element as DomElement} from 'domhandler'; +import {parseDocument} from 'htmlparser2'; + +/** + * Tags whose content can't be split mid-element without looking broken + * (half mention, partial emoji codepoint, anchor without its URL). + */ +const ATOMIC_TAGS = new Set(['mention-user', 'mention-report', 'emoji', 'a', 'code', 'img']); + +/** + * One reveal point. `path` is the index path from the doc root to the anchor + * node; `textEndIdx` (when present) is the character offset to truncate that + * node's text data to. + */ +type Anchor = { + path: number[]; + textEndIdx?: number; +}; + +/** + * Emits one anchor per character inside text nodes and one per atomic-tag + * subtree. Formatting elements (, , ...) recurse so bold appears + * progressively rather than in one jump. + */ +function collectAnchors(doc: Document): Anchor[] { + const anchors: Anchor[] = []; + const visit = (node: AnyNode, path: number[]): void => { + if (node.type === ElementType.Text) { + const text = node.data; + if (text.length === 0) { + return; + } + // One anchor per character — atomic tags below emit a single anchor + // per subtree, so mentions/emoji/anchors/code stay whole. + for (let i = 1; i <= text.length; i += 1) { + anchors.push({path: path.slice(), textEndIdx: i}); + } + return; + } + if (node.type !== ElementType.Tag) { + return; + } + const elem = node; + const tagName = elem.name.toLowerCase(); + if (ATOMIC_TAGS.has(tagName)) { + anchors.push({path: path.slice()}); + return; + } + if (!elem.children || elem.children.length === 0) { + anchors.push({path: path.slice()}); + return; + } + for (const [i, child] of elem.children.entries()) { + visit(child, [...path, i]); + } + }; + for (const [i, child] of doc.children.entries()) { + visit(child, [i]); + } + return anchors; +} + +/** + * Builds a partial node forest up to `anchor`. Only the path branch is + * shallow-cloned; siblings stay referentially equal for dom-serializer. + */ +function buildClippedNodes(doc: Document, anchor: Anchor): AnyNode[] { + const clip = (siblings: readonly AnyNode[], depth: number): AnyNode[] => { + const idx = anchor.path.at(depth) ?? 0; + const isLeaf = depth === anchor.path.length - 1; + const before = siblings.slice(0, idx); + const stopNode = siblings.at(idx); + if (!stopNode) { + return [...before]; + } + if (isLeaf) { + if (stopNode.type === ElementType.Text && anchor.textEndIdx !== undefined) { + const truncated = {...stopNode, data: stopNode.data.slice(0, anchor.textEndIdx)} as unknown as AnyNode; + return [...before, truncated]; + } + return [...before, stopNode]; + } + const elem = stopNode as DomElement; + const innerChildren = clip(elem.children as AnyNode[], depth + 1); + const partialElem = {...elem, children: innerChildren} as unknown as AnyNode; + return [...before, partialElem]; + }; + return clip(doc.children as AnyNode[], 0); +} + +/** + * Pre-computes progressive HTML stages at character granularity. Atomic + * primitives (mentions, emoji, links, code, images) stay whole. + */ +function tokenizeForReveal(html: string): string[] { + if (!html) { + return ['']; + } + const doc = parseDocument(html); + const anchors = collectAnchors(doc); + const stages: string[] = ['']; + for (const anchor of anchors) { + stages.push(render(buildClippedNodes(doc, anchor))); + } + return stages; +} + +export default tokenizeForReveal; diff --git a/src/pages/inbox/ConciergeDraftContext.tsx b/src/pages/inbox/ConciergeDraftContext.tsx new file mode 100644 index 000000000000..788b29b03860 --- /dev/null +++ b/src/pages/inbox/ConciergeDraftContext.tsx @@ -0,0 +1,156 @@ +import {getReportChatType} from '@selectors/Report'; +import React, {createContext, useContext, useEffect, useState} from 'react'; +import useOnyx from '@hooks/useOnyx'; +import {getReportChannelName} from '@libs/actions/Report'; +import Log from '@libs/Log'; +import Pusher from '@libs/Pusher'; +import type {ConciergeDraftEvent} from '@libs/Pusher/types'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportAction} from '@src/types/onyx'; +import type {ConciergeDraft} from './conciergeDraftState'; +import {applyConciergeDraftEvent, getCachedDraft, setCachedDraft} from './conciergeDraftState'; + +type ConciergeDraftState = { + draftReportAction: ReportAction | null; + hasActiveDraft: boolean; +}; + +type ConciergeDraftActions = { + clearDraft: () => void; + /** + * Apply a draft event from a non-Pusher source (e.g. a local pacer for + * pregenerated replies). Uses the same reducer as the Pusher path so + * `reportActionID`-based reconciliation works unchanged. + */ + dispatchLocalDraftEvent: (event: ConciergeDraftEvent) => void; +}; + +const defaultState: ConciergeDraftState = { + draftReportAction: null, + hasActiveDraft: false, +}; + +const defaultActions: ConciergeDraftActions = { + clearDraft: () => {}, + dispatchLocalDraftEvent: () => {}, +}; + +const ConciergeDraftStateContext = createContext(defaultState); +const ConciergeDraftActionsContext = createContext(defaultActions); + +function ConciergeDraftProvider({reportID, children}: React.PropsWithChildren<{reportID: string | undefined}>) { + const [chatType] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {selector: getReportChatType}); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const isConciergeChat = reportID === conciergeReportID; + const isAdmin = chatType === CONST.REPORT.CHAT_TYPE.POLICY_ADMINS; + const isAgentZeroChat = isConciergeChat || isAdmin; + + if (!reportID || !isAgentZeroChat) { + return children; + } + + return ( + + {children} + + ); +} + +function ConciergeDraftGate({reportID, children}: React.PropsWithChildren<{reportID: string}>) { + // Lazy-init from the module-level cache so a remount (ReportScreen + // unmount/remount on chat-switch) restores the in-progress draft on the + // first paint instead of flashing the synthetic bubble away. + const [draft, setDraft] = useState(() => getCachedDraft(reportID)); + + // React Compiler auto-memoizes; explicit useCallback/useMemo would just + // shadow the compiler's analysis (clean-react-0-compiler). + const clearDraft = () => { + setCachedDraft(reportID, null); + setDraft(null); + }; + + const dispatchLocalDraftEvent = (event: ConciergeDraftEvent) => { + setDraft((currentDraft) => { + const next = applyConciergeDraftEvent(currentDraft, event, reportID); + setCachedDraft(reportID, next); + return next; + }); + }; + + useEffect(() => { + const channelName = getReportChannelName(reportID); + // Inline the clear so the effect's deps stay scoped to reportID; closing + // over `clearDraft` would either drag it into deps (re-subscribing on + // every render) or trip exhaustive-deps. + const handleResubscribe = () => { + setCachedDraft(reportID, null); + setDraft(null); + }; + const eventTypes = [ + Pusher.TYPE.CONCIERGE_DRAFT_STARTED, + Pusher.TYPE.CONCIERGE_DRAFT_UPDATED, + Pusher.TYPE.CONCIERGE_DRAFT_COMPLETED, + Pusher.TYPE.CONCIERGE_DRAFT_FAILED, + Pusher.TYPE.CONCIERGE_DRAFT_CLEARED, + ] as const; + + const subscriptions = eventTypes.map((eventType) => { + const listener = Pusher.subscribe( + channelName, + eventType, + (eventData) => { + const conciergeDraftEvent = eventData as ConciergeDraftEvent; + setDraft((currentDraft) => { + const next = applyConciergeDraftEvent(currentDraft, conciergeDraftEvent, reportID); + setCachedDraft(reportID, next); + return next; + }); + }, + handleResubscribe, + ); + + listener.catch((error: unknown) => { + Log.hmmm('Failed to subscribe to Pusher concierge draft events', {eventType, reportID, error}); + }); + + return listener; + }); + + return () => { + for (const subscription of subscriptions) { + subscription.unsubscribe(); + } + }; + }, [reportID]); + + const stateValue: ConciergeDraftState = { + draftReportAction: draft?.reportAction ?? null, + hasActiveDraft: !!draft?.reportAction, + }; + + const actionsValue: ConciergeDraftActions = { + clearDraft, + dispatchLocalDraftEvent, + }; + + return ( + + {children} + + ); +} + +function useConciergeDraft(): ConciergeDraftState { + return useContext(ConciergeDraftStateContext); +} + +function useConciergeDraftActions(): ConciergeDraftActions { + return useContext(ConciergeDraftActionsContext); +} + +export {ConciergeDraftProvider, useConciergeDraft, useConciergeDraftActions}; +export type {ConciergeDraftState, ConciergeDraftActions}; diff --git a/src/pages/inbox/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx index 6790e427478a..de28636d1c3b 100644 --- a/src/pages/inbox/ReportScreen.tsx +++ b/src/pages/inbox/ReportScreen.tsx @@ -23,6 +23,7 @@ import CONST from '@src/CONST'; import SCREENS from '@src/SCREENS'; import AccountManagerBanner from './AccountManagerBanner'; import {AgentZeroStatusProvider} from './AgentZeroStatusContext'; +import {ConciergeDraftProvider} from './ConciergeDraftContext'; import DeleteTransactionNavigateBackHandler from './DeleteTransactionNavigateBackHandler'; import LinkedActionNotFoundGuard from './LinkedActionNotFoundGuard'; import ReactionListWrapper from './ReactionListWrapper'; @@ -145,13 +146,15 @@ function ReportScreen({route, navigation}: ReportScreenProps) { {!shouldDeferNonEssentials && } - - - {shouldDeferNonEssentials ? : } - + + + + {shouldDeferNonEssentials ? : } + + diff --git a/src/pages/inbox/conciergeDraftState.ts b/src/pages/inbox/conciergeDraftState.ts new file mode 100644 index 000000000000..70c07518f8ec --- /dev/null +++ b/src/pages/inbox/conciergeDraftState.ts @@ -0,0 +1,105 @@ +import Parser from '@libs/Parser'; +import type {ConciergeDraftEvent} from '@libs/Pusher/types'; +import {getParsedComment} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import type {ReportAction} from '@src/types/onyx'; + +type ConciergeDraft = { + reportAction: ReportAction; + sequence: number; + status: ConciergeDraftEvent['status']; + streamSessionID: string; + terminalReason?: string; +}; + +type BuildConciergeDraftReportActionParams = { + bodyMarkdown?: string; + created: string; + finalRenderedHTML?: string; + reportActionID: string; + reportID: string; +}; + +function buildConciergeDraftReportAction({bodyMarkdown, created, finalRenderedHTML, reportActionID, reportID}: BuildConciergeDraftReportActionParams): ReportAction | null { + const html = finalRenderedHTML ?? (bodyMarkdown ? getParsedComment(bodyMarkdown, {reportID}) : ''); + + if (!html) { + return null; + } + + return { + reportActionID, + reportID, + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + actorAccountID: CONST.ACCOUNT_ID.CONCIERGE, + person: [{style: 'strong', text: CONST.CONCIERGE_DISPLAY_NAME, type: 'TEXT'}], + created, + message: [{type: CONST.REPORT.MESSAGE.TYPE.COMMENT, html, text: Parser.htmlToText(html)}], + originalMessage: {html, whisperedTo: []}, + shouldShow: true, + } as ReportAction; +} + +// Module-level cache so a chat re-mount (ReportScreen unmount/remount on chat +// switch) preserves the in-progress draft. Without this the gate's local state +// resets to null on every revisit and the synthetic bubble disappears for the +// remount + Onyx-hydration window. Keyed by reportID; entries are evicted by +// `setCachedDraft(reportID, null)` when the reducer returns null +// (completed/failed/cleared). +const draftCache = new Map(); + +function getCachedDraft(reportID: string): ConciergeDraft | null { + return draftCache.get(reportID) ?? null; +} + +function setCachedDraft(reportID: string, draft: ConciergeDraft | null): void { + if (draft) { + draftCache.set(reportID, draft); + } else { + draftCache.delete(reportID); + } +} + +function applyConciergeDraftEvent(currentDraft: ConciergeDraft | null, event: ConciergeDraftEvent, reportID: string): ConciergeDraft | null { + if (event.reportID !== reportID) { + return currentDraft; + } + + const isSameStreamSession = currentDraft?.streamSessionID === event.streamSessionID; + + if (isSameStreamSession && event.sequence <= currentDraft.sequence) { + return currentDraft; + } + + if (!isSameStreamSession && currentDraft && event.status !== 'started' && event.status !== 'updated') { + return currentDraft; + } + + if (event.status === 'failed' || event.status === 'cleared') { + return isSameStreamSession ? null : currentDraft; + } + + const nextReportAction = + buildConciergeDraftReportAction({ + bodyMarkdown: event.bodyMarkdown, + created: event.created, + finalRenderedHTML: event.finalRenderedHTML, + reportActionID: event.reportActionID, + reportID: event.reportID, + }) ?? currentDraft?.reportAction; + + if (!nextReportAction) { + return currentDraft; + } + + return { + reportAction: nextReportAction, + sequence: event.sequence, + status: event.status, + streamSessionID: event.streamSessionID, + terminalReason: event.terminalReason, + }; +} + +export {applyConciergeDraftEvent, buildConciergeDraftReportAction, getCachedDraft, setCachedDraft}; +export type {ConciergeDraft}; diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 1e9dd91d3ff6..d5dcee39114d 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -8,6 +8,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import {renderScrollComponent as renderActionSheetAwareScrollView} from '@components/ActionSheetAwareScrollView'; import InvertedFlashList from '@components/FlashList/InvertedFlashList'; import getShowScrollIndicator from '@components/FlashList/InvertedFlashList/getShowScrollIndicator'; +import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@components/FlatList/hooks/useFlatListScrollKey'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -34,6 +35,8 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import { getFirstVisibleReportActionID, + getReportActionMessage, + getSortedReportActions, isConsecutiveActionMadeByPreviousActor, isCurrentActionUnread, isDeletedParentAction, @@ -60,6 +63,7 @@ import { } from '@libs/ReportUtils'; import Visibility from '@libs/Visibility'; import type {ReportsSplitNavigatorParamList} from '@navigation/types'; +import {useConciergeDraft, useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext'; import {ActionListContext} from '@pages/inbox/ReportScreenContext'; import variables from '@styles/variables'; import {openReport, readNewestAction, subscribeToNewActionEvent} from '@userActions/Report'; @@ -179,6 +183,8 @@ function ReportActionsList({ const route = useRoute>(); const reportScrollManager = useReportScrollManager(); const {scrollOffsetRef} = useContext(ActionListContext); + const {draftReportAction, hasActiveDraft} = useConciergeDraft(); + const {clearDraft} = useConciergeDraftActions(); const userActiveSince = useRef(DateUtils.getDBTime()); const lastMessageTime = useRef(null); const [isVisible, setIsVisible] = useState(Visibility.isVisible); @@ -202,7 +208,18 @@ function ReportActionsList({ const isTransactionThreadReport = useMemo(() => isTransactionThread(parentReportAction) && !isSentMoneyReportAction(parentReportAction), [parentReportAction]); const isMoneyRequestOrInvoiceReport = useMemo(() => isMoneyRequestReport(report) || isInvoiceReport(report), [report]); const shouldFocusToTopOnMount = useMemo(() => isTransactionThreadReport || isMoneyRequestOrInvoiceReport, [isMoneyRequestOrInvoiceReport, isTransactionThreadReport]); - const topReportAction = sortedVisibleReportActions.at(-1); + const renderedVisibleReportActions = useMemo(() => { + if (!draftReportAction || sortedVisibleReportActions.some((action) => action.reportActionID === draftReportAction.reportActionID)) { + return sortedVisibleReportActions; + } + + return getSortedReportActions([...sortedVisibleReportActions, draftReportAction], true); + }, [draftReportAction, sortedVisibleReportActions]); + const draftMessageHTML = draftReportAction ? getReportActionMessage(draftReportAction)?.html : undefined; + const isSyntheticDraftVisible = !!draftReportAction && !sortedVisibleReportActions.some((action) => action.reportActionID === draftReportAction.reportActionID); + const draftAutoScrollKey = isSyntheticDraftVisible ? `${draftReportAction.reportActionID}:${draftMessageHTML ?? ''}` : ''; + const previousDraftAutoScrollKey = usePrevious(draftAutoScrollKey); + const topReportAction = renderedVisibleReportActions.at(-1); const [shouldScrollToEndAfterLayout, setShouldScrollToEndAfterLayout] = useState(shouldFocusToTopOnMount && !linkedReportActionID); const scrollEndTimerRef = useRef | undefined>(undefined); const isAnonymousUser = useIsAnonymousUser(); @@ -258,6 +275,14 @@ function ReportActionsList({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [reportLastReadTime]); + useEffect(() => { + if (!draftReportAction || !sortedVisibleReportActions.some((action) => action.reportActionID === draftReportAction.reportActionID)) { + return; + } + + clearDraft(); + }, [clearDraft, draftReportAction, sortedVisibleReportActions]); + const prevUnreadMarkerReportActionID = useRef(null); /** @@ -379,6 +404,21 @@ function ReportActionsList({ resetKey: linkedReportActionID, }); + useEffect(() => { + if (!draftAutoScrollKey || previousDraftAutoScrollKey === draftAutoScrollKey) { + return; + } + + if (scrollOffsetRef.current >= AUTOSCROLL_TO_TOP_THRESHOLD || !hasNewestReportActionRef.current) { + return; + } + + setIsFloatingMessageCounterVisible(false); + requestAnimationFrame(() => { + reportScrollManager.scrollToBottom(); + }); + }, [draftAutoScrollKey, previousDraftAutoScrollKey, reportScrollManager, scrollOffsetRef, setIsFloatingMessageCounterVisible]); + useEffect(() => { const shouldTriggerScroll = shouldFocusToTopOnMount && prevHasCreatedActionAdded && !hasCreatedActionAdded; if (!shouldTriggerScroll) { @@ -637,7 +677,7 @@ function ReportActionsList({ linkedReportActionID, shouldScrollToEndAfterLayout, hasCreatedActionAdded, - sortedVisibleReportActionsLength: sortedVisibleReportActions.length, + sortedVisibleReportActionsLength: renderedVisibleReportActions.length, isOffline, getInitialNumToRender, }); @@ -648,7 +688,7 @@ function ReportActionsList({ linkedReportActionID, shouldScrollToEndAfterLayout, hasCreatedActionAdded, - sortedVisibleReportActions.length, + renderedVisibleReportActions.length, isOffline, ]); @@ -664,7 +704,7 @@ function ReportActionsList({ const firstVisibleReportActionID = useMemo(() => getFirstVisibleReportActionID(sortedReportActions, isOffline), [sortedReportActions, isOffline]); const shouldUseThreadDividerLine = useMemo(() => { - const topReport = sortedVisibleReportActions.length > 0 ? sortedVisibleReportActions.at(sortedVisibleReportActions.length - 1) : null; + const topReport = renderedVisibleReportActions.length > 0 ? renderedVisibleReportActions.at(renderedVisibleReportActions.length - 1) : null; if (topReport && topReport.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED) { return false; @@ -679,17 +719,17 @@ function ReportActionsList({ } return isExpenseReport(report) || isIOUReport(report) || isInvoiceReport(report); - }, [parentReportAction, report, sortedVisibleReportActions]); + }, [parentReportAction, renderedVisibleReportActions, report]); // Precompute a reportActionID → index map so renderItem can resolve the real index in O(1) - // instead of scanning sortedVisibleReportActions with indexOf on every render. + // instead of scanning renderedVisibleReportActions with indexOf on every render. const actionIndexMap = useMemo(() => { const map = new Map(); - for (const [i, action] of sortedVisibleReportActions.entries()) { + for (const [i, action] of renderedVisibleReportActions.entries()) { map.set(action.reportActionID, i); } return map; - }, [sortedVisibleReportActions]); + }, [renderedVisibleReportActions]); const renderItem = useCallback( ({item: reportAction, index}: ListRenderItemInfo) => { @@ -711,12 +751,12 @@ function ReportActionsList({ transactionThreadReport={transactionThreadReport} linkedReportActionID={linkedReportActionID} displayAsGroup={ - !isConsecutiveChronosAutomaticTimerAction(sortedVisibleReportActions, safeIndex, chatIncludesChronosWithID(reportAction?.reportID), isOffline) && - isConsecutiveActionMadeByPreviousActor(sortedVisibleReportActions, safeIndex, isOffline) + !isConsecutiveChronosAutomaticTimerAction(renderedVisibleReportActions, safeIndex, chatIncludesChronosWithID(reportAction?.reportID), isOffline) && + isConsecutiveActionMadeByPreviousActor(renderedVisibleReportActions, safeIndex, isOffline) } shouldHideThreadDividerLine={shouldHideThreadDividerLine} shouldDisplayNewMarker={reportAction.reportActionID === unreadMarkerReportActionID} - shouldDisplayReplyDivider={sortedVisibleReportActions.length > 1} + shouldDisplayReplyDivider={renderedVisibleReportActions.length > 1} isFirstVisibleReportAction={firstVisibleReportActionID === reportAction.reportActionID} shouldUseThreadDividerLine={shouldUseThreadDividerLine} personalDetails={personalDetailsList} @@ -745,7 +785,7 @@ function ReportActionsList({ transactionThreadReport, linkedReportActionID, actionIndexMap, - sortedVisibleReportActions, + renderedVisibleReportActions, shouldHideThreadDividerLine, unreadMarkerReportActionID, firstVisibleReportActionID, @@ -766,8 +806,8 @@ function ReportActionsList({ // Native mobile does not render updates flatlist the changes even though component did update called. // To notify there something changes we can use extraData prop to flatlist const extraData = useMemo( - () => [shouldUseNarrowLayout ? unreadMarkerReportActionID : undefined, isArchivedNonExpenseReport(report, isReportArchived)], - [unreadMarkerReportActionID, shouldUseNarrowLayout, report, isReportArchived], + () => [shouldUseNarrowLayout ? unreadMarkerReportActionID : undefined, isArchivedNonExpenseReport(report, isReportArchived), draftReportAction?.reportActionID, draftMessageHTML], + [draftMessageHTML, draftReportAction?.reportActionID, unreadMarkerReportActionID, shouldUseNarrowLayout, report, isReportArchived], ); const hideComposer = !canUserPerformWriteAction(report, isReportArchived); const shouldShowReportRecipientLocalTime = canShowReportRecipientLocalTime(personalDetailsList, report, currentUserAccountID) && !isComposerFullSize; @@ -804,9 +844,10 @@ function ReportActionsList({ ); - }, [canShowHeader, report.reportID, retryLoadNewerChatsError]); + }, [canShowHeader, hasActiveDraft, report.reportID, retryLoadNewerChatsError]); const shouldShowSkeleton = isOffline && !sortedVisibleReportActions.some((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); @@ -819,7 +860,7 @@ function ReportActionsList({ }, [shouldShowSkeleton]); const renderTopReportActions = useCallback(() => { - const previewItems = sortedVisibleReportActions.slice(initialNumToRender ? -initialNumToRender : 0).reverse(); + const previewItems = renderedVisibleReportActions.slice(initialNumToRender ? -initialNumToRender : 0).reverse(); return ( <> @@ -836,7 +877,7 @@ function ReportActionsList({ ); - }, [actionIndexMap, hideComposer, initialNumToRender, renderItem, shouldShowReportRecipientLocalTime, sortedVisibleReportActions, styles]); + }, [actionIndexMap, hideComposer, initialNumToRender, renderItem, shouldShowReportRecipientLocalTime, renderedVisibleReportActions, styles]); const onStartReached = useCallback(() => { if (!isSearchTopmostFullScreenRoute()) { @@ -869,7 +910,7 @@ function ReportActionsList({ ref={reportScrollManager.ref} testID="report-actions-list" style={styles.overscrollBehaviorContain} - data={sortedVisibleReportActions} + data={renderedVisibleReportActions} renderItem={renderItem} keyExtractor={keyExtractor} drawDistance={1500} diff --git a/src/pages/inbox/report/ReportActionsListHeader.tsx b/src/pages/inbox/report/ReportActionsListHeader.tsx index 77d2c617cff5..bf8611f0dd5f 100644 --- a/src/pages/inbox/report/ReportActionsListHeader.tsx +++ b/src/pages/inbox/report/ReportActionsListHeader.tsx @@ -11,14 +11,17 @@ type ReportActionsListHeaderProps = { /** Callback to retry loading newer chats after an error */ onRetry: () => void; + + /** Whether the user has an active Concierge draft response — hides the thinking indicator */ + hasActiveDraft?: boolean; }; -function ReportActionsListHeader({reportID, onRetry}: ReportActionsListHeaderProps) { +function ReportActionsListHeader({reportID, onRetry, hasActiveDraft}: ReportActionsListHeaderProps) { const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); return ( <> - + {!hasActiveDraft && } 100 chars of plain text → tokenizeForReveal emits ≥100 char-level + * anchors → the hook's `tokens.length >= 100` gate opts INTO the trickle path. */ +const LONG_HTML = + '

To connect Xero to Expensify, go to Settings > Workspaces and select your workspace.

' + + '
  1. Click More features, then in the Integrate section toggle Accounting.
  2. ' + + '
  3. Click Connect next to Xero.
  4. Log in to Xero as an administrator and authorize the connection.
'; + +const fakeLongConciergeAction = { + ...fakeConciergeAction, + message: [{html: LONG_HTML, text: LONG_HTML.replaceAll(/<[^>]+>/g, ''), type: CONST.REPORT.MESSAGE.TYPE.COMMENT}], +} as ReportAction; + +/** Tuple of (message, sendNow?, parameters?) for Log.info calls — matches the + * arg list usePendingConciergeResponse passes. Typing the spy's `.mock.calls` + * via this lets the find/filter callbacks access call[0]/[2] without tripping + * @typescript-eslint/no-unsafe-member-access. */ +type LogInfoCall = [string, boolean?, Record?]; + /** Wait for a given number of ms (real timer) */ function delay(ms: number): Promise { return new Promise((resolve) => { @@ -151,10 +170,10 @@ describe('usePendingConciergeResponse', () => { }); it('should discard stale pending responses instead of displaying them', async () => { - // Given a pending concierge response from a previous session (well past the stale threshold) + // Given a pending concierge response from a previous session (well past the hard cap, e.g. app killed and reopened later) await Onyx.merge(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${REPORT_ID}`, { reportAction: fakeConciergeAction, - displayAfter: Date.now() - 30_000, + displayAfter: Date.now() - 90_000, }); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING}${REPORT_ID}`, { [CONST.ACCOUNT_ID.CONCIERGE]: true, @@ -192,4 +211,155 @@ describe('usePendingConciergeResponse', () => { const reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}` as const); expect(reportActions).toBeUndefined(); }); + + describe('trickle path (long replies, ≥100 char-level anchors)', () => { + let logSpy: jest.SpyInstance; + + beforeEach(() => { + logSpy = jest.spyOn(Log, 'info').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + it('emits a [ConciergeTrickle] start telemetry log when the gate opens', async () => { + // Given a long pending Concierge response (passes the tokens.length >= 100 gate) + await Onyx.merge(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${REPORT_ID}`, { + reportAction: fakeLongConciergeAction, + displayAfter: Date.now() + SHORT_DELAY, + }); + await waitForBatchedUpdates(); + + const {unmount} = renderHook(() => usePendingConciergeResponse(REPORT_ID)); + await waitForBatchedUpdates(); + + // Wait for the displayAfter delay so startTrickle fires + await delay(SHORT_DELAY + 50); + await waitForBatchedUpdates(); + + // Then [ConciergeTrickle] start should have fired with token + duration metadata + const calls = logSpy.mock.calls as LogInfoCall[]; + const startCall = calls.find((call) => call[0] === '[ConciergeTrickle] start'); + expect(startCall).toBeDefined(); + const payload = startCall?.[2] as {reportActionID?: string; tokenCount?: number; durationMs?: number} | undefined; + expect(payload?.reportActionID).toBe(REPORT_ACTION_ID); + expect(payload?.tokenCount ?? 0).toBeGreaterThanOrEqual(100); + expect(payload?.durationMs).toBeGreaterThan(0); + + unmount(); + }); + + // Natural-completion (full ~15s trickle + applyPendingConciergeAction → REPORT_ACTIONS) + // is verified end-to-end by the Playwright ui-verify spec at + // script/playwright-fixtures/tests/verify-626938.spec.ts (asserts the [complete] + // log fires and the canonical reply lands). A jest version with fake timers + // can't drive completion: the hook reads Date.now() for elapsed progress and + // setInterval-only fake-timer advancement leaves progress stuck at 0. + + it('resumes mid-stage on revisit (displayAfter is in the past, within the hard cap)', async () => { + // Given a long pending response whose displayAfter is 5s in the past (user navigated away and came back). + await Onyx.merge(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${REPORT_ID}`, { + reportAction: fakeLongConciergeAction, + displayAfter: Date.now() - 5_000, + }); + await waitForBatchedUpdates(); + + const {unmount} = renderHook(() => usePendingConciergeResponse(REPORT_ID)); + // remainingDelay <= 0 → setTimeout(fn, 0). One tick lets startTrickle run. + await delay(50); + await waitForBatchedUpdates(); + + // The start log should report a non-trivial initialStage and elapsedAtStart >= 5s, + // proving the trickle resumed at the wall-clock-correct position rather than restarting from char 0. + const calls = logSpy.mock.calls as LogInfoCall[]; + const startCall = calls.find((call) => call[0] === '[ConciergeTrickle] start'); + expect(startCall).toBeDefined(); + const payload = startCall?.[2] as {initialStage?: number; elapsedAtStart?: number} | undefined; + expect(payload?.elapsedAtStart ?? 0).toBeGreaterThanOrEqual(4_900); + expect(payload?.initialStage ?? 0).toBeGreaterThan(1); + + unmount(); + }); + + it('completes immediately on revisit if elapsed exceeds the trickle duration but stays under the hard cap', async () => { + // Given a long pending response whose displayAfter is 20s in the past — past the 15s reveal but inside the 60s cap. + await Onyx.merge(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${REPORT_ID}`, { + reportAction: fakeLongConciergeAction, + displayAfter: Date.now() - 20_000, + }); + await waitForBatchedUpdates(); + + const {unmount} = renderHook(() => usePendingConciergeResponse(REPORT_ID)); + await delay(100); + await waitForBatchedUpdates(); + + // Then the action should land in REPORT_ACTIONS without spinning a 15s reveal. + const reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}` as const); + expect(reportActions?.[REPORT_ACTION_ID]?.actorAccountID).toBe(CONST.ACCOUNT_ID.CONCIERGE); + + // And the pending response should be cleared. + const pendingResponse = await getOnyxValue(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${REPORT_ID}` as const); + expect(pendingResponse).toBeUndefined(); + + unmount(); + }); + + it('discards (does not trickle) on revisit past the hard cap', async () => { + // Given a long pending response whose displayAfter is 90s in the past — well past the 60s hard cap. + await Onyx.merge(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${REPORT_ID}`, { + reportAction: fakeLongConciergeAction, + displayAfter: Date.now() - 90_000, + }); + await waitForBatchedUpdates(); + + const {unmount} = renderHook(() => usePendingConciergeResponse(REPORT_ID)); + await delay(50); + await waitForBatchedUpdates(); + + // Then no trickle telemetry should have fired and the pending optimistic should be discarded. + const calls = logSpy.mock.calls as LogInfoCall[]; + const startCall = calls.find((call) => call[0] === '[ConciergeTrickle] start'); + expect(startCall).toBeUndefined(); + + const reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}` as const); + expect(reportActions?.[REPORT_ACTION_ID]).toBeUndefined(); + + const pendingResponse = await getOnyxValue(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${REPORT_ID}` as const); + expect(pendingResponse).toBeUndefined(); + + unmount(); + }); + + it('cleans up the interval on unmount mid-trickle', async () => { + // Given a long pending response + await Onyx.merge(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${REPORT_ID}`, { + reportAction: fakeLongConciergeAction, + displayAfter: Date.now() + SHORT_DELAY, + }); + await waitForBatchedUpdates(); + + const {unmount} = renderHook(() => usePendingConciergeResponse(REPORT_ID)); + await waitForBatchedUpdates(); + + // Let the trickle start (past displayAfter) but unmount before completion + await delay(SHORT_DELAY + 200); + unmount(); + await waitForBatchedUpdates(); + + // Then no completion telemetry should fire after unmount + const callsBefore = logSpy.mock.calls as LogInfoCall[]; + const completeCallsBefore = callsBefore.filter((call) => call[0] === '[ConciergeTrickle] complete').length; + await delay(500); + await waitForBatchedUpdates(); + const callsAfter = logSpy.mock.calls as LogInfoCall[]; + const completeCallsAfter = callsAfter.filter((call) => call[0] === '[ConciergeTrickle] complete').length; + + expect(completeCallsAfter).toBe(completeCallsBefore); + + // And REPORT_ACTIONS should NOT contain the action (trickle was cancelled mid-way) + const reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}` as const); + expect(reportActions?.[REPORT_ACTION_ID]).toBeUndefined(); + }); + }); }); diff --git a/tests/unit/libs/ReportActionFollowupUtils/tokenizeForRevealTest.ts b/tests/unit/libs/ReportActionFollowupUtils/tokenizeForRevealTest.ts new file mode 100644 index 000000000000..15aaa218b6a9 --- /dev/null +++ b/tests/unit/libs/ReportActionFollowupUtils/tokenizeForRevealTest.ts @@ -0,0 +1,107 @@ +// cspell:ignore dani +import tokenizeForReveal from '@libs/ReportActionFollowupUtils/tokenizeForReveal'; + +describe('tokenizeForReveal', () => { + it('returns just the empty stage for an empty input', () => { + expect(tokenizeForReveal('')).toEqual(['']); + }); + + it('reveals plain text character-by-character inside a single block', () => { + const html = '

The quick brown fox.

'; + const stages = tokenizeForReveal(html); + + expect(stages.at(0)).toBe(''); + expect(stages.at(-1)).toBe('

The quick brown fox.

'); + // Char-level reveal yields one stage per character of the text content + // ("The quick brown fox." = 20 chars), plus stage 0 (empty). + expect(stages.length).toBe(21); + }); + + it('preserves the wrapper while revealing words inside it (formatting applies as text grows)', () => { + const html = '

Status: important warning for you.

'; + const stages = tokenizeForReveal(html); + + // At some intermediate stage we should see the bold wrapper opened + // around a partial word — proves we're recursing into the formatting + // element rather than treating it as atomic. + const hasOpenStrong = stages.some((s) => /[^<]*<\/strong>/.test(s) && !s.includes('important warning')); + expect(hasOpenStrong).toBe(true); + + // Every stage must keep the tag balanced. + for (const stage of stages) { + const opens = (stage.match(//g) ?? []).length; + const closes = (stage.match(/<\/strong>/g) ?? []).length; + expect(opens).toBe(closes); + } + + expect(stages.at(-1)).toBe('

Status: important warning for you.

'); + }); + + it('keeps mention-user atomic (never half-rendered)', () => { + const html = '

Hi @daniel welcome.

'; + const stages = tokenizeForReveal(html); + + for (const stage of stages) { + const opens = (stage.match(//g) ?? []).length; + expect(opens).toBe(closes); + } + + // Partial text inside the mention-user wrapper would indicate broken atomicity. + expect(stages.some((s) => /]*>@dani(?!e)/.test(s))).toBe(false); + }); + + it('keeps emoji atomic', () => { + const html = '

Status: all good.

'; + const stages = tokenizeForReveal(html); + + for (const stage of stages) { + const opens = (stage.match(//g) ?? []).length; + const closes = (stage.match(/<\/emoji>/g) ?? []).length; + expect(opens).toBe(closes); + } + }); + + it('keeps anchor atomic so the URL and text appear together', () => { + const html = '

See our docs for more.

'; + const stages = tokenizeForReveal(html); + + const anchorContents = stages.flatMap((s) => Array.from(s.matchAll(/]*>([^<]*)<\/a>/g)).map((m) => m[1])); + for (const content of anchorContents) { + expect(content).toBe('our docs'); + } + }); + + it('keeps code atomic', () => { + const html = '

Run npm install to start.

'; + const stages = tokenizeForReveal(html); + + const codeContents = stages.flatMap((s) => Array.from(s.matchAll(/([^<]*)<\/code>/g)).map((m) => m[1])); + for (const content of codeContents) { + expect(content).toBe('npm install'); + } + }); + + it('reveals each top-level child progressively (multi-paragraph + list shape)', () => { + const html = '

Intro paragraph here.

  1. First step.
  2. Second step.

Closing line.

'; + const stages = tokenizeForReveal(html); + + expect(stages.at(-1)).toBe(html); + + // Stages must monotonically grow: textual content length never decreases. + const lengths = stages.map((s) => s.replaceAll(/<[^>]+>/g, '').length); + for (let i = 1; i < lengths.length; i++) { + expect(lengths.at(i) ?? 0).toBeGreaterThanOrEqual(lengths.at(i - 1) ?? 0); + } + }); + + it('produces enough anchors that a typical multi-paragraph response trickles smoothly', () => { + // Realistic shape — Xero-style 5-step instructions. + const html = + '

Here is how to connect Xero as your accounting integration:

  1. In the left-hand menu, go to Settings.
  2. Click More features, then click Accounting.
  3. Click Set up next to Xero.
  4. Log in to Xero as an administrator.
  5. Confirm the connection.

This imports your charts of accounts and tracking categories.

'; + const stages = tokenizeForReveal(html); + // Char-level: total visible-text chars ~280, so we expect comfortably + // more than the shouldTrickle threshold of 100 anchors. + expect(stages.length).toBeGreaterThanOrEqual(200); + }); +}); diff --git a/tests/unit/pages/inbox/conciergeDraftState.test.ts b/tests/unit/pages/inbox/conciergeDraftState.test.ts new file mode 100644 index 000000000000..3f3a5aeb0c46 --- /dev/null +++ b/tests/unit/pages/inbox/conciergeDraftState.test.ts @@ -0,0 +1,171 @@ +import {applyConciergeDraftEvent, getCachedDraft, setCachedDraft} from '@pages/inbox/conciergeDraftState'; +import CONST from '@src/CONST'; + +const REPORT_ID = '123'; +const REPORT_ACTION_ID = '456'; +const CREATED = '2026-04-03 10:00:00.000'; +const STREAM_SESSION_ID = 'stream-session-1'; + +function createDraftEvent(overrides?: Partial[1]>) { + return { + reportID: REPORT_ID, + reportActionID: REPORT_ACTION_ID, + streamSessionID: STREAM_SESSION_ID, + sequence: 1, + status: 'started' as const, + created: CREATED, + bodyMarkdown: 'Hello, **world**!', + ...overrides, + }; +} + +function getFirstMessageHTML(draft: ReturnType) { + const message = draft?.reportAction.message; + + if (!Array.isArray(message)) { + return undefined; + } + + return message.at(0)?.html; +} + +function getFirstMessageText(draft: ReturnType) { + const message = draft?.reportAction.message; + + if (!Array.isArray(message)) { + return undefined; + } + + return message.at(0)?.text; +} + +describe('conciergeDraftState', () => { + it('should create a synthetic Concierge draft action from the first streamed snapshot', () => { + const draft = applyConciergeDraftEvent(null, createDraftEvent(), REPORT_ID); + + expect(draft?.reportAction.reportActionID).toBe(REPORT_ACTION_ID); + expect(draft?.reportAction.actorAccountID).toBe(CONST.ACCOUNT_ID.CONCIERGE); + expect(draft?.reportAction.created).toBe(CREATED); + expect(getFirstMessageHTML(draft)).toContain('world'); + expect(getFirstMessageText(draft)).toBe('Hello, *world*!'); + }); + + it('should update the same draft session when a newer sequence arrives', () => { + const initialDraft = applyConciergeDraftEvent(null, createDraftEvent(), REPORT_ID); + const updatedDraft = applyConciergeDraftEvent( + initialDraft, + createDraftEvent({ + sequence: 2, + status: 'updated', + bodyMarkdown: 'Hello, **streaming** world!', + }), + REPORT_ID, + ); + + expect(updatedDraft?.sequence).toBe(2); + expect(getFirstMessageHTML(updatedDraft)).toContain('streaming'); + }); + + it('should ignore stale events from the same stream session', () => { + const initialDraft = applyConciergeDraftEvent(null, createDraftEvent({sequence: 3}), REPORT_ID); + const staleDraft = applyConciergeDraftEvent( + initialDraft, + createDraftEvent({ + sequence: 2, + status: 'updated', + bodyMarkdown: 'This should be ignored', + }), + REPORT_ID, + ); + + expect(staleDraft).toBe(initialDraft); + expect(getFirstMessageText(staleDraft)).toBe('Hello, *world*!'); + }); + + it('should keep the draft visible through completion and prefer finalRenderedHTML when provided', () => { + const initialDraft = applyConciergeDraftEvent(null, createDraftEvent(), REPORT_ID); + const completedDraft = applyConciergeDraftEvent( + initialDraft, + createDraftEvent({ + sequence: 2, + status: 'completed', + finalRenderedHTML: 'Server rendered', + bodyMarkdown: undefined, + }), + REPORT_ID, + ); + + expect(completedDraft?.status).toBe('completed'); + expect(getFirstMessageHTML(completedDraft)).toBe('Server rendered'); + expect(getFirstMessageText(completedDraft)).toBe('Server rendered'); + }); + + it('should clear the active draft when the same stream session fails', () => { + const initialDraft = applyConciergeDraftEvent(null, createDraftEvent(), REPORT_ID); + const failedDraft = applyConciergeDraftEvent( + initialDraft, + createDraftEvent({ + sequence: 2, + status: 'failed', + terminalReason: 'lostLease', + bodyMarkdown: undefined, + }), + REPORT_ID, + ); + + expect(failedDraft).toBeNull(); + }); + + it('should ignore events for a different report', () => { + const initialDraft = applyConciergeDraftEvent(null, createDraftEvent(), REPORT_ID); + const otherReportDraft = applyConciergeDraftEvent( + initialDraft, + createDraftEvent({ + reportID: '999', + sequence: 2, + status: 'updated', + bodyMarkdown: 'Different report', + }), + REPORT_ID, + ); + + expect(otherReportDraft).toBe(initialDraft); + }); + + describe('draftCache', () => { + // Always start clean so tests don't leak state into each other. + beforeEach(() => { + setCachedDraft(REPORT_ID, null); + }); + + it('returns null for an unseen reportID', () => { + expect(getCachedDraft('never-stored')).toBeNull(); + }); + + it('persists a draft across set/get and survives across calls (the remount survival contract)', () => { + const draft = applyConciergeDraftEvent(null, createDraftEvent(), REPORT_ID); + expect(draft).not.toBeNull(); + setCachedDraft(REPORT_ID, draft); + expect(getCachedDraft(REPORT_ID)).toBe(draft); + // Simulating a remount: a fresh getCachedDraft call returns the same instance. + expect(getCachedDraft(REPORT_ID)).toBe(draft); + }); + + it('evicts when set to null (completed/failed/cleared reducer return)', () => { + const draft = applyConciergeDraftEvent(null, createDraftEvent(), REPORT_ID); + setCachedDraft(REPORT_ID, draft); + expect(getCachedDraft(REPORT_ID)).not.toBeNull(); + setCachedDraft(REPORT_ID, null); + expect(getCachedDraft(REPORT_ID)).toBeNull(); + }); + + it('keeps entries scoped per reportID (no cross-talk)', () => { + const draftA = applyConciergeDraftEvent(null, createDraftEvent(), REPORT_ID); + const draftB = applyConciergeDraftEvent(null, createDraftEvent({reportID: 'other'}), 'other'); + setCachedDraft(REPORT_ID, draftA); + setCachedDraft('other', draftB); + expect(getCachedDraft(REPORT_ID)).toBe(draftA); + expect(getCachedDraft('other')).toBe(draftB); + }); + }); +});