diff --git a/src/hooks/usePendingConciergeResponse.ts b/src/hooks/usePendingConciergeResponse.ts index 2fc5b1e2147c..ccb66fcee7c5 100644 --- a/src/hooks/usePendingConciergeResponse.ts +++ b/src/hooks/usePendingConciergeResponse.ts @@ -1,42 +1,186 @@ -import {useEffect} from 'react'; +import {useCallback, useEffect, useMemo, 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) */ +/** 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 running trickle. If the loop is still alive past this, force completion to avoid pinning a synthetic bubble forever. */ +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; + +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. + * Multi-chunk payloads trickle into the `ConciergeDraftContext` reducer over + * `DEFAULT_STREAM_DURATION_MS` so the synthetic report action behaves + * identically to server-driven streaming. `REPORT_ACTIONS` is written only + * at completion, preserving LHN previews / push / search snapshots. + * Single-chunk payloads keep the binary reveal at `displayAfter`. */ 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) : ''; + const persistedActionSelector = useCallback( + (actions: OnyxEntry): ReportAction | undefined => (reportActionID && actions ? actions[reportActionID] : undefined), + [reportActionID], + ); + const [persistedAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {selector: persistedActionSelector}); + const {dispatchLocalDraftEvent} = useConciergeDraftActions(); + + const tokens = useMemo(() => tokenizeForReveal(fullHtml), [fullHtml]); + const accelerateRef = useRef<((nowMs: number) => void) | null>(null); + // 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 (!pendingResponse || !reportID || !reportActionID) { + return; + } + const {reportAction, displayAfter} = pendingResponse; + 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) { + if (remainingDelay < -STALE_THRESHOLD_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 = tokens.length >= 100 && !!fullHtml; + 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; + // Track whether/when acceleration fired so the [complete] log can + // attribute the completion reason and arrival point of `reportComment`. + let arrivedAtProgress: number | undefined; + let arrivedAtElapsedMs: number | undefined; + + const dispatch = (status: ConciergeDraftEvent['status'], finalRenderedHTML: string) => { + if (cancelled) { + return; + } + sequence += 1; + 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 (arrivedAtProgress !== undefined) { + reason = 'accelerated'; + } else if (totalElapsedMs >= TRICKLE_HARD_CAP_MS) { + reason = 'stale_cap'; + } + Log.info('[ConciergeTrickle] complete', false, { + reportActionID, + reason, + tokenCount: tokens.length, + durationMs: effectiveDuration, + totalElapsedMs, + arrivedAtProgress, + arrivedAtElapsedMs, + }); + dispatch('completed', tokens.at(-1) ?? fullHtml); + 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. + arrivedAtProgress = easeOut(elapsed / effectiveDuration); + arrivedAtElapsedMs = elapsed; + effectiveDuration = elapsed + ACCELERATED_REMAINING_MS; + }; + + const startTrickle = () => { + if (cancelled) { + return; + } + trickleStart = Date.now(); + Log.info('[ConciergeTrickle] start', false, { + reportActionID, + tokenCount: tokens.length, + durationMs: effectiveDuration, + }); + dispatch('started', tokens.at(1) ?? ''); + lastStage = 1; + intervalID = setInterval(() => { + const elapsed = Date.now() - trickleStart; + const progress = easeOut(elapsed / effectiveDuration); + const stage = Math.min(tokens.length - 1, Math.max(0, Math.ceil(progress * (tokens.length - 1)))); + if (stage > lastStage) { + lastStage = stage; + dispatch('updated', tokens.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; + }; + }, [pendingResponse, reportID, reportActionID, fullHtml, tokens, dispatchLocalDraftEvent]); } export default usePendingConciergeResponse; 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..3ca4367a60f1 --- /dev/null +++ b/src/libs/ReportActionFollowupUtils/tokenizeForReveal.ts @@ -0,0 +1,122 @@ +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 word/whitespace run inside text nodes and one per + * atomic-tag subtree. Formatting elements (, , ...) recurse so + * bold appears as words become bold, 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 for ChatGPT-style streaming feel. + // Atomic tags below still emit a single anchor for the whole tag, + // so mentions/emoji/anchors/code never render half-rendered even + // at this granularity. + 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 (let i = 0; i < elem.children.length; i++) { + const child = elem.children.at(i); + if (child) { + visit(child, [...path, i]); + } + } + }; + for (let i = 0; i < doc.children.length; i++) { + const child = doc.children.at(i); + if (child) { + visit(child, [i]); + } + } + return anchors; +} + +/** + * Builds a partial node forest up to and including `anchor`, truncating the + * leaf text node at the anchor offset. Only the path branch is shallow-cloned + * with a new children array; sibling references stay untouched so + * dom-serializer renders them as-is. + */ +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 with word-level granularity. Formatting + * wrappers (, , ...) recurse so bold reveals progressively; + * atomic primitives (mentions, emoji, links, code, images) stay whole. + * Stage 0 is empty; the final stage equals the input. + */ +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 index 977ca7928486..7bad6309721c 100644 --- a/src/pages/inbox/ConciergeDraftContext.tsx +++ b/src/pages/inbox/ConciergeDraftContext.tsx @@ -18,6 +18,12 @@ type ConciergeDraftState = { 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 = { @@ -27,6 +33,7 @@ const defaultState: ConciergeDraftState = { const defaultActions: ConciergeDraftActions = { clearDraft: () => {}, + dispatchLocalDraftEvent: () => {}, }; const ConciergeDraftStateContext = createContext(defaultState); @@ -60,6 +67,13 @@ function ConciergeDraftGate({reportID, children}: React.PropsWithChildren<{repor setDraft(null); }, []); + const dispatchLocalDraftEvent = useCallback( + (event: ConciergeDraftEvent) => { + setDraft((currentDraft) => applyConciergeDraftEvent(currentDraft, event, reportID)); + }, + [reportID], + ); + useEffect(() => { const channelName = getReportChannelName(reportID); const handleResubscribe = () => { @@ -109,8 +123,9 @@ function ConciergeDraftGate({reportID, children}: React.PropsWithChildren<{repor const actionsValue = useMemo( () => ({ clearDraft, + dispatchLocalDraftEvent, }), - [clearDraft], + [clearDraft, dispatchLocalDraftEvent], ); return ( diff --git a/tests/unit/libs/ReportActionFollowupUtils/tokenizeForRevealTest.ts b/tests/unit/libs/ReportActionFollowupUtils/tokenizeForRevealTest.ts new file mode 100644 index 000000000000..ed6a37ce6ebe --- /dev/null +++ b/tests/unit/libs/ReportActionFollowupUtils/tokenizeForRevealTest.ts @@ -0,0 +1,106 @@ +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); + }); +});