Files
relay/client/src/components/chat/chatArea/MessageForm.tsx
2025-01-02 20:10:08 +01:00

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;