import { useState, useEffect, useRef, useCallback } from "react"; /** * Smoothly reveals text character-by-character using requestAnimationFrame. * * When `enabled` is true (actively streaming), incoming text is buffered and * revealed at a controlled rate so bursty SSE chunks feel like steady typing. * When `enabled` is false (stored/finalized message), the full text is returned * immediately with no animation. * * Uses proportional speed: each frame reveals ~30% of the remaining buffer, * with a minimum of 2 characters. This means small chunks feel smooth while * large bursts catch up quickly (~10 frames / ~166ms regardless of chunk size). */ const REVEAL_RATIO = 0.3; const MIN_CHARS_PER_FRAME = 2; export function useTypewriter(text: string, enabled: boolean): string { const targetRef = useRef(text); const posRef = useRef(enabled ? 0 : text.length); const [displayed, setDisplayed] = useState(enabled ? "" : text); const rafRef = useRef(null); const enabledRef = useRef(enabled); targetRef.current = text; enabledRef.current = enabled; const step = useCallback(() => { const target = targetRef.current; const remaining = target.length - posRef.current; if (remaining <= 0 || !enabledRef.current) { rafRef.current = null; return; } const chars = Math.max(MIN_CHARS_PER_FRAME, Math.ceil(remaining * REVEAL_RATIO)); posRef.current = Math.min(target.length, posRef.current + chars); setDisplayed(target.slice(0, posRef.current)); if (posRef.current < target.length) { rafRef.current = requestAnimationFrame(step); } else { rafRef.current = null; } }, []); const startAnimation = useCallback(() => { if (rafRef.current !== null) return; rafRef.current = requestAnimationFrame(step); }, [step]); const stopAnimation = useCallback(() => { if (rafRef.current !== null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } }, []); useEffect(() => { if (!enabled) { stopAnimation(); posRef.current = text.length; setDisplayed(text); } else { if (posRef.current < text.length) { startAnimation(); } } return stopAnimation; }, [enabled]); useEffect(() => { if (!enabled) return; if (posRef.current < text.length) { startAnimation(); } }, [text, enabled, startAnimation]); return displayed; }