Skip to content
180 changes: 162 additions & 18 deletions src/hooks/usePendingConciergeResponse.ts
Original file line number Diff line number Diff line change
@@ -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<ReportActions>): 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<typeof setInterval> | 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;
4 changes: 3 additions & 1 deletion src/libs/ReportActionFollowupUtils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -58,5 +59,6 @@ function parseFollowupsFromHtml(html: string): Followup[] | null {
return {text, response};
});
}
export {containsActionableFollowUps, parseFollowupsFromHtml};

export {containsActionableFollowUps, parseFollowupsFromHtml, tokenizeForReveal};
export type {Followup};
122 changes: 122 additions & 0 deletions src/libs/ReportActionFollowupUtils/tokenizeForReveal.ts
Original file line number Diff line number Diff line change
@@ -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 (<strong>, <em>, ...) 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 (<strong>, <em>, ...) 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;
17 changes: 16 additions & 1 deletion src/pages/inbox/ConciergeDraftContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -27,6 +33,7 @@ const defaultState: ConciergeDraftState = {

const defaultActions: ConciergeDraftActions = {
clearDraft: () => {},
dispatchLocalDraftEvent: () => {},
};

const ConciergeDraftStateContext = createContext<ConciergeDraftState>(defaultState);
Expand Down Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -109,8 +123,9 @@ function ConciergeDraftGate({reportID, children}: React.PropsWithChildren<{repor
const actionsValue = useMemo<ConciergeDraftActions>(
() => ({
clearDraft,
dispatchLocalDraftEvent,
}),
[clearDraft],
[clearDraft, dispatchLocalDraftEvent],
);

return (
Expand Down
Loading
Loading