MessageInput.tsx

  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;