useDraft.ts (3880B)
1 import { useState, useEffect, useRef, useCallback } from "react"; 2 3 export const DRAFT_KEY_PREFIX = "theo-draft:"; 4 5 const DEBOUNCE_MS = 300; 6 7 function storageKey(sessionId: string | null): string { 8 return `${DRAFT_KEY_PREFIX}${sessionId ?? "new"}`; 9 } 10 11 /** 12 * Read draft from localStorage. Returns empty string if none exists. 13 */ 14 function readDraft(sessionId: string | null): string { 15 try { 16 return localStorage.getItem(storageKey(sessionId)) ?? ""; 17 } catch { 18 return ""; 19 } 20 } 21 22 /** 23 * Write draft to localStorage. 24 */ 25 function writeDraft(sessionId: string | null, text: string): void { 26 try { 27 if (text) { 28 localStorage.setItem(storageKey(sessionId), text); 29 } else { 30 localStorage.removeItem(storageKey(sessionId)); 31 } 32 } catch { 33 // localStorage full or unavailable — silently ignore 34 } 35 } 36 37 /** 38 * Remove draft from localStorage for a given session. 39 */ 40 export function clearDraft(sessionId: string | null): void { 41 try { 42 localStorage.removeItem(storageKey(sessionId)); 43 } catch { 44 // ignore 45 } 46 } 47 48 /** 49 * Migrate a draft from one key to another (e.g. "new" → session ID). 50 * Removes the source key after copying. 51 */ 52 export function migrateDraft( 53 fromSessionId: string | null, 54 toSessionId: string, 55 ): void { 56 const text = readDraft(fromSessionId); 57 if (!text) return; 58 writeDraft(toSessionId, text); 59 clearDraft(fromSessionId); 60 } 61 62 /** 63 * Hook that syncs a text draft to localStorage, keyed by session ID. 64 * 65 * - Returns `[text, setText]` where setText updates both React state and 66 * debounced localStorage writes. 67 * - When sessionId changes, loads the draft for the new session. 68 * - Uses a 300ms debounce to avoid thrashing storage on every keystroke. 69 */ 70 export function useDraft( 71 sessionId: string | null, 72 ): [string, (text: string) => void, () => void] { 73 const [text, setTextState] = useState(() => readDraft(sessionId)); 74 const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); 75 const sessionIdRef = useRef(sessionId); 76 const justSentRef = useRef(false); 77 78 // When sessionId changes, flush pending save and load new draft. 79 // Skip if markSent() was just called — the user sent a message so 80 // the draft is stale (migrateDraft may have copied it to the new key). 81 useEffect(() => { 82 if (sessionIdRef.current === sessionId) return; 83 84 // Flush any pending save for the old session 85 if (timerRef.current) { 86 clearTimeout(timerRef.current); 87 timerRef.current = null; 88 } 89 90 sessionIdRef.current = sessionId; 91 92 if (justSentRef.current) { 93 justSentRef.current = false; 94 setTextState(""); 95 clearDraft(sessionId); 96 return; 97 } 98 99 setTextState(readDraft(sessionId)); 100 }, [sessionId]); 101 102 const setText = useCallback( 103 (newText: string) => { 104 setTextState(newText); 105 106 if (timerRef.current) { 107 clearTimeout(timerRef.current); 108 } 109 110 timerRef.current = setTimeout(() => { 111 writeDraft(sessionIdRef.current, newText); 112 timerRef.current = null; 113 }, DEBOUNCE_MS); 114 }, 115 [], 116 ); 117 118 // Signal that a message was just sent — suppresses the next sessionId-change 119 // draft restore so the cleared text doesn't reappear. 120 const markSent = useCallback(() => { 121 justSentRef.current = true; 122 // Also clear the pending debounce timer so it doesn't re-save the old text 123 if (timerRef.current) { 124 clearTimeout(timerRef.current); 125 timerRef.current = null; 126 } 127 }, []); 128 129 // Flush on unmount 130 useEffect(() => { 131 return () => { 132 if (timerRef.current) { 133 clearTimeout(timerRef.current); 134 // We can't reliably read the latest text state in a cleanup effect, 135 // but the debounce timer would have already fired for any value 136 // that was set >300ms ago. This is acceptable for drafts. 137 } 138 }; 139 }, []); 140 141 return [text, setText, markSent]; 142 }