added file audio video and files
Continuous integration / Check (push) Failing after 10s
Continuous integration / Rustfmt (push) Successful in 36s
Continuous integration / build (push) Successful in 36s
Continuous integration / build-release (push) Successful in 57s

This commit is contained in:
wt committed 2026-02-15 16:55:51 +07:00
1 parent 8cb6143bac
commit 9016889a78
11 files changed
+988 -7

No files matched your search

+2 -1
View File
@@ -10,7 +10,8 @@
"files": {
"avatars_dir": "web/avatars",
"web_dir": "web",
"max_avatar_size": 5242880
"max_avatar_size": 5242880,
"upload_dir": "web/uploads"
},
"limits": {
"max_message_length": 4096,
+1
View File
@@ -6,4 +6,5 @@ pub mod profile;
pub mod register;
pub mod search;
pub mod security;
pub mod upload;
pub mod websock;
+49
View File
@@ -4,6 +4,7 @@ use crate::utils;
use axum::extract::{Json, State, ws::Message};
use sqlx::Row;
use std::sync::Arc;
use tokio::fs;
// Delete message endpoint
pub async fn delete_message_handler(
@@ -66,6 +67,32 @@ pub async fn delete_message_handler(
});
}
// Получаем тип сообщения и его содержимое
let message_details = sqlx::query("SELECT message_type, content FROM messages WHERE id = ?")
.bind(&payload.message_id)
.fetch_optional(&state.db_pool)
.await;
let (message_type, content) = match message_details {
Ok(Some(row)) => (
row.get::<String, _>("message_type"),
row.get::<String, _>("content"),
),
Ok(None) => {
return Json(model::DeleteMessageResponse {
success: false,
message: "Message not found".to_string(),
});
}
Err(e) => {
eprintln!("Error fetching message details: {}", e);
return Json(model::DeleteMessageResponse {
success: false,
message: "Database error".to_string(),
});
}
};
if payload.for_everyone {
// Удаление для всех
if from_user != username {
@@ -75,6 +102,28 @@ pub async fn delete_message_handler(
});
}
// Если это сообщение с файлом, удаляем физический файл
if message_type != "text" {
// Создаем временную структуру для извлечения пути к файлу
#[derive(serde::Deserialize)]
struct FilePathExtractor {
filepath: String,
}
if let Ok(file_extractor) = serde_json::from_str::<FilePathExtractor>(&content) {
let file_path =
format!("{}{}", state.config.files.web_dir, file_extractor.filepath);
// Пытаемся удалить файл из файловой системы
if let Err(e) = fs::remove_file(&file_path).await {
eprintln!("Error deleting file {}: {}", file_path, e);
// Продолжаем выполнение, даже если удаление файла не удалось
} else {
println!("Deleted file: {}", file_path);
}
}
}
let result = sqlx::query("UPDATE messages SET deleted_for_everyone = TRUE WHERE id = ?")
.bind(&payload.message_id)
.execute(&state.db_pool)
+296
View File
@@ -0,0 +1,296 @@
use crate::model::{AudioInfo, FileInfo, PhotoInfo, VideoInfo};
use crate::state;
use crate::utils;
use axum::extract::{Multipart, State};
use axum::response::Json;
use image::GenericImageView;
use std::path::Path;
use std::sync::Arc;
use tokio::fs;
use tokio::io::AsyncWriteExt;
pub async fn upload_file_handler(
State(state): State<Arc<state::AppState>>,
headers: axum::http::HeaderMap,
mut multipart: Multipart,
) -> Json<serde_json::Value> {
// Extract token from header
let token = if let Some(auth_header) = headers.get("authorization") {
auth_header.to_str().unwrap_or("").replace("Bearer ", "")
} else {
return Json(serde_json::json!({"success": false, "error": "No token"}));
};
// Get current user from token
match utils::verify_token(&state.db_pool, &token).await {
Ok(Some(_user)) => {} // We verify the token is valid, but don't need the username for file upload
_ => return Json(serde_json::json!({"success": false, "error": "Invalid token"})),
};
// Process uploaded file
while let Some(field) = match multipart.next_field().await {
Ok(field_opt) => field_opt,
Err(e) => {
eprintln!("Error reading multipart field: {}", e);
return Json(
serde_json::json!({"success": false, "error": "Error reading multipart data"}),
);
}
} {
let name = field.name().unwrap_or("unknown").to_string();
if name == "file" {
// Get field properties before consuming the field
let filename = field.file_name().unwrap_or("unknown").to_string();
let content_type = field
.content_type()
.unwrap_or("application/octet-stream")
.to_string();
let file_data = match field.bytes().await {
Ok(data) => {
if data.is_empty() {
eprintln!(
"Received empty file data for file: {}, content-type: {}",
filename, content_type
);
return Json(
serde_json::json!({"success": false, "error": "Empty file received"}),
);
}
// Check file size against the configured limit
if data.len() > state.config.files.max_upload_size {
eprintln!(
"File {} exceeds size limit. Size: {} bytes, Limit: {} bytes",
filename,
data.len(),
state.config.files.max_upload_size
);
return Json(
serde_json::json!({"success": false, "error": "File exceeds size limit"}),
);
}
eprintln!(
"Successfully read {} bytes for file: {}, content-type: {}",
data.len(),
filename,
content_type
);
data
}
Err(e) => {
eprintln!(
"Error reading file data for {}: {} - Request may not contain proper file data",
filename, e
);
eprintln!("Content-Type: {}, Filename: {}", content_type, filename);
return Json(
serde_json::json!({"success": false, "error": format!("No valid file data found in request for {}", filename)}),
);
}
};
// Determine file type and validate
let file_type = determine_file_type(&content_type, &filename);
if file_type.is_none() {
return Json(
serde_json::json!({"success": false, "error": "Unsupported file type"}),
);
}
// Generate unique filename to prevent conflicts
let ext = Path::new(&filename)
.extension()
.and_then(std::ffi::OsStr::to_str)
.unwrap_or("tmp");
let unique_filename = format!(
"{}_{}.{}",
chrono::Utc::now().timestamp(),
uuid::Uuid::new_v4().simple(),
ext
);
// Create upload directory if it doesn't exist
let upload_dir = &state.config.files.upload_dir;
if let Err(e) = fs::create_dir_all(upload_dir).await {
eprintln!("Error creating upload directory: {}", e);
return Json(
serde_json::json!({"success": false, "error": "Error creating upload directory"}),
);
}
// Save file
let filepath = format!("{}/{}", upload_dir, unique_filename);
eprintln!("Saving file to: {}", filepath);
let filepathweb = format!("/uploads/{}", unique_filename); // Web-accessible path - files are in web/uploads
let mut file = match fs::File::create(&filepath).await {
Ok(f) => f,
Err(e) => {
eprintln!("Error creating file: {}", e);
return Json(
serde_json::json!({"success": false, "error": "Error creating file"}),
);
}
};
if let Err(e) = file.write_all(&file_data).await {
eprintln!("Error writing file: {}", e);
return Json(serde_json::json!({"success": false, "error": "Error writing file"}));
}
drop(file); // Close the file
// Get file size
let metadata = match fs::metadata(&filepath).await {
Ok(m) => m,
Err(e) => {
eprintln!("Error getting file metadata: {}", e);
return Json(
serde_json::json!({"success": false, "error": "Error getting file metadata"}),
);
}
};
let size = metadata.len();
// Prepare response based on file type
let response = match file_type.unwrap() {
FileType::Image => {
// Try to get image dimensions
let img_result = image::open(&filepath);
let (width, height) = if let Ok(img) = img_result {
let (w, h) = img.dimensions();
(w, h)
} else {
(0, 0) // fallback if we can't read dimensions
};
let photo_info = PhotoInfo {
filename: filename.clone(),
filepath: filepathweb.clone(),
size,
width,
height,
mime_type: content_type.clone(),
};
Json(serde_json::json!({
"success": true,
"file_type": "photo",
"info": photo_info
}))
}
FileType::Video => {
// For now, we'll just return basic info for videos
// In a real implementation, you might want to extract duration using ffmpeg
let video_info = VideoInfo {
filename: filename.clone(),
filepath: filepathweb.clone(),
size,
duration: None, // Duration extraction would require additional libraries
mime_type: content_type.clone(),
};
Json(serde_json::json!({
"success": true,
"file_type": "video",
"info": video_info
}))
}
FileType::Audio => {
// For now, we'll just return basic info for audio
// In a real implementation, you might want to extract duration using ffmpeg
let audio_info = AudioInfo {
filename: filename.clone(),
filepath: filepathweb.clone(),
size,
duration: None, // Duration extraction would require additional libraries
mime_type: content_type.clone(),
};
Json(serde_json::json!({
"success": true,
"file_type": "audio",
"info": audio_info
}))
}
FileType::Generic => {
let file_info = FileInfo {
filename: filename.clone(),
filepath: filepathweb.clone(),
size,
mime_type: content_type.clone(),
};
Json(serde_json::json!({
"success": true,
"file_type": "file",
"info": file_info
}))
}
};
return response; // Return the response from the function
}
}
Json(serde_json::json!({"success": false, "error": "No file found"}))
}
enum FileType {
Image,
Video,
Audio,
Generic,
}
fn determine_file_type(content_type: &str, filename: &str) -> Option<FileType> {
let lower_ct = content_type.to_lowercase();
let lower_fn = filename.to_lowercase();
// Check content type first
if lower_ct.starts_with("image/") {
return Some(FileType::Image);
} else if lower_ct.starts_with("video/") {
return Some(FileType::Video);
} else if lower_ct.starts_with("audio/") {
return Some(FileType::Audio);
}
// Fallback to file extension - expanded list of supported formats
if lower_fn.ends_with(".jpg")
|| lower_fn.ends_with(".jpeg")
|| lower_fn.ends_with(".png")
|| lower_fn.ends_with(".gif")
|| lower_fn.ends_with(".webp")
|| lower_fn.ends_with(".bmp")
|| lower_fn.ends_with(".svg")
|| lower_fn.ends_with(".tiff")
|| lower_fn.ends_with(".tif")
{
return Some(FileType::Image);
} else if lower_fn.ends_with(".mp4")
|| lower_fn.ends_with(".avi")
|| lower_fn.ends_with(".mov")
|| lower_fn.ends_with(".mkv")
|| lower_fn.ends_with(".webm")
|| lower_fn.ends_with(".wmv")
|| lower_fn.ends_with(".flv")
|| lower_fn.ends_with(".m4v")
|| lower_fn.ends_with(".3gp")
|| lower_fn.ends_with(".mpeg")
|| lower_fn.ends_with(".mpg")
{
return Some(FileType::Video);
} else if lower_fn.ends_with(".mp3")
|| lower_fn.ends_with(".wav")
|| lower_fn.ends_with(".ogg")
|| lower_fn.ends_with(".flac")
|| lower_fn.ends_with(".m4a")
|| lower_fn.ends_with(".aac")
|| lower_fn.ends_with(".wma")
|| lower_fn.ends_with(".opus")
{
return Some(FileType::Audio);
}
Some(FileType::Generic)
}
+4
View File
@@ -26,7 +26,9 @@ pub struct DatabaseConfig {
pub struct FilesConfig {
pub avatars_dir: String,
pub web_dir: String,
pub upload_dir: String,
pub max_avatar_size: usize,
pub max_upload_size: usize,
}
#[derive(Debug, Deserialize, Clone)]
@@ -53,7 +55,9 @@ impl AppConfig {
[files]
avatars_dir = "web/avatars"
web_dir = "web"
upload_dir = "uploads"
max_avatar_size = 5242880
max_upload_size = 52428800 # 50MB
[limits]
max_message_length = 4096
+4
View File
@@ -1,5 +1,6 @@
use axum::{
Router,
extract::DefaultBodyLimit,
routing::{get, post},
};
use std::{collections::HashMap, net::SocketAddr, sync::Arc};
@@ -25,6 +26,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create directories if they don't exist
tokio::fs::create_dir_all(&config.files.avatars_dir).await?;
tokio::fs::create_dir_all(&config.files.web_dir).await?;
tokio::fs::create_dir_all(&config.files.upload_dir).await?;
// Initialize database with configured URL
let db_pool = db::init_db(&config.database.url).await?;
@@ -60,8 +62,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
"/api/upload-avatar",
post(api::profile::avatar::upload_avatar_handler),
)
.route("/api/upload", post(api::upload::upload_file_handler))
.route("/api/profile", get(api::profile::get::profile_handler))
.nest_service("/", ServeDir::new(&state.config.files.web_dir))
.layer(DefaultBodyLimit::max(state.config.files.max_upload_size))
.with_state(state.clone());
let addr = format!("{}:{}", state.config.server.host, state.config.server.port)
+160 -2
View File
@@ -82,12 +82,170 @@ pub struct UserProfile {
pub avatar_url: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub enum MessageType {
#[serde(rename = "text")]
Text,
#[serde(rename = "file")]
File,
#[serde(rename = "photo")]
Photo,
#[serde(rename = "video")]
Video,
#[serde(rename = "audio")]
Audio,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ChatMessage {
pub id: String,
pub from_user: String,
pub to_user: String,
pub content: String,
pub content: String, // Store serialized content as JSON string
pub timestamp: i64,
pub message_type: String,
pub message_type: MessageType,
}
// impl ChatMessage {
// pub fn new_text(from_user: String, to_user: String, text: String) -> Self {
// ChatMessage {
// id: uuid::Uuid::new_v4().to_string(),
// from_user,
// to_user,
// content: text,
// timestamp: chrono::Utc::now().timestamp(),
// message_type: MessageType::Text,
// }
// }
// pub fn new_file(from_user: String, to_user: String, file_info: FileInfo) -> Self {
// ChatMessage {
// id: uuid::Uuid::new_v4().to_string(),
// from_user,
// to_user,
// content: serde_json::to_string(&file_info).unwrap_or_default(),
// timestamp: chrono::Utc::now().timestamp(),
// message_type: MessageType::File,
// }
// }
// pub fn new_photo(from_user: String, to_user: String, photo_info: PhotoInfo) -> Self {
// ChatMessage {
// id: uuid::Uuid::new_v4().to_string(),
// from_user,
// to_user,
// content: serde_json::to_string(&photo_info).unwrap_or_default(),
// timestamp: chrono::Utc::now().timestamp(),
// message_type: MessageType::Photo,
// }
// }
// pub fn new_video(from_user: String, to_user: String, video_info: VideoInfo) -> Self {
// ChatMessage {
// id: uuid::Uuid::new_v4().to_string(),
// from_user,
// to_user,
// content: serde_json::to_string(&video_info).unwrap_or_default(),
// timestamp: chrono::Utc::now().timestamp(),
// message_type: MessageType::Video,
// }
// }
// pub fn new_audio(from_user: String, to_user: String, audio_info: AudioInfo) -> Self {
// ChatMessage {
// id: uuid::Uuid::new_v4().to_string(),
// from_user,
// to_user,
// content: serde_json::to_string(&audio_info).unwrap_or_default(),
// timestamp: chrono::Utc::now().timestamp(),
// message_type: MessageType::Audio,
// }
// }
// pub fn get_content_as_text(&self) -> Option<String> {
// match self.message_type {
// MessageType::Text => Some(self.content.clone()),
// _ => None,
// }
// }
// pub fn get_content_as_file(&self) -> Option<FileInfo> {
// if matches!(self.message_type, MessageType::File) {
// serde_json::from_str(&self.content).ok()
// } else {
// None
// }
// }
// pub fn get_content_as_photo(&self) -> Option<PhotoInfo> {
// if matches!(self.message_type, MessageType::Photo) {
// serde_json::from_str(&self.content).ok()
// } else {
// None
// }
// }
// pub fn get_content_as_video(&self) -> Option<VideoInfo> {
// if matches!(self.message_type, MessageType::Video) {
// serde_json::from_str(&self.content).ok()
// } else {
// None
// }
// }
// pub fn get_content_as_audio(&self) -> Option<AudioInfo> {
// if matches!(self.message_type, MessageType::Audio) {
// serde_json::from_str(&self.content).ok()
// } else {
// None
// }
// }
// }
// #[derive(Debug, Serialize, Deserialize, Clone)]
// pub enum MessageContent {
// #[serde(rename = "text")]
// Text(String),
// #[serde(rename = "file")]
// File(FileInfo),
// #[serde(rename = "photo")]
// Photo(PhotoInfo),
// #[serde(rename = "video")]
// Video(VideoInfo),
// }
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct FileInfo {
pub filename: String,
pub filepath: String,
pub size: u64,
pub mime_type: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PhotoInfo {
pub filename: String,
pub filepath: String,
pub size: u64,
pub width: u32,
pub height: u32,
pub mime_type: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct VideoInfo {
pub filename: String,
pub filepath: String,
pub size: u64,
pub duration: Option<f64>, // in seconds
pub mime_type: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AudioInfo {
pub filename: String,
pub filepath: String,
pub size: u64,
pub duration: Option<f64>, // in seconds
pub mime_type: String,
}
+20 -2
View File
@@ -121,13 +121,23 @@ pub async fn get_message_history(
let mut messages = Vec::new();
for row in rows {
let message_type_str: String = row.get("message_type");
let message_type: model::MessageType = match message_type_str.as_str() {
"text" => model::MessageType::Text,
"file" => model::MessageType::File,
"photo" => model::MessageType::Photo,
"video" => model::MessageType::Video,
"audio" => model::MessageType::Audio,
_ => model::MessageType::Text, // Default fallback
};
let message = model::ChatMessage {
id: row.get("id"),
from_user: row.get("from_user"),
to_user: row.get("to_user"),
content: row.get("content"),
timestamp: row.get("timestamp"),
message_type: row.get("message_type"),
message_type,
};
messages.push(message);
}
@@ -148,6 +158,14 @@ pub async fn get_message_history(
// }
pub async fn save_message(pool: &SqlitePool, msg: &model::ChatMessage) -> Result<(), sqlx::Error> {
let message_type_str = match msg.message_type {
model::MessageType::Text => "text",
model::MessageType::File => "file",
model::MessageType::Photo => "photo",
model::MessageType::Video => "video",
model::MessageType::Audio => "audio",
};
sqlx::query(
"INSERT OR REPLACE INTO messages (id, from_user, to_user, content, timestamp, message_type) VALUES (?, ?, ?, ?, ?, ?)",
)
@@ -156,7 +174,7 @@ pub async fn save_message(pool: &SqlitePool, msg: &model::ChatMessage) -> Result
.bind(&msg.to_user)
.bind(&msg.content)
.bind(msg.timestamp)
.bind(&msg.message_type)
.bind(message_type_str)
.execute(pool)
.await?;
+164
View File
@@ -1285,3 +1285,167 @@ body.modal-open {
.modal-btn-delete-for-me:hover {
background: var(--primary-hover);
}
/* File message styles */
.file-message {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
background: var(--bg-tertiary);
border-radius: 8px;
max-width: 300px;
}
.file-icon {
font-size: 24px;
}
.file-info {
flex: 1;
min-width: 0;
}
.file-name {
font-weight: 500;
font-size: 13px;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-size,
.file-type {
font-size: 11px;
color: var(--text-secondary);
}
.download-btn {
background: var(--primary-color);
color: white;
border: none;
border-radius: 4px;
padding: 4px 8px;
font-size: 11px;
cursor: pointer;
text-decoration: none;
display: inline-block;
margin-top: 5px;
}
.download-btn:hover {
background: var(--primary-hover);
}
/* Media message styles */
.media-message {
display: flex;
flex-direction: column;
gap: 5px;
max-width: 300px;
}
.message-image {
max-width: 100%;
max-height: 200px;
border-radius: 8px;
cursor: pointer;
object-fit: cover;
}
.message-video {
max-width: 100%;
max-height: 200px;
border-radius: 8px;
}
.message-audio {
max-width: 100%;
border-radius: 8px;
}
/* Image viewer modal */
.image-viewer-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.9);
display: flex;
justify-content: center;
align-items: center;
z-index: 2000;
}
.image-viewer-content {
position: relative;
max-width: 90%;
max-height: 90%;
}
.image-viewer-large {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.close-image-viewer {
position: absolute;
top: -40px;
right: 0;
color: white;
font-size: 35px;
font-weight: bold;
cursor: pointer;
z-index: 2001;
}
.close-image-viewer:hover {
color: #ccc;
}
/* Upload progress styles */
.upload-progress {
padding: 10px;
margin: 5px 0;
background: var(--bg-tertiary);
border-radius: 10px;
align-self: flex-start;
max-width: 85%;
}
.progress-text {
font-size: 12px;
margin-bottom: 5px;
color: var(--text-secondary);
}
.progress-bar {
width: 100%;
height: 6px;
background: var(--bg-secondary);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--primary-color);
width: 0%;
transition: width 0.3s ease;
animation: progressAnimation 2s infinite;
}
@keyframes progressAnimation {
0% {
width: 0%;
}
50% {
width: 100%;
}
100% {
width: 0%;
}
}
+12 -1
View File
@@ -194,7 +194,18 @@
disabled
rows="1"
></textarea>
<button class="add-button">&plus;</button>
<input
type="file"
id="fileInput"
class="file-input"
style="display: none"
/>
<button
class="add-button"
onclick="document.getElementById('fileInput').click()"
>
&plus;
</button>
<button
onclick="sendMessage()"
class="send-button"
+276 -1
View File
@@ -142,7 +142,67 @@ function displayMessage(msg, scrollToBottom) {
const contentDiv = document.createElement("div");
contentDiv.className = "message-content";
contentDiv.textContent = msg.content;
// Handle different message types
if (msg.message_type === "text") {
contentDiv.textContent = msg.content;
} else if (msg.message_type === "file") {
const fileInfo = JSON.parse(msg.content);
contentDiv.innerHTML = `
<div class="file-message">
<div class="file-icon">📄</div>
<div class="file-info">
<div class="file-name">${fileInfo.filename}</div>
<div class="file-size">${formatFileSize(fileInfo.size)}</div>
<div class="file-type">${fileInfo.mime_type}</div>
</div>
<a href="${fileInfo.filepath}" download="${fileInfo.filename}" class="download-btn">⬇️ Download</a>
</div>
`;
} else if (msg.message_type === "photo") {
const photoInfo = JSON.parse(msg.content);
contentDiv.innerHTML = `
<div class="media-message">
<img src="${photoInfo.filepath}" alt="Photo" class="message-image" onclick="viewImage('${photoInfo.filepath}')"/>
<div class="file-info">
<div class="file-name">${photoInfo.filename}</div>
<div class="file-size">${formatFileSize(photoInfo.size)}</div>
</div>
<a href="${photoInfo.filepath}" download="${photoInfo.filename}" class="download-btn">⬇️ Download</a>
</div>
`;
} else if (msg.message_type === "video") {
const videoInfo = JSON.parse(msg.content);
contentDiv.innerHTML = `
<div class="media-message">
<video controls class="message-video">
<source src="${videoInfo.filepath}" type="${videoInfo.mime_type}">
Your browser does not support the video tag.
</video>
<div class="file-info">
<div class="file-name">${videoInfo.filename}</div>
<div class="file-size">${formatFileSize(videoInfo.size)}</div>
</div>
<a href="${videoInfo.filepath}" download="${videoInfo.filename}" class="download-btn">⬇️ Download</a>
</div>
`;
} else if (msg.message_type === "audio") {
const audioInfo = JSON.parse(msg.content);
contentDiv.innerHTML = `
<div class="media-message">
<audio controls class="message-audio">
<source src="${audioInfo.filepath}" type="${audioInfo.mime_type}">
Your browser does not support the audio tag.
</audio>
<div class="file-info">
<div class="file-name">${audioInfo.filename}</div>
<div class="file-size">${formatFileSize(audioInfo.size)}</div>
</div>
<a href="${audioInfo.filepath}" download="${audioInfo.filename}" class="download-btn">⬇️ Download</a>
</div>
`;
}
messageDiv.appendChild(contentDiv);
const timeDiv = document.createElement("div");
@@ -182,6 +242,40 @@ function displayMessage(msg, scrollToBottom) {
}
}
function formatFileSize(bytes) {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
function viewImage(imageUrl) {
// Create a modal to view the image
const modal = document.createElement("div");
modal.className = "image-viewer-modal";
modal.innerHTML = `
<div class="image-viewer-content">
<span class="close-image-viewer">&times;</span>
<img src="${imageUrl}" alt="Full size image" class="image-viewer-large">
</div>
`;
document.body.appendChild(modal);
// Close modal when clicking on close button
modal.querySelector(".close-image-viewer").onclick = function () {
document.body.removeChild(modal);
};
// Close modal when clicking outside the image
modal.onclick = function (event) {
if (event.target === modal) {
document.body.removeChild(modal);
}
};
}
function addSystemMessage(content) {
const chatMessages = document.getElementById("chatMessages");
@@ -234,6 +328,163 @@ function sendMessage() {
setTimeout(() => loadChats(), 100);
}
// Queue to handle file uploads sequentially
let uploadQueue = [];
let isUploading = false;
// Function to process the upload queue
async function processUploadQueue() {
if (isUploading || uploadQueue.length === 0) {
return;
}
isUploading = true;
const file = uploadQueue.shift(); // Get the first file in the queue
try {
await sendFileMessageInternal(file);
} catch (error) {
console.error("Upload failed for file:", error);
} finally {
isUploading = false;
// Process next file in queue if available
setTimeout(processUploadQueue, 100); // Small delay to allow server to settle
}
}
// Internal function to handle the actual file upload
async function sendFileMessageInternal(file) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
showAlert("Please connect to the server first", "error");
return;
}
if (!currentRecipient) {
showAlert("Please select a user to chat with", "error");
return;
}
// Show upload progress
const chatMessages = document.getElementById("chatMessages");
const progressDiv = document.createElement("div");
progressDiv.className = "upload-progress";
progressDiv.innerHTML = `<div class="progress-text">Uploading ${file.name}</div><div class="progress-bar"><div class="progress-fill"></div></div>`;
chatMessages.appendChild(progressDiv);
try {
// Create FormData for file upload
const formData = new FormData();
formData.append("file", file);
// Upload file to server with better error handling
const response = await fetch("/api/upload", {
method: "POST",
headers: {
Authorization: `Bearer ${currentToken}`,
},
body: formData,
}).catch((error) => {
console.error("Network error during upload:", error);
throw new Error("Network error during upload. Please try again.");
});
// Remove progress indicator
progressDiv.remove();
if (!response.ok) {
throw new Error(`Upload failed: ${response.status}`);
}
const result = await response.json();
if (!result.success) {
throw new Error(result.error || "Upload failed");
}
// Create message object based on file type
let message;
let messageType;
if (result.file_type === "photo") {
messageType = "photo";
message = {
id: generateId(),
from_user: currentUser,
to_user: currentRecipient,
content: JSON.stringify(result.info),
timestamp: Date.now(),
message_type: messageType,
};
} else if (result.file_type === "video") {
messageType = "video";
message = {
id: generateId(),
from_user: currentUser,
to_user: currentRecipient,
content: JSON.stringify(result.info),
timestamp: Date.now(),
message_type: messageType,
};
} else if (result.file_type === "audio") {
messageType = "audio";
message = {
id: generateId(),
from_user: currentUser,
to_user: currentRecipient,
content: JSON.stringify(result.info),
timestamp: Date.now(),
message_type: messageType,
};
} else {
messageType = "file";
message = {
id: generateId(),
from_user: currentUser,
to_user: currentRecipient,
content: JSON.stringify(result.info),
timestamp: Date.now(),
message_type: messageType,
};
}
console.log("Sending file message:", message);
ws.send(JSON.stringify(message));
setTimeout(() => loadChats(), 100);
} catch (error) {
// Remove progress indicator
progressDiv.remove();
console.error("Upload error:", error);
showAlert(`Failed to upload file: ${error.message}`, "error");
}
}
// Function to send a file message (adds to queue)
async function sendFileMessage(file) {
// Check if file is already in queue to prevent duplicates
const fileName = file.name;
const fileSize = file.size;
const isInQueue = uploadQueue.some(
(item) => item.name === fileName && item.size === fileSize,
);
if (isInQueue) {
console.log(
`File ${fileName} is already in upload queue, skipping duplicate.`,
);
return;
}
// Add file to upload queue
uploadQueue.push(file);
console.log(
`File ${file.name} added to upload queue. Queue length: ${uploadQueue.length}`,
);
// Process the queue
setTimeout(processUploadQueue, 0);
}
// Event listeners
document
.getElementById("messageInput")
@@ -243,3 +494,27 @@ document
sendMessage();
}
});
// Track if event listener is already attached to prevent duplicates
if (!window.fileInputListenerAttached) {
// File input event listener
document.getElementById("fileInput").addEventListener("change", function (e) {
const file = e.target.files[0];
if (file) {
// Add file to upload queue regardless of current upload status
// The queue system will handle the sequencing
uploadQueue.push(file);
console.log(
`File ${file.name} added to upload queue. Queue length: ${uploadQueue.length}`,
);
// Process the queue
setTimeout(processUploadQueue, 0);
// Reset the input so the same file can be selected again
e.target.value = "";
}
});
window.fileInputListenerAttached = true;
}