improved attachments preview

This commit is contained in:
slawk0
2024-12-03 16:56:04 +01:00
parent e3aaf4675e
commit 083ad49137
6 changed files with 182 additions and 94 deletions

View File

@@ -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>

View File

@@ -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"

View File

@@ -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;
};

View File

@@ -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" };
}
}

View File

@@ -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" });
}

View File

@@ -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,