'use client'; import { type ChangeEvent, type ClipboardEvent, type KeyboardEvent, type RefObject, useCallback, useEffect, useRef, useState, } from 'react'; import type { ChatAttachment } from '../types'; import { LIMITS } from '../constants'; import { sanitizeDraft } from '../utils'; export interface UseChatComposerOptions { onSubmit: (content: string, attachments: ChatAttachment[]) => void | Promise; initialValue?: string; maxLength?: number; maxAttachments?: number; disabled?: boolean; /** 'enter' = Enter sends, Shift+Enter newline. 'cmd+enter' = Enter inserts newline, Cmd/Ctrl+Enter sends. */ submitOn?: 'enter' | 'cmd+enter'; history?: { enabled?: boolean; size?: number }; onPasteFiles?: (files: File[]) => void; /** * Persist the current draft to `sessionStorage` under this key. The * draft is loaded once on mount (overrides `initialValue` if non- * empty) and rewritten on every value change. Cleared on `reset()`. * * Pass a per-conversation id to keep separate drafts per chat. Pass * `undefined` (default) to disable persistence — composer behaves * exactly as before. Plan64. */ persistKey?: string; /** * Skip pre-submit draft sanitation (trim + line-ending normalise + * zero-width strip). Default `false` — sanitation matches what * ChatGPT / Claude / Telegram ship and is what consumers want 99% of * the time. Set to `true` for niche flows that need byte-perfect * passthrough (clipboard inspector, raw-prompt debug tool). */ preserveExactValue?: boolean; } export interface UseChatComposerReturn { value: string; setValue: (next: string) => void; attachments: ChatAttachment[]; addAttachment: (a: ChatAttachment) => void; /** Patch an existing attachment in place — used by the upload lifecycle * to flip `status` / `progress` / `url` without losing list order. */ updateAttachment: (id: string, patch: Partial) => void; removeAttachment: (id: string) => void; isSubmitting: boolean; canSubmit: boolean; submit: () => Promise; reset: () => void; focus: () => void; textareaRef: RefObject; textareaProps: { ref: RefObject; value: string; disabled: boolean; onChange: (e: ChangeEvent) => void; onKeyDown: (e: KeyboardEvent) => void; onPaste: (e: ClipboardEvent) => void; }; recallPrevious: () => void; recallNext: () => void; } const MAX_TEXTAREA_HEIGHT = 240; export function useChatComposer(options: UseChatComposerOptions): UseChatComposerReturn { const { onSubmit, initialValue = '', maxLength = LIMITS.messageMaxLength, maxAttachments = LIMITS.attachmentsMax, disabled = false, submitOn = 'enter', history = { enabled: true, size: LIMITS.composerHistorySize }, onPasteFiles, persistKey, preserveExactValue = false, } = options; // Hydrate draft from sessionStorage on mount when a key is provided. // We read once, lazily — switching `persistKey` mid-life (e.g. // session change) requires a parent remount via React `key`, same // pattern as ``. Avoids accidental cross-session // bleed-through when the parent forgets to remount. const initialFromStorage = (() => { if (!persistKey || typeof window === 'undefined') return initialValue; try { const stored = window.sessionStorage.getItem(`chat:draft:${persistKey}`); return stored && stored.length > 0 ? stored : initialValue; } catch { return initialValue; } })(); const [value, setValueState] = useState(initialFromStorage); // Persist on every value change. Throwaway swallow keeps quota / // private-mode failures from breaking the composer. useEffect(() => { if (!persistKey || typeof window === 'undefined') return; try { const k = `chat:draft:${persistKey}`; if (value.length > 0) window.sessionStorage.setItem(k, value); else window.sessionStorage.removeItem(k); } catch { /* noop — quota / disabled storage is non-fatal */ } }, [value, persistKey]); const [attachments, setAttachments] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); const textareaRef = useRef(null); const historyRef = useRef<{ items: string[]; index: number }>({ items: [], index: -1 }); const setValue = useCallback( (next: string) => { setValueState(next.length > maxLength ? next.slice(0, maxLength) : next); }, [maxLength], ); // Autosize textarea on value change. useEffect(() => { const el = textareaRef.current; if (!el) return; el.style.height = 'auto'; el.style.height = `${Math.min(el.scrollHeight, MAX_TEXTAREA_HEIGHT)}px`; }, [value]); const reset = useCallback(() => { setValueState(''); setAttachments([]); historyRef.current.index = -1; }, []); const focus = useCallback(() => { requestAnimationFrame(() => textareaRef.current?.focus()); }, []); const submit = useCallback(async () => { // Sanitise BEFORE the empty-guard so a draft of only whitespace // (` \n\n `) is treated as empty — saves a round trip for // "send" attempts that would produce nothing. // // sanitizeDraft is intentionally conservative: trim outer // whitespace, normalise line endings, strip zero-width chars. // It does NOT collapse internal whitespace or cap blank lines // (would break code indentation / intentional separators). See // utils/sanitizeDraft.ts for the full ruleset + rationale. // // The cleaned text is what reaches `onSubmit` (matches ChatGPT / // Claude / Telegram behaviour — the bubble shows what the user // last saw, sans accidental trailing whitespace). Niche callers // can opt out via `preserveExactValue`. const cleaned = preserveExactValue ? value : sanitizeDraft(value); if ((!cleaned && attachments.length === 0) || isSubmitting || disabled) return; setIsSubmitting(true); try { if (history.enabled !== false && cleaned) { const buf = historyRef.current.items; if (buf[buf.length - 1] !== cleaned) { buf.push(cleaned); if (buf.length > (history.size ?? LIMITS.composerHistorySize)) buf.shift(); } historyRef.current.index = -1; } const snapshot = [...attachments]; reset(); await onSubmit(cleaned, snapshot); } finally { setIsSubmitting(false); } }, [value, attachments, isSubmitting, disabled, history, onSubmit, reset, preserveExactValue]); const addAttachment = useCallback( (a: ChatAttachment) => { setAttachments((prev) => { if (prev.some((p) => p.id === a.id)) { return prev.map((p) => (p.id === a.id ? a : p)); } if (prev.length >= maxAttachments) return prev; return [...prev, a]; }); }, [maxAttachments], ); const updateAttachment = useCallback( (id: string, patch: Partial) => { setAttachments((prev) => prev.map((a) => (a.id === id ? { ...a, ...patch } : a)), ); }, [], ); const removeAttachment = useCallback((id: string) => { setAttachments((prev) => prev.filter((a) => a.id !== id)); }, []); const recallPrevious = useCallback(() => { const { items } = historyRef.current; if (!items.length) return; const next = historyRef.current.index < 0 ? items.length - 1 : Math.max(0, historyRef.current.index - 1); historyRef.current.index = next; setValueState(items[next] ?? ''); }, []); const recallNext = useCallback(() => { const { items } = historyRef.current; if (!items.length || historyRef.current.index < 0) return; const next = historyRef.current.index + 1; if (next >= items.length) { historyRef.current.index = -1; setValueState(''); return; } historyRef.current.index = next; setValueState(items[next] ?? ''); }, []); const onChange = useCallback( (e: ChangeEvent) => { setValue(e.target.value); }, [setValue], ); const onKeyDown = useCallback( (e: KeyboardEvent) => { if (e.key === 'Enter') { const isCmd = e.metaKey || e.ctrlKey; const shouldSend = submitOn === 'cmd+enter' ? isCmd : !e.shiftKey; if (shouldSend) { e.preventDefault(); void submit(); } return; } if (history.enabled !== false && value === '' && attachments.length === 0) { if (e.key === 'ArrowUp') { e.preventDefault(); recallPrevious(); } else if (e.key === 'ArrowDown' && historyRef.current.index >= 0) { e.preventDefault(); recallNext(); } } }, [submitOn, submit, history, value, attachments.length, recallPrevious, recallNext], ); const onPaste = useCallback( (e: ClipboardEvent) => { const files = Array.from(e.clipboardData?.files ?? []); if (files.length && onPasteFiles) { e.preventDefault(); onPasteFiles(files); } }, [onPasteFiles], ); // canSubmit mirrors what submit() will actually do — gate the Send // button on the post-sanitation result so a whitespace-only draft // renders Send as disabled (no false-hope affordance). // preserveExactValue callers fall back to raw .trim() — they // opted out of sanitation, but we still don't enable Send on a // pure-whitespace draft (an empty message is rarely intentional). const canSubmit = !disabled && !isSubmitting && ( (preserveExactValue ? value.trim().length : sanitizeDraft(value).length) > 0 || attachments.length > 0 ); return { value, setValue, attachments, addAttachment, updateAttachment, removeAttachment, isSubmitting, canSubmit, submit, reset, focus, textareaRef, textareaProps: { ref: textareaRef, value, disabled, onChange, onKeyDown, onPaste, }, recallPrevious, recallNext, }; }