319 lines
10 KiB
TypeScript
319 lines
10 KiB
TypeScript
import type { KeyboardEventHandler } from 'react';
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { SubmitHandler, useForm } from 'react-hook-form';
|
|
import { socket } from '@/socket/socket.ts';
|
|
import { File, Paperclip, Send, X } from 'lucide-react';
|
|
import LoadingWheel from '@/components/chat/LoadingWheel.tsx';
|
|
import { FileWithPreviewProps } from '@/types/types.ts';
|
|
import { useChat } from '@/context/chat/useChat.ts';
|
|
import { axiosClient } from '@/utils/axiosClient.ts';
|
|
|
|
export type InputProps = {
|
|
message: string;
|
|
attachments: FileList | null;
|
|
};
|
|
|
|
const MessageForm = () => {
|
|
const { currentContact } = useChat();
|
|
const [files, setFiles] = useState<FileWithPreviewProps[]>([]);
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
const [isSending, setIsSending] = useState<boolean>(false);
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
|
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
|
|
const { register, handleSubmit, reset, watch, setValue } =
|
|
useForm<InputProps>({
|
|
mode: 'onChange',
|
|
});
|
|
|
|
const message = watch('message', '');
|
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
|
|
|
// Function to adjust textarea height
|
|
const adjustHeight = useCallback(() => {
|
|
const textarea = textareaRef.current;
|
|
if (textarea) {
|
|
textarea.style.height = 'auto';
|
|
textarea.style.height = `${textarea.scrollHeight}px`;
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
adjustHeight();
|
|
}, [message, adjustHeight]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
files.forEach((file) => {
|
|
if (file.preview) {
|
|
URL.revokeObjectURL(file.preview);
|
|
}
|
|
});
|
|
};
|
|
}, [files]);
|
|
|
|
const handleClearFiles = () => {
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
setFiles([]);
|
|
setValue('attachments', null);
|
|
};
|
|
|
|
const handleClearSingleFile = (index: number) => {
|
|
setFiles((prevFiles) => {
|
|
const newFiles = [...prevFiles];
|
|
if (newFiles[index].preview) {
|
|
URL.revokeObjectURL(newFiles[index].preview);
|
|
}
|
|
newFiles.splice(index, 1);
|
|
return newFiles;
|
|
});
|
|
|
|
if (files.length === 1 && fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
setValue('attachments', null);
|
|
}
|
|
};
|
|
|
|
if (!socket) {
|
|
console.error('Socket not initialized');
|
|
return;
|
|
}
|
|
|
|
const uploadFiles = async (filesToUpload: File[]) => {
|
|
const formData = new FormData();
|
|
filesToUpload.forEach((file, index) => {
|
|
formData.append(`attachments[${index}]`, file);
|
|
});
|
|
|
|
try {
|
|
setIsUploading(true);
|
|
const response = await axiosClient.post(
|
|
'/api/chat/attachments',
|
|
formData,
|
|
{
|
|
headers: {
|
|
'Content-Type': 'multipart/form-data',
|
|
},
|
|
},
|
|
);
|
|
setIsUploading(false);
|
|
return response.data;
|
|
} catch (e) {
|
|
setIsUploading(false);
|
|
console.error('Failed to upload attachments: ', e);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const submitMessage: SubmitHandler<InputProps> = async (data) => {
|
|
if ((data.message.trim().length < 1 && files.length === 0) || isSending)
|
|
return;
|
|
setErrorMessage(null);
|
|
setIsSending(true);
|
|
if (!socket) {
|
|
console.error('Socket not initialized');
|
|
return;
|
|
}
|
|
|
|
let attachmentUrls: string[] = [];
|
|
if (files.length > 0) {
|
|
try {
|
|
const response = await uploadFiles(files.map((f) => f.file));
|
|
console.log(response);
|
|
attachmentUrls = response;
|
|
} catch {
|
|
console.error('Failed to upload attachments');
|
|
}
|
|
}
|
|
|
|
// Emit message to server
|
|
socket.emit(
|
|
'chat message',
|
|
{
|
|
message: data.message.trim(),
|
|
recipient: currentContact?.conversation_id,
|
|
attachment_urls: attachmentUrls,
|
|
recipient_id: currentContact?.user_id,
|
|
},
|
|
(response: { status: string; message: string }) => {
|
|
if (response.status === 'ok') {
|
|
setIsSending(false);
|
|
reset({ message: '' });
|
|
handleClearFiles();
|
|
} else {
|
|
setIsSending(false);
|
|
setErrorMessage(response.message);
|
|
}
|
|
console.log('Response: ', response);
|
|
},
|
|
);
|
|
console.log('sent: ', {
|
|
message: data.message.trim(),
|
|
recipient: currentContact?.conversation_id,
|
|
attachment_urls: attachmentUrls,
|
|
recipient_id: currentContact?.user_id,
|
|
});
|
|
};
|
|
|
|
const handleKeyPress: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSubmit(submitMessage)();
|
|
}
|
|
};
|
|
|
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
if (e.target.files && e.target.files.length > 0) {
|
|
const newFiles: FileWithPreviewProps[] = Array.from(e.target.files).map(
|
|
(file) => ({
|
|
file,
|
|
preview: file.type.startsWith('image/')
|
|
? URL.createObjectURL(file)
|
|
: null,
|
|
}),
|
|
);
|
|
|
|
setFiles((prevFiles) => [...prevFiles, ...newFiles]);
|
|
setValue('attachments', e.target.files);
|
|
}
|
|
};
|
|
|
|
const { ref, ...messageRegister } = register('message', {
|
|
maxLength: {
|
|
value: 2000,
|
|
message: 'Maximum length exceeded',
|
|
},
|
|
minLength: 1,
|
|
});
|
|
|
|
const charCount = message.length;
|
|
const isNearLimit = charCount > 1900;
|
|
const isOverLimit = charCount > 2000;
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit(submitMessage)} className="w-full">
|
|
<div className="flex flex-col gap-2 w-full">
|
|
{files.length > 0 && (
|
|
<div className="flex flex-col gap-2 p-2 bg-[#121212] rounded-md mx-2 border-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-gray-400">
|
|
{files.length} {files.length === 1 ? 'file' : 'files'} selected
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={handleClearFiles}
|
|
className="p-1 hover:bg-green-100 rounded-full"
|
|
aria-label="Clear all files"
|
|
>
|
|
<X className="w-4 h-4 text-gray-500" />
|
|
</button>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{files.map((file, index) => (
|
|
<div key={index} className="relative">
|
|
<div className="flex flex-col p-2 bg-[#1a1a1a] rounded-md">
|
|
{file.preview ? (
|
|
<img
|
|
src={file.preview}
|
|
alt={`attachment ${index + 1}`}
|
|
className="rounded-md max-w-full max-h-24 object-contain"
|
|
/>
|
|
) : (
|
|
<div className="flex items-center justify-center text-center p-2">
|
|
<File className=" text-gray-300" />
|
|
<p className="text-sm">{file.file.name}</p>
|
|
</div>
|
|
)}
|
|
<span className="text-xs text-gray-400 mt-1">
|
|
({Math.round(file.file.size / 1024)} KB)
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleClearSingleFile(index)}
|
|
className="absolute -top-2 -right-2 p-1 bg-gray-800 hover:bg-gray-700 rounded-full"
|
|
aria-label={`Remove ${file.file.name}`}
|
|
>
|
|
<X className="w-3 h-3 text-gray-300" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-end gap-2 w-full text-center">
|
|
<div className="flex-1 inline-block">
|
|
<div className="relative flex justify-center">
|
|
<textarea
|
|
{...messageRegister}
|
|
ref={(e) => {
|
|
ref(e);
|
|
textareaRef.current = e;
|
|
}}
|
|
className={` ml-2 w-full overflow-y-hidden resize-none bg-zinc-900 rounded-lg text-white min-h-[40px] max-h-96 placeholder:text-gray-400 focus:border-1 focus:ring-0 focus:border-emerald-800
|
|
${isOverLimit ? 'border-2 border-red-500' : isNearLimit ? 'border-2 border-yellow-500' : ''} mx-auto`}
|
|
autoFocus={!!currentContact}
|
|
disabled={!currentContact}
|
|
placeholder="Enter message"
|
|
onKeyDown={handleKeyPress}
|
|
rows={1}
|
|
/>
|
|
<span
|
|
className={`absolute right-2 bottom-1 text-sm text-gray-400 ${
|
|
isOverLimit
|
|
? 'text-red-500 font-bold'
|
|
: isNearLimit
|
|
? 'text-yellow-600'
|
|
: 'text-gray-200'
|
|
}`}
|
|
>
|
|
{charCount}/2000
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{errorMessage ? <p className="text-red-200">{errorMessage}</p> : null}
|
|
<div className="flex">
|
|
<label
|
|
htmlFor="attachments"
|
|
className="flex items-center justify-center hover:cursor-pointer p-2 rounded-full hover:bg-zinc-800"
|
|
>
|
|
<Paperclip className="text-gray-200" />
|
|
</label>
|
|
<input
|
|
name="attachments"
|
|
id="attachments"
|
|
type="file"
|
|
accept="*/*"
|
|
onChange={handleFileChange}
|
|
disabled={isUploading}
|
|
ref={fileInputRef}
|
|
className="hidden"
|
|
multiple={true}
|
|
/>
|
|
{isUploading && (
|
|
<span className="text-gray-500 text-sm">Uploading...</span>
|
|
)}
|
|
<button
|
|
className="text-white hover:bg-zinc-800 rounded-full flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed h-10 w-10"
|
|
type="submit"
|
|
disabled={isOverLimit || isSending || isUploading}
|
|
>
|
|
{isSending ? (
|
|
<LoadingWheel />
|
|
) : (
|
|
<Send className="text-gray-200" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
);
|
|
};
|
|
|
|
export default MessageForm;
|