improved attachments preview
This commit is contained in:
@@ -12,7 +12,7 @@ const nanoid = customAlphabet('1234567890', 5);
|
||||
|
||||
type Input = {
|
||||
message: string;
|
||||
attachment: FileList | null;
|
||||
attachments: FileList | null;
|
||||
};
|
||||
|
||||
type MessageFormProps = {
|
||||
@@ -21,10 +21,16 @@ type MessageFormProps = {
|
||||
messages: ChatMessages[];
|
||||
};
|
||||
|
||||
type FileWithPreview = {
|
||||
file: File;
|
||||
preview: string | null;
|
||||
};
|
||||
|
||||
const MessageForm = ({ contact, setMessages }: MessageFormProps) => {
|
||||
const { username }: { username: string } = useOutletContext();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [files, setFiles] = useState<FileWithPreview[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const { register, handleSubmit, reset, watch, setValue } = useForm<Input>({
|
||||
@@ -43,17 +49,42 @@ const MessageForm = ({ contact, setMessages }: MessageFormProps) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Adjust height on message change
|
||||
useEffect(() => {
|
||||
adjustHeight();
|
||||
}, [message, adjustHeight]);
|
||||
|
||||
const handleClearFile = () => {
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
files.forEach((file) => {
|
||||
if (file.preview) {
|
||||
URL.revokeObjectURL(file.preview);
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [files]);
|
||||
|
||||
const handleClearFiles = () => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
setFile(null);
|
||||
setValue('attachment', null);
|
||||
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) {
|
||||
@@ -61,17 +92,20 @@ const MessageForm = ({ contact, setMessages }: MessageFormProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
const uploadFiles = async (filesToUpload: File[]) => {
|
||||
const formData = new FormData();
|
||||
formData.append('attachment', file);
|
||||
filesToUpload.forEach((file, index) => {
|
||||
formData.append(`attachments[${index}]`, file);
|
||||
});
|
||||
|
||||
try {
|
||||
setIsUploading(true);
|
||||
const response = await axiosClient.post(
|
||||
'/api/chat/attachment',
|
||||
'/api/chat/attachments',
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'message-Type': 'multipart/form-data',
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -79,28 +113,28 @@ const MessageForm = ({ contact, setMessages }: MessageFormProps) => {
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
setIsUploading(false);
|
||||
console.error('Failed to upload attachment: ', e);
|
||||
console.error('Failed to upload attachments: ', e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Sending message
|
||||
const submitMessage: SubmitHandler<Input> = async (data) => {
|
||||
if (!data.message && !file) return;
|
||||
if (!data.message && files.length === 0) return;
|
||||
|
||||
if (!socket) {
|
||||
console.error('Socket not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
let attachmentUrl = null;
|
||||
if (file) {
|
||||
const response = await uploadFile(file);
|
||||
if (response?.attachment_url) {
|
||||
attachmentUrl = response.attachment_url;
|
||||
} else {
|
||||
console.error('Failed to upload attachment');
|
||||
return;
|
||||
let attachmentUrls: string[] = [];
|
||||
if (files.length > 0) {
|
||||
try {
|
||||
const response = await uploadFiles(files.map((f) => f.file));
|
||||
console.log(response);
|
||||
attachmentUrls = response;
|
||||
} catch (e) {
|
||||
console.error('Failed to upload attachments');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,20 +147,20 @@ const MessageForm = ({ contact, setMessages }: MessageFormProps) => {
|
||||
message_id: 0,
|
||||
pending: true,
|
||||
tempId: tempId,
|
||||
attachment_url: attachmentUrl || null,
|
||||
attachment_urls: attachmentUrls,
|
||||
sender_id: 0,
|
||||
conversation_id: 0,
|
||||
};
|
||||
|
||||
setMessages((prevMessages) => [...prevMessages, tempMessage]);
|
||||
|
||||
socket.emit(
|
||||
'chat message',
|
||||
{
|
||||
message: data.message.trim(),
|
||||
recipient: contact.conversation_id,
|
||||
tempId: tempId,
|
||||
attachment_url: attachmentUrl,
|
||||
attachment_urls: attachmentUrls,
|
||||
recipient_id: contact.user_id,
|
||||
},
|
||||
(response: {
|
||||
status: string;
|
||||
@@ -149,20 +183,13 @@ const MessageForm = ({ contact, setMessages }: MessageFormProps) => {
|
||||
);
|
||||
|
||||
reset({ message: '' });
|
||||
handleClearFile();
|
||||
handleClearFiles();
|
||||
}
|
||||
console.log(
|
||||
`status: ${response.status}, tempId: ${response.tempId}, message: ${response.message}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
console.log('sent message: ', {
|
||||
message: data.message.trim(),
|
||||
recipient: contact.conversation_id,
|
||||
tempId: tempId,
|
||||
attachment_url: attachmentUrl,
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyPress: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
|
||||
@@ -174,8 +201,17 @@ const MessageForm = ({ contact, setMessages }: MessageFormProps) => {
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
setFile(e.target.files[0]);
|
||||
setValue('attachment', e.target.files);
|
||||
const newFiles: FileWithPreview[] = 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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -194,23 +230,52 @@ const MessageForm = ({ contact, setMessages }: MessageFormProps) => {
|
||||
return (
|
||||
<form onSubmit={handleSubmit(submitMessage)} className="w-full">
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{file && (
|
||||
<div className="flex items-center justify-between p-2 bg-green-50 rounded-md mx-2">
|
||||
<div className="flex items-center">
|
||||
<File className="w-4 h-4 mr-2" />
|
||||
<span className="text-black text-sm truncate">{file.name}</span>
|
||||
<span className="text-xs text-gray-500 ml-2">
|
||||
({Math.round(file.size / 1024)} KB)
|
||||
{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="mr-2 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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearFile}
|
||||
className="p-1 hover:bg-green-100 rounded-full"
|
||||
aria-label="Clear file"
|
||||
>
|
||||
<X className="w-4 h-4 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -246,20 +311,21 @@ const MessageForm = ({ contact, setMessages }: MessageFormProps) => {
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<label
|
||||
htmlFor="attachment"
|
||||
htmlFor="attachments"
|
||||
className="flex items-center justify-center hover:cursor-pointer p-1 rounded-full hover:bg-gray-800"
|
||||
>
|
||||
<Paperclip />
|
||||
</label>
|
||||
<input
|
||||
name="attachment"
|
||||
id="attachment"
|
||||
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>
|
||||
|
||||
@@ -54,13 +54,17 @@ function MessagesArea({
|
||||
};
|
||||
|
||||
const deleteMessage = async (message_id: number) => {
|
||||
const response = await axiosClient.delete(
|
||||
`/api/chat/messages/${message_id}`,
|
||||
);
|
||||
console.log('Delete message response: ', response.data);
|
||||
setMessages((prevMessages) =>
|
||||
prevMessages.filter((message) => message.message_id !== message_id),
|
||||
);
|
||||
try {
|
||||
const response = await axiosClient.delete(
|
||||
`/api/chat/messages/${message_id}`,
|
||||
);
|
||||
console.log('Delete message response: ', response.data);
|
||||
setMessages((prevMessages) =>
|
||||
prevMessages.filter((message) => message.message_id !== message_id),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('Failed to delete message');
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
@@ -139,6 +143,19 @@ function MessagesArea({
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
const AttachmentPreview = ({ url }: { url: string }) => {
|
||||
const isImage = url.match(/\.(jpg|jpeg|png|gif|bmp|webp)$/i);
|
||||
return isImage ? (
|
||||
<img
|
||||
src={url}
|
||||
alt="attachment"
|
||||
className="max-w-full max-h-64 object-contain rounded"
|
||||
/>
|
||||
) : (
|
||||
<FileBox url={url} />
|
||||
);
|
||||
};
|
||||
|
||||
const messageList = messages.map((msg: ChatMessages) => (
|
||||
<li
|
||||
className={`whitespace-pre-wrap ml-2 rounded p-1 group ${
|
||||
@@ -149,19 +166,18 @@ function MessagesArea({
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
{msg.sender}: {msg.message}
|
||||
{msg.attachment_url ? (
|
||||
<div className="mt-2">
|
||||
{msg.attachment_url.match(/\.(jpg|jpeg|png|gif|bmp|webp)$/i) ? (
|
||||
<img
|
||||
src={msg.attachment_url}
|
||||
alt="attachment"
|
||||
className="max-w-full max-h-64 object-contain rounded"
|
||||
/>
|
||||
) : (
|
||||
<FileBox url={msg.attachment_url} />
|
||||
)}
|
||||
{msg.attachment_urls && (
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
{msg.attachment_urls.length > 0
|
||||
? msg.attachment_urls.map((url, index) => (
|
||||
<AttachmentPreview
|
||||
key={`${msg.message_id}-${index}`}
|
||||
url={url}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
<Trash2
|
||||
className="opacity-0 group-hover:opacity-100 h-5 w-5 ml-2 flex-shrink-0 cursor-pointer text-gray-400 hover:text-red-500"
|
||||
|
||||
@@ -16,7 +16,7 @@ export type ChatMessages = {
|
||||
message_id: number;
|
||||
tempId: string;
|
||||
pending: boolean;
|
||||
attachment_url: string | null;
|
||||
attachment_urls: string[] | null;
|
||||
sender_id: number;
|
||||
conversation_id: number;
|
||||
};
|
||||
|
||||
@@ -72,7 +72,7 @@ async function createTables() {
|
||||
conversation_id INT REFERENCES Conversations(conversation_id) ON DELETE CASCADE,
|
||||
user_id INT REFERENCES Accounts(user_id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
attachment_url TEXT,
|
||||
attachment_urls TEXT[],
|
||||
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON Messages (conversation_id);
|
||||
@@ -156,16 +156,16 @@ async function insertMessage(
|
||||
senderId,
|
||||
conversation_id,
|
||||
content,
|
||||
attachmentUrl,
|
||||
attachmentUrls,
|
||||
) {
|
||||
console.log(
|
||||
`senderId: ${senderId}, conversation_id: ${conversation_id}, content: ${content}, attachmentUrl: ${attachmentUrl}`,
|
||||
`senderId: ${senderId}, conversation_id: ${conversation_id}, content: ${content}, attachmentUrl: ${attachmentUrls}`,
|
||||
);
|
||||
|
||||
const query = `
|
||||
INSERT INTO Messages (conversation_id, user_id, content, attachment_url)
|
||||
INSERT INTO Messages (conversation_id, user_id, content, attachment_urls)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING message_id, content, sent_at, attachment_url, user_id AS sender_id, conversation_id;
|
||||
RETURNING message_id, content, sent_at, attachment_urls, user_id AS sender_id, conversation_id;
|
||||
`;
|
||||
|
||||
try {
|
||||
@@ -173,8 +173,9 @@ async function insertMessage(
|
||||
conversation_id,
|
||||
senderId,
|
||||
content,
|
||||
attachmentUrl,
|
||||
attachmentUrls,
|
||||
]);
|
||||
console.log("insertmessageresult: ", result.rows);
|
||||
return result.rows[0];
|
||||
} catch (e) {
|
||||
console.error("Failed to insert message ", e);
|
||||
@@ -279,7 +280,7 @@ async function getMessages(user_id, conversation_id, limit = 50, cursor = 0) {
|
||||
m.message_id,
|
||||
m.content AS message,
|
||||
m.sent_at,
|
||||
m.attachment_url,
|
||||
m.attachment_urls,
|
||||
a.username AS sender
|
||||
FROM Messages m
|
||||
JOIN Accounts a ON m.user_id = a.user_id
|
||||
@@ -295,7 +296,7 @@ async function getMessages(user_id, conversation_id, limit = 50, cursor = 0) {
|
||||
m.message_id,
|
||||
m.content AS message,
|
||||
m.sent_at,
|
||||
m.attachment_url,
|
||||
m.attachment_urls,
|
||||
a.username AS sender
|
||||
FROM Messages m
|
||||
JOIN Accounts a ON m.user_id = a.user_id
|
||||
@@ -710,14 +711,11 @@ async function deleteMessage(user_id, message_id) {
|
||||
const deleteResult = await client.query(deleteMessageQuery, [message_id]);
|
||||
if (deleteResult.rowCount > 0) {
|
||||
console.log("Message deleted successfully");
|
||||
return { message: "Message deleted successfully" };
|
||||
} else {
|
||||
console.log("Failed to delete message");
|
||||
return { message: "Failed to delete message." };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to delete message ", e);
|
||||
return { message: "Failed to delete message" };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -255,6 +255,9 @@ app.post("/api/chat/contact/:contact", authorizeUser, async (req, res) => {
|
||||
usernameContact,
|
||||
true,
|
||||
);
|
||||
if (result.conversation_id === null) {
|
||||
res.status(500).json({ message: "Failed to create conversation" });
|
||||
}
|
||||
return res.status(200).json(result);
|
||||
} catch (e) {
|
||||
console.error("Failed to insert contact: ", e);
|
||||
@@ -317,19 +320,17 @@ app.get("/api/chat/messages/:contact", authorizeUser, async (req, res) => {
|
||||
});
|
||||
|
||||
app.post(
|
||||
"/api/chat/attachment",
|
||||
upload.single("attachment"),
|
||||
"/api/chat/attachments",
|
||||
upload.any("attachments"),
|
||||
authorizeUser,
|
||||
async (req, res) => {
|
||||
if (!req.file) {
|
||||
if (req.files?.length < 1) {
|
||||
return res.status(400).json({ message: "No file specified" });
|
||||
}
|
||||
const { finalName } = req.file;
|
||||
const url = `${process.env.ORIGIN}/attachments/${finalName}`;
|
||||
res.json({
|
||||
message: "File uploaded successfully",
|
||||
attachment_url: url,
|
||||
const attachment_urls = req.files.map((file) => {
|
||||
return `${process.env.ORIGIN}/attachments/${file.finalName}`;
|
||||
});
|
||||
res.json(attachment_urls);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -376,8 +377,8 @@ app.delete(
|
||||
}
|
||||
|
||||
const result = await deleteMessage(req.user.user_id, message_id);
|
||||
if (result.message) {
|
||||
return res.status(200).json({ message: result.message });
|
||||
if (result?.message) {
|
||||
return res.status(401).json({ message: result.message });
|
||||
} else {
|
||||
return res.status(200).json({ message: "Successfully deleted message" });
|
||||
}
|
||||
|
||||
@@ -64,13 +64,20 @@ function initializeSocket(io) {
|
||||
|
||||
try {
|
||||
const conversations = await getConversationsForUser(socket.user_id);
|
||||
conversations.push(socket.user_id);
|
||||
console.log("join conversations: ", conversations);
|
||||
socket.join(conversations);
|
||||
} catch (e) {
|
||||
console.error("(socket) Failed to get user conversations");
|
||||
}
|
||||
|
||||
socket.on("join socket", (msg) => {
|
||||
socket.join(msg.conversation_id);
|
||||
console.log("joinsocket: ", msg.conversation_id);
|
||||
});
|
||||
|
||||
socket.on("chat message", async (msg, callback) => {
|
||||
const { message, recipient, attachment_url } = msg;
|
||||
const { message, recipient, recipient_id, attachment_url } = msg;
|
||||
const sender = socket.username;
|
||||
|
||||
if (!message && !attachment_url) {
|
||||
@@ -116,7 +123,7 @@ function initializeSocket(io) {
|
||||
} = insertedMessage;
|
||||
console.log("(socket) received from chat message", msg);
|
||||
|
||||
io.to(username).to(recipient).emit("chat message", {
|
||||
io.to(username).to(recipient).to(recipient_id).emit("chat message", {
|
||||
sender,
|
||||
message: content,
|
||||
attachment_url,
|
||||
|
||||
Reference in New Issue
Block a user