|
1 | | -import {useEffect} from 'react'; |
| 1 | +import {useEffect, useRef} from 'react'; |
| 2 | +import type {OnyxEntry} from 'react-native-onyx'; |
2 | 3 | import {applyPendingConciergeAction, discardPendingConciergeAction} from '@libs/actions/Report/SuggestedFollowup'; |
| 4 | +import Log from '@libs/Log'; |
| 5 | +import {rand64} from '@libs/NumberUtils'; |
| 6 | +import type {ConciergeDraftEvent} from '@libs/Pusher/types'; |
| 7 | +import tokenizeForReveal from '@libs/ReportActionFollowupUtils/tokenizeForReveal'; |
| 8 | +import {getReportActionHtml} from '@libs/ReportActionsUtils'; |
| 9 | +import {useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext'; |
3 | 10 | import ONYXKEYS from '@src/ONYXKEYS'; |
| 11 | +import type {ReportAction, ReportActions} from '@src/types/onyx'; |
4 | 12 | import useOnyx from './useOnyx'; |
5 | 13 |
|
6 | | -/** If displayAfter is more than this far in the past, the response is stale (e.g. app was killed and restarted) */ |
7 | | -const STALE_THRESHOLD_MS = 10_000; |
| 14 | +/** 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. */ |
| 15 | +const DEFAULT_STREAM_DURATION_MS = 15_000; |
| 16 | +/** 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). */ |
| 17 | +const TICK_INTERVAL_MS = 80; |
| 18 | +/** 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. */ |
| 19 | +const TRICKLE_HARD_CAP_MS = 60_000; |
| 20 | +/** Once the real reportComment lands in REPORT_ACTIONS, finish the remaining reveal within this window. */ |
| 21 | +const ACCELERATED_REMAINING_MS = 1_500; |
| 22 | +/** Minimum char-level anchors before we opt into the trickle reveal. Replies under this fall back to the binary reveal at `displayAfter`. */ |
| 23 | +const MIN_TRICKLE_TOKEN_COUNT = 100; |
| 24 | + |
| 25 | +function easeOut(t: number): number { |
| 26 | + const clamped = Math.max(0, Math.min(1, t)); |
| 27 | + return 1 - (1 - clamped) ** 2; |
| 28 | +} |
8 | 29 |
|
9 | 30 | /** |
10 | | - * Processes pending concierge responses stored in Onyx for a given report. |
11 | | - * When a pending response exists, schedules the action to be moved to REPORT_ACTIONS |
12 | | - * after the remaining delay, with automatic cleanup on unmount via useEffect. |
| 31 | + * Long Concierge replies trickle into `ConciergeDraftContext`; short ones keep |
| 32 | + * the binary reveal at `displayAfter`. `REPORT_ACTIONS` is written at completion. |
13 | 33 | */ |
14 | 34 | function usePendingConciergeResponse(reportID: string | undefined) { |
15 | 35 | const [pendingResponse] = useOnyx(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${reportID}`); |
| 36 | + const reportActionID = pendingResponse?.reportAction?.reportActionID; |
| 37 | + const fullHtml = pendingResponse?.reportAction ? getReportActionHtml(pendingResponse.reportAction) : ''; |
| 38 | + // React Compiler auto-memoizes the selector closure and the tokenize result; |
| 39 | + // explicit useCallback/useMemo would just shadow the compiler's analysis. |
| 40 | + const persistedActionSelector = (actions: OnyxEntry<ReportActions>): ReportAction | undefined => (reportActionID && actions ? actions[reportActionID] : undefined); |
| 41 | + const [persistedAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {selector: persistedActionSelector}); |
| 42 | + const {dispatchLocalDraftEvent} = useConciergeDraftActions(); |
| 43 | + |
| 44 | + const tokens = tokenizeForReveal(fullHtml); |
| 45 | + const accelerateRef = useRef<((nowMs: number) => void) | null>(null); |
| 46 | + |
| 47 | + // Captured into a ref so the trickle effect can re-run only on the IDs that |
| 48 | + // identify a distinct Concierge reply. Composer typing, unrelated Onyx emits, |
| 49 | + // and ConciergeDraftActions context refreshes all produce reference churn for |
| 50 | + // pendingResponse/tokens/fullHtml — without this snapshot, those non-content |
| 51 | + // updates would cancel the running interval and restart the reveal. The |
| 52 | + // useEffect keeps ref writes in the commit phase (React-Compiler-safe). |
| 53 | + const trickleInputsRef = useRef({pendingResponse, fullHtml, tokens, dispatchLocalDraftEvent, persistedAction}); |
| 54 | + useEffect(() => { |
| 55 | + trickleInputsRef.current = {pendingResponse, fullHtml, tokens, dispatchLocalDraftEvent, persistedAction}; |
| 56 | + }); |
16 | 57 |
|
| 58 | + // Reconciliation: when the canonical reportComment lands in REPORT_ACTIONS |
| 59 | + // mid-trickle, fire the running loop's accelerator so the remaining reveal |
| 60 | + // finishes in ~1.5s instead of snapping the synthetic bubble closed. |
17 | 61 | useEffect(() => { |
18 | | - if (!pendingResponse) { |
| 62 | + if (!persistedAction || !accelerateRef.current) { |
19 | 63 | return; |
20 | 64 | } |
| 65 | + accelerateRef.current(Date.now()); |
| 66 | + }, [persistedAction]); |
21 | 67 |
|
22 | | - const remaining = pendingResponse.displayAfter - Date.now(); |
| 68 | + useEffect(() => { |
| 69 | + if (!reportID || !reportActionID) { |
| 70 | + return; |
| 71 | + } |
| 72 | + // Snapshot inputs at effect start. The trickle commits to the content it had |
| 73 | + // when it began; subsequent updates that share this same reportActionID don't |
| 74 | + // disturb the in-progress reveal. A genuinely new Concierge reply produces a |
| 75 | + // new reportActionID and re-enters this effect via the deps below. |
| 76 | + const {pendingResponse: snapshot, fullHtml: snapshotHtml, tokens: snapshotTokens} = trickleInputsRef.current; |
| 77 | + if (!snapshot) { |
| 78 | + return; |
| 79 | + } |
| 80 | + const {reportAction, displayAfter} = snapshot; |
| 81 | + const remainingDelay = displayAfter - Date.now(); |
23 | 82 |
|
24 | | - // If the pending response is stale (e.g. app was killed/restarted), discard it |
25 | | - // instead of displaying a phantom message that was never confirmed by the server. |
26 | | - if (remaining < -STALE_THRESHOLD_MS) { |
| 83 | + // Past the hard cap from displayAfter, the server-side canonical reply |
| 84 | + // is expected to be in REPORT_ACTIONS already. Skip the trickle. |
| 85 | + if (remainingDelay < -TRICKLE_HARD_CAP_MS) { |
27 | 86 | discardPendingConciergeAction(reportID); |
28 | 87 | return; |
29 | 88 | } |
30 | 89 |
|
31 | | - const timer = setTimeout( |
32 | | - () => { |
33 | | - applyPendingConciergeAction(reportID, pendingResponse.reportAction); |
34 | | - }, |
35 | | - Math.max(0, remaining), |
36 | | - ); |
| 90 | + // Anchors are character-level. Short replies (~50–100 chars) keep the |
| 91 | + // binary reveal; longer ones (paragraphs / lists) cross the threshold |
| 92 | + // and get the smooth trickle. |
| 93 | + const shouldTrickle = snapshotTokens.length >= MIN_TRICKLE_TOKEN_COUNT && !!snapshotHtml; |
| 94 | + if (!shouldTrickle) { |
| 95 | + const timer = setTimeout(() => applyPendingConciergeAction(reportID, reportAction), Math.max(0, remainingDelay)); |
| 96 | + return () => clearTimeout(timer); |
| 97 | + } |
| 98 | + |
| 99 | + const session = rand64(); |
| 100 | + let sequence = 0; |
| 101 | + let intervalID: ReturnType<typeof setInterval> | null = null; |
| 102 | + let trickleStart = 0; |
| 103 | + let effectiveDuration = DEFAULT_STREAM_DURATION_MS; |
| 104 | + let lastStage = 0; |
| 105 | + let cancelled = false; |
| 106 | + // Snapshot of trickle progress at the moment the canonical reportComment |
| 107 | + // arrives. Presence (`arrival !== undefined`) doubles as the |
| 108 | + // "acceleration fired" check that selects the completion reason below. |
| 109 | + let arrival: {progress: number; elapsedMs: number} | undefined; |
| 110 | + |
| 111 | + const dispatch = (status: ConciergeDraftEvent['status'], finalRenderedHTML: string) => { |
| 112 | + if (cancelled) { |
| 113 | + return; |
| 114 | + } |
| 115 | + sequence += 1; |
| 116 | + // Read dispatch fn from the ref so a context-provider refresh doesn't pin |
| 117 | + // the trickle to a stale handler. The ref always points at the latest. |
| 118 | + trickleInputsRef.current.dispatchLocalDraftEvent({ |
| 119 | + reportID, |
| 120 | + reportActionID, |
| 121 | + streamSessionID: session, |
| 122 | + sequence, |
| 123 | + status, |
| 124 | + created: reportAction.created, |
| 125 | + finalRenderedHTML, |
| 126 | + }); |
| 127 | + }; |
| 128 | + |
| 129 | + const completeAndApply = () => { |
| 130 | + if (intervalID) { |
| 131 | + clearInterval(intervalID); |
| 132 | + intervalID = null; |
| 133 | + } |
| 134 | + const totalElapsedMs = trickleStart === 0 ? 0 : Date.now() - trickleStart; |
| 135 | + let reason: 'natural' | 'accelerated' | 'stale_cap' = 'natural'; |
| 136 | + if (arrival) { |
| 137 | + reason = 'accelerated'; |
| 138 | + } else if (totalElapsedMs >= TRICKLE_HARD_CAP_MS) { |
| 139 | + reason = 'stale_cap'; |
| 140 | + } |
| 141 | + Log.info('[ConciergeTrickle] complete', false, { |
| 142 | + reportActionID, |
| 143 | + reason, |
| 144 | + tokenCount: snapshotTokens.length, |
| 145 | + durationMs: effectiveDuration, |
| 146 | + totalElapsedMs, |
| 147 | + arrivedAtProgress: arrival?.progress, |
| 148 | + arrivedAtElapsedMs: arrival?.elapsedMs, |
| 149 | + }); |
| 150 | + dispatch('completed', snapshotTokens.at(-1) ?? snapshotHtml); |
| 151 | + // Don't reapply our older optimistic when the canonical is already there — |
| 152 | + // it would clobber server-added markup (follow-up buttons, deep-link |
| 153 | + // Pressables). `arrival` covers the accelerator path; the live ref read |
| 154 | + // catches arrivals during the pre-trickle setTimeout where the accelerator |
| 155 | + // no-ops on null intervalID. |
| 156 | + if (arrival || trickleInputsRef.current.persistedAction) { |
| 157 | + discardPendingConciergeAction(reportID); |
| 158 | + } else { |
| 159 | + applyPendingConciergeAction(reportID, reportAction); |
| 160 | + } |
| 161 | + }; |
| 162 | + |
| 163 | + accelerateRef.current = (nowMs: number) => { |
| 164 | + if (!intervalID || trickleStart === 0) { |
| 165 | + return; |
| 166 | + } |
| 167 | + const elapsed = nowMs - trickleStart; |
| 168 | + // Compressing effectiveDuration is what makes progress hit 1 within |
| 169 | + // ACCELERATED_REMAINING_MS — the next tick observes progress >= 1 |
| 170 | + // and runs completeAndApply via the normal path. |
| 171 | + arrival = {progress: easeOut(elapsed / effectiveDuration), elapsedMs: elapsed}; |
| 172 | + effectiveDuration = elapsed + ACCELERATED_REMAINING_MS; |
| 173 | + }; |
| 174 | + |
| 175 | + const startTrickle = () => { |
| 176 | + if (cancelled) { |
| 177 | + return; |
| 178 | + } |
| 179 | + // Anchor to displayAfter so revisit resumes at the wall-clock-correct |
| 180 | + // stage instead of restarting the reveal from char 0. |
| 181 | + trickleStart = displayAfter; |
| 182 | + const lastIndex = snapshotTokens.length - 1; |
| 183 | + const elapsedAtStart = Date.now() - trickleStart; |
| 184 | + const initialProgress = easeOut(elapsedAtStart / effectiveDuration); |
| 185 | + // Floor at 1 so a fresh trickle (elapsed ≈ 0) still reveals the leading chunk on the first dispatch. |
| 186 | + const initialStage = Math.max(1, Math.min(lastIndex, Math.ceil(initialProgress * lastIndex))); |
| 187 | + Log.info('[ConciergeTrickle] start', false, { |
| 188 | + reportActionID, |
| 189 | + tokenCount: snapshotTokens.length, |
| 190 | + durationMs: effectiveDuration, |
| 191 | + initialStage, |
| 192 | + elapsedAtStart, |
| 193 | + }); |
| 194 | + dispatch('started', snapshotTokens.at(initialStage) ?? ''); |
| 195 | + lastStage = initialStage; |
| 196 | + // If revisited past the duration / cap, finish without scheduling ticks. |
| 197 | + if (initialProgress >= 1 || elapsedAtStart >= TRICKLE_HARD_CAP_MS) { |
| 198 | + completeAndApply(); |
| 199 | + return; |
| 200 | + } |
| 201 | + intervalID = setInterval(() => { |
| 202 | + const elapsed = Date.now() - trickleStart; |
| 203 | + const progress = easeOut(elapsed / effectiveDuration); |
| 204 | + // progress ∈ [0,1] (easeOut clamps) and lastIndex ≥ 99 (shouldTrickle gate), |
| 205 | + // so `progress * lastIndex` is always non-negative — only the upper bound needs clamping. |
| 206 | + const stage = Math.min(lastIndex, Math.ceil(progress * lastIndex)); |
| 207 | + if (stage > lastStage) { |
| 208 | + lastStage = stage; |
| 209 | + dispatch('updated', snapshotTokens.at(stage) ?? ''); |
| 210 | + } |
| 211 | + if (progress >= 1 || elapsed >= TRICKLE_HARD_CAP_MS) { |
| 212 | + completeAndApply(); |
| 213 | + } |
| 214 | + }, TICK_INTERVAL_MS); |
| 215 | + }; |
37 | 216 |
|
38 | | - return () => clearTimeout(timer); |
39 | | - }, [pendingResponse, reportID]); |
| 217 | + const startTimer = setTimeout(startTrickle, Math.max(0, remainingDelay)); |
| 218 | + return () => { |
| 219 | + cancelled = true; |
| 220 | + clearTimeout(startTimer); |
| 221 | + if (intervalID) { |
| 222 | + clearInterval(intervalID); |
| 223 | + } |
| 224 | + accelerateRef.current = null; |
| 225 | + }; |
| 226 | + }, [reportID, reportActionID]); |
40 | 227 | } |
41 | 228 |
|
42 | 229 | export default usePendingConciergeResponse; |
0 commit comments