1import React, { useState, useRef, useEffect, useCallback } from "react";
2
3// Web Speech API types
4interface SpeechRecognitionEvent extends Event {
5 results: SpeechRecognitionResultList;
6 resultIndex: number;
7}
8
9interface SpeechRecognitionResultList {
10 length: number;
11 item(index: number): SpeechRecognitionResult;
12 [index: number]: SpeechRecognitionResult;
13}
14
15interface SpeechRecognitionResult {
16 isFinal: boolean;
17 length: number;
18 item(index: number): SpeechRecognitionAlternative;
19 [index: number]: SpeechRecognitionAlternative;
20}
21
22interface SpeechRecognitionAlternative {
23 transcript: string;
24 confidence: number;
25}
26
27interface SpeechRecognition extends EventTarget {
28 continuous: boolean;
29 interimResults: boolean;
30 lang: string;
31 onresult: ((event: SpeechRecognitionEvent) => void) | null;
32 onerror: ((event: Event & { error: string }) => void) | null;
33 onend: (() => void) | null;
34 start(): void;
35 stop(): void;
36 abort(): void;
37}
38
39declare global {
40 interface Window {
41 SpeechRecognition: new () => SpeechRecognition;
42 webkitSpeechRecognition: new () => SpeechRecognition;
43 }
44}
45
46interface MessageInputProps {
47 onSend: (message: string) => Promise<void>;
48 disabled?: boolean;
49 autoFocus?: boolean;
50 onFocus?: () => void;
51 injectedText?: string;
52 onClearInjectedText?: () => void;
53 /** If set, persist draft message to localStorage under this key */
54 persistKey?: string;
55}
56
57const PERSIST_KEY_PREFIX = "shelley_draft_";
58
59function MessageInput({
60 onSend,
61 disabled = false,
62 autoFocus = false,
63 onFocus,
64 injectedText,
65 onClearInjectedText,
66 persistKey,
67}: MessageInputProps) {
68 const [message, setMessage] = useState(() => {
69 // Load persisted draft if persistKey is set
70 if (persistKey) {
71 return localStorage.getItem(PERSIST_KEY_PREFIX + persistKey) || "";
72 }
73 return "";
74 });
75 const [submitting, setSubmitting] = useState(false);
76 const [uploadsInProgress, setUploadsInProgress] = useState(0);
77 const [dragCounter, setDragCounter] = useState(0);
78 const [isListening, setIsListening] = useState(false);
79 const textareaRef = useRef<HTMLTextAreaElement>(null);
80 const recognitionRef = useRef<SpeechRecognition | null>(null);
81 // Track the base text (before speech recognition started) and finalized speech text
82 const baseTextRef = useRef<string>("");
83 const finalizedTextRef = useRef<string>("");
84
85 // Check if speech recognition is available
86 const speechRecognitionAvailable =
87 typeof window !== "undefined" && (window.SpeechRecognition || window.webkitSpeechRecognition);
88
89 const stopListening = useCallback(() => {
90 if (recognitionRef.current) {
91 recognitionRef.current.stop();
92 recognitionRef.current = null;
93 }
94 setIsListening(false);
95 }, []);
96
97 const startListening = useCallback(() => {
98 if (!speechRecognitionAvailable) return;
99
100 const SpeechRecognitionClass = window.SpeechRecognition || window.webkitSpeechRecognition;
101 const recognition = new SpeechRecognitionClass();
102
103 recognition.continuous = true;
104 recognition.interimResults = true;
105 recognition.lang = navigator.language || "en-US";
106
107 // Capture current message as base text
108 setMessage((current) => {
109 baseTextRef.current = current;
110 finalizedTextRef.current = "";
111 return current;
112 });
113
114 recognition.onresult = (event: SpeechRecognitionEvent) => {
115 let finalTranscript = "";
116 let interimTranscript = "";
117
118 for (let i = event.resultIndex; i < event.results.length; i++) {
119 const transcript = event.results[i][0].transcript;
120 if (event.results[i].isFinal) {
121 finalTranscript += transcript;
122 } else {
123 interimTranscript += transcript;
124 }
125 }
126
127 // Accumulate finalized text
128 if (finalTranscript) {
129 finalizedTextRef.current += finalTranscript;
130 }
131
132 // Build the full message: base + finalized + interim
133 const base = baseTextRef.current;
134 const needsSpace = base.length > 0 && !/\s$/.test(base);
135 const spacer = needsSpace ? " " : "";
136 const fullText = base + spacer + finalizedTextRef.current + interimTranscript;
137
138 setMessage(fullText);
139 };
140
141 recognition.onerror = (event) => {
142 console.error("Speech recognition error:", event.error);
143 stopListening();
144 };
145
146 recognition.onend = () => {
147 setIsListening(false);
148 recognitionRef.current = null;
149 };
150
151 recognitionRef.current = recognition;
152 recognition.start();
153 setIsListening(true);
154 }, [speechRecognitionAvailable, stopListening]);
155
156 const toggleListening = useCallback(() => {
157 if (isListening) {
158 stopListening();
159 } else {
160 startListening();
161 }
162 }, [isListening, startListening, stopListening]);
163
164 // Cleanup on unmount
165 useEffect(() => {
166 return () => {
167 if (recognitionRef.current) {
168 recognitionRef.current.abort();
169 }
170 };
171 }, []);
172
173 const uploadFile = async (file: File, insertPosition: number) => {
174 const textBefore = message.substring(0, insertPosition);
175 const textAfter = message.substring(insertPosition);
176
177 // Add a loading indicator
178 const loadingText = `[uploading ${file.name}...]`;
179 setMessage(`${textBefore}${loadingText}${textAfter}`);
180 setUploadsInProgress((prev) => prev + 1);
181
182 try {
183 const formData = new FormData();
184 formData.append("file", file);
185
186 const response = await fetch("/api/upload", {
187 method: "POST",
188 headers: { "X-Shelley-Request": "1" },
189 body: formData,
190 });
191
192 if (!response.ok) {
193 throw new Error(`Upload failed: ${response.statusText}`);
194 }
195
196 const data = await response.json();
197
198 // Replace the loading placeholder with the actual file path
199 setMessage((currentMessage) => currentMessage.replace(loadingText, `[${data.path}]`));
200 } catch (error) {
201 console.error("Failed to upload file:", error);
202 // Replace loading indicator with error message
203 const errorText = `[upload failed: ${error instanceof Error ? error.message : "unknown error"}]`;
204 setMessage((currentMessage) => currentMessage.replace(loadingText, errorText));
205 } finally {
206 setUploadsInProgress((prev) => prev - 1);
207 }
208 };
209
210 const handlePaste = async (event: React.ClipboardEvent) => {
211 // Check clipboard items (works on both desktop and mobile)
212 // Mobile browsers often don't populate clipboardData.files, but items works
213 const items = event.clipboardData?.items;
214 if (items) {
215 for (let i = 0; i < items.length; i++) {
216 const item = items[i];
217 if (item.kind === "file") {
218 const file = item.getAsFile();
219 if (file) {
220 event.preventDefault();
221 const cursorPos = textareaRef.current?.selectionStart ?? message.length;
222 await uploadFile(file, cursorPos);
223 return;
224 }
225 }
226 }
227 }
228 };
229
230 const handleDragOver = (event: React.DragEvent) => {
231 event.preventDefault();
232 event.stopPropagation();
233 };
234
235 const handleDragEnter = (event: React.DragEvent) => {
236 event.preventDefault();
237 event.stopPropagation();
238 setDragCounter((prev) => prev + 1);
239 };
240
241 const handleDragLeave = (event: React.DragEvent) => {
242 event.preventDefault();
243 event.stopPropagation();
244 setDragCounter((prev) => prev - 1);
245 };
246
247 const handleDrop = async (event: React.DragEvent) => {
248 event.preventDefault();
249 event.stopPropagation();
250 setDragCounter(0);
251
252 if (event.dataTransfer && event.dataTransfer.files.length > 0) {
253 // Process all dropped files
254 for (let i = 0; i < event.dataTransfer.files.length; i++) {
255 const file = event.dataTransfer.files[i];
256 const insertPosition =
257 i === 0 ? (textareaRef.current?.selectionStart ?? message.length) : message.length;
258 await uploadFile(file, insertPosition);
259 // Add a space between files
260 if (i < event.dataTransfer.files.length - 1) {
261 setMessage((prev) => prev + " ");
262 }
263 }
264 }
265 };
266
267 // Auto-insert injected text (diff comments) directly into the textarea
268 useEffect(() => {
269 if (injectedText) {
270 setMessage((prev) => {
271 const needsNewline = prev.length > 0 && !prev.endsWith("\n");
272 return prev + (needsNewline ? "\n\n" : "") + injectedText;
273 });
274 onClearInjectedText?.();
275 // Focus the textarea after inserting
276 setTimeout(() => textareaRef.current?.focus(), 0);
277 }
278 }, [injectedText, onClearInjectedText]);
279
280 const handleSubmit = async (e: React.FormEvent) => {
281 e.preventDefault();
282 if (message.trim() && !disabled && !submitting && uploadsInProgress === 0) {
283 // Stop listening if we were recording
284 if (isListening) {
285 stopListening();
286 }
287
288 const messageToSend = message;
289 setSubmitting(true);
290 try {
291 await onSend(messageToSend);
292 // Only clear on success
293 setMessage("");
294 // Clear persisted draft on successful send
295 if (persistKey) {
296 localStorage.removeItem(PERSIST_KEY_PREFIX + persistKey);
297 }
298 } catch {
299 // Keep the message on error so user can retry
300 } finally {
301 setSubmitting(false);
302 }
303 }
304 };
305
306 const handleKeyDown = (e: React.KeyboardEvent) => {
307 // Don't submit while IME is composing (e.g., converting Japanese hiragana to kanji)
308 if (e.nativeEvent.isComposing) {
309 return;
310 }
311 if (e.key === "Enter" && !e.shiftKey) {
312 e.preventDefault();
313 handleSubmit(e);
314 }
315 };
316
317 const adjustTextareaHeight = () => {
318 if (textareaRef.current) {
319 textareaRef.current.style.height = "auto";
320 const scrollHeight = textareaRef.current.scrollHeight;
321 const maxHeight = 200; // Maximum height in pixels
322 textareaRef.current.style.height = `${Math.min(scrollHeight, maxHeight)}px`;
323 }
324 };
325
326 useEffect(() => {
327 adjustTextareaHeight();
328 }, [message]);
329
330 // Persist draft to localStorage when persistKey is set
331 useEffect(() => {
332 if (persistKey) {
333 if (message) {
334 localStorage.setItem(PERSIST_KEY_PREFIX + persistKey, message);
335 } else {
336 localStorage.removeItem(PERSIST_KEY_PREFIX + persistKey);
337 }
338 }
339 }, [message, persistKey]);
340
341 useEffect(() => {
342 if (autoFocus && textareaRef.current) {
343 // Use setTimeout to ensure the component is fully rendered
344 setTimeout(() => {
345 textareaRef.current?.focus();
346 }, 0);
347 }
348 }, [autoFocus]);
349
350 const isDisabled = disabled || uploadsInProgress > 0;
351 const canSubmit = message.trim() && !isDisabled && !submitting;
352
353 const isDraggingOver = dragCounter > 0;
354 // Note: injectedText is auto-inserted via useEffect, no manual UI needed
355
356 return (
357 <div
358 className={`message-input-container ${isDraggingOver ? "drag-over" : ""}`}
359 onDragOver={handleDragOver}
360 onDragEnter={handleDragEnter}
361 onDragLeave={handleDragLeave}
362 onDrop={handleDrop}
363 >
364 {isDraggingOver && (
365 <div className="drag-overlay">
366 <div className="drag-overlay-content">Drop files here</div>
367 </div>
368 )}
369 <form onSubmit={handleSubmit} className="message-input-form">
370 <textarea
371 ref={textareaRef}
372 value={message}
373 onChange={(e) => setMessage(e.target.value)}
374 onKeyDown={handleKeyDown}
375 onPaste={handlePaste}
376 onFocus={() => {
377 // Scroll to bottom after keyboard animation settles
378 if (onFocus) {
379 requestAnimationFrame(() => requestAnimationFrame(onFocus));
380 }
381 }}
382 placeholder="Message, paste image, or attach file..."
383 className="message-textarea"
384 disabled={isDisabled}
385 rows={1}
386 aria-label="Message input"
387 data-testid="message-input"
388 autoFocus={autoFocus}
389 />
390 {speechRecognitionAvailable && (
391 <button
392 type="button"
393 onClick={toggleListening}
394 disabled={isDisabled}
395 className={`message-voice-btn ${isListening ? "listening" : ""}`}
396 aria-label={isListening ? "Stop voice input" : "Start voice input"}
397 data-testid="voice-button"
398 >
399 {isListening ? (
400 <svg fill="currentColor" viewBox="0 0 24 24" width="20" height="20">
401 <circle cx="12" cy="12" r="6" />
402 </svg>
403 ) : (
404 <svg fill="currentColor" viewBox="0 0 24 24" width="20" height="20">
405 <path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
406 </svg>
407 )}
408 </button>
409 )}
410 <button
411 type="submit"
412 disabled={!canSubmit}
413 className="message-send-btn"
414 aria-label="Send message"
415 data-testid="send-button"
416 >
417 {isDisabled || submitting ? (
418 <div className="flex items-center justify-center">
419 <div className="spinner spinner-small" style={{ borderTopColor: "white" }}></div>
420 </div>
421 ) : (
422 <svg fill="currentColor" viewBox="0 0 24 24" width="20" height="20">
423 <path d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z" />
424 </svg>
425 )}
426 </button>
427 </form>
428 </div>
429 );
430}
431
432export default MessageInput;