veza/apps/web/src/features/chat/components/ChatInput.tsx
2026-01-04 01:44:23 +01:00

212 lines
7 KiB
TypeScript

import React, { useState, useCallback, useRef, useEffect } from 'react';
import { Send, Smile, Paperclip, X, Image as ImageIcon, File } from 'lucide-react';
import { useChat } from '../hooks/useChat';
import { useChatStore } from '../store/chatStore';
import EmojiPicker, { Theme } from 'emoji-picker-react';
import { useDropzone } from 'react-dropzone';
import { apiClient } from '@/services/api/client';
import { MessageAttachment } from '../types';
export const ChatInput: React.FC = () => {
const [message, setMessage] = useState('');
const [attachments, setAttachments] = useState<MessageAttachment[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const { sendMessage, setTyping } = useChat();
const { currentConversationId } = useChatStore();
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if ((message.trim() || attachments.length > 0) && currentConversationId) {
sendMessage(message, attachments.length > 0 ? attachments : undefined);
setMessage('');
setAttachments([]);
// Stop typing indicator immediately
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
setTyping(false);
}
};
const fileInputRef = useRef<HTMLInputElement>(null);
const onDrop = useCallback(async (acceptedFiles: File[]) => {
setIsUploading(true);
try {
const uploadPromises = acceptedFiles.map(async (file) => {
const formData = new FormData();
formData.append('file', file);
// Use existing upload endpoint
const response = await apiClient.post('/uploads', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
const data = response.data;
return {
file_name: file.name,
file_type: file.type,
file_url: data.url, // Assuming backend returns { url: "..." }
file_size: file.size,
} as MessageAttachment;
});
const newAttachments = await Promise.all(uploadPromises);
setAttachments((prev) => [...prev, ...newAttachments]);
} catch (error) {
console.error('Failed to upload files:', error);
} finally {
setIsUploading(false);
}
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
noClick: true, // We want custom click on button
});
const handleEmojiClick = (emojiData: { emoji: string }) => {
setMessage((prev) => prev + emojiData.emoji);
setShowEmojiPicker(false);
};
const handleFileButtonClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
onDrop(Array.from(e.target.files));
}
};
const removeAttachment = (index: number) => {
setAttachments((prev) => prev.filter((_, i) => i !== index));
};
// Typing indicator logic
useEffect(() => {
if (message.length > 0) {
setTyping(true);
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
typingTimeoutRef.current = setTimeout(() => {
setTyping(false);
}, 3000); // Stop typing after 3 seconds of inactivity
} else {
setTyping(false);
}
}, [message, setTyping]);
return (
<div {...getRootProps()} className="border-t bg-gray-50">
<input
{...getInputProps()}
ref={fileInputRef}
onChange={handleFileChange}
className="hidden"
/>
{/* File Previews */}
{attachments.length > 0 && (
<div className="flex flex-wrap gap-2 p-2 px-4 border-b bg-white">
{attachments.map((att, i) => (
<div key={i} className="relative group flex items-center gap-2 p-1.5 bg-gray-100 rounded-md border text-xs">
{att.file_type.startsWith('image') ? (
<ImageIcon size={14} className="text-blue-500" />
) : (
<File size={14} className="text-gray-500" />
)}
<span className="truncate max-w-[100px]">{att.file_name}</span>
<button
onClick={() => removeAttachment(i)}
className="p-0.5 hover:bg-gray-200 rounded-full"
>
<X size={12} />
</button>
</div>
))}
</div>
)}
{isDragActive && (
<div className="absolute inset-0 z-50 bg-blue-500/10 flex items-center justify-center border-2 border-dashed border-blue-500 pointer-events-none">
<p className="text-blue-600 font-semibold">Déposez vos fichiers ici</p>
</div>
)}
<form
onSubmit={handleSubmit}
className="flex items-center gap-2 p-3"
>
<div className="flex gap-1">
<button
type="button"
className="p-2 text-gray-500 hover:bg-gray-200 rounded-lg transition-colors"
onClick={handleFileButtonClick}
>
<Paperclip size={20} />
</button>
<div className="relative">
<button
type="button"
className={cn(
"p-2 text-gray-500 hover:bg-gray-200 rounded-lg transition-colors",
showEmojiPicker && "bg-gray-200 text-blue-600"
)}
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
>
<Smile size={20} />
</button>
{showEmojiPicker && (
<div className="absolute bottom-full left-0 mb-2 z-50">
<div className="fixed inset-0" onClick={() => setShowEmojiPicker(false)} />
<div className="relative">
<EmojiPicker
onEmojiClick={handleEmojiClick}
theme={Theme.LIGHT}
lazyLoadEmojis={true}
/>
</div>
</div>
)}
</div>
</div>
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Écrire un message..."
className="flex-1 p-2 bg-white border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all"
disabled={!currentConversationId || isUploading}
/>
<button
type="submit"
className="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
disabled={!currentConversationId || (!message.trim() && attachments.length === 0) || isUploading}
>
{isUploading ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<Send size={20} />
)}
</button>
</form>
</div>
);
};
// Helper for class names since Lucide and ShadUI might be used
function cn(...classes: (string | boolean | undefined)[]) {
return classes.filter(Boolean).join(' ');
}