Merge pull request 'develop' (#3) from develop into main
Reviewed-on: https://gitea.home.com:3000/TolaMironcenko/msgs/pulls/3
This commit was merged in pull request #3.
This commit is contained in:
+19
-19
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"server": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 3333,
|
||||
"max_connections": 10000
|
||||
},
|
||||
"database": {
|
||||
"url": "sqlite:chat.db"
|
||||
},
|
||||
"files": {
|
||||
"avatars_dir": "web/avatars",
|
||||
"web_dir": "web",
|
||||
"max_avatar_size": 5242880
|
||||
},
|
||||
"limits": {
|
||||
"max_message_length": 4096,
|
||||
"max_username_length": 50,
|
||||
"min_password_length": 6
|
||||
}
|
||||
}
|
||||
"server": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 3333,
|
||||
"max_connections": 10000
|
||||
},
|
||||
"database": {
|
||||
"url": "chat.db"
|
||||
},
|
||||
"files": {
|
||||
"avatars_dir": "web/avatars",
|
||||
"web_dir": "web",
|
||||
"max_avatar_size": 5242880
|
||||
},
|
||||
"limits": {
|
||||
"max_message_length": 4096,
|
||||
"max_username_length": 50,
|
||||
"min_password_length": 6
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
pub mod chats;
|
||||
pub mod debug;
|
||||
pub mod login;
|
||||
pub mod messages;
|
||||
pub mod profile;
|
||||
pub mod register;
|
||||
pub mod search;
|
||||
pub mod security;
|
||||
pub mod websock;
|
||||
@@ -0,0 +1,214 @@
|
||||
use crate::model;
|
||||
use crate::state;
|
||||
use crate::utils;
|
||||
use axum::extract::{Json, State};
|
||||
use sqlx::Row;
|
||||
use std::sync::Arc;
|
||||
|
||||
// УПРОЩЕННАЯ версия получения чатов
|
||||
pub async fn get_chats_handler(
|
||||
State(state): State<Arc<state::AppState>>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Json<Vec<model::ChatPartner>> {
|
||||
// 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(Vec::new());
|
||||
};
|
||||
|
||||
// Get current user from token
|
||||
let username = match utils::verify_token(&state.db_pool, &token).await {
|
||||
Ok(Some(user)) => user,
|
||||
_ => return Json(Vec::new()),
|
||||
};
|
||||
|
||||
println!("🔍 Fetching chats for user: {}", username);
|
||||
|
||||
// ПРОСТОЙ И ПРЯМОЙ ЗАПРОС: находим всех пользователей, с которыми есть сообщения
|
||||
let chat_partners = sqlx::query(
|
||||
r#"
|
||||
-- Находим всех уникальных собеседников
|
||||
SELECT DISTINCT
|
||||
CASE
|
||||
WHEN from_user = ? THEN to_user
|
||||
ELSE from_user
|
||||
END as partner_username
|
||||
FROM messages
|
||||
WHERE (from_user = ? OR to_user = ?)
|
||||
AND deleted_for_everyone = FALSE
|
||||
AND ((from_user = ? AND deleted_for_sender = FALSE) OR (to_user = ? AND deleted_for_receiver = FALSE))
|
||||
"#
|
||||
)
|
||||
.bind(&username)
|
||||
.bind(&username)
|
||||
.bind(&username)
|
||||
.bind(&username)
|
||||
.bind(&username)
|
||||
.fetch_all(&state.db_pool)
|
||||
.await;
|
||||
|
||||
let partner_usernames = match chat_partners {
|
||||
Ok(rows) => {
|
||||
let partners: Vec<String> = rows
|
||||
.iter()
|
||||
.map(|row| row.get::<String, _>("partner_username"))
|
||||
.collect();
|
||||
println!("🤝 Found partner usernames: {:?}", partners);
|
||||
partners
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("❌ Error fetching partner usernames: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
if partner_usernames.is_empty() {
|
||||
println!("❌ No partners found for user {}", username);
|
||||
|
||||
// Проверим, есть ли вообще сообщения у пользователя
|
||||
let message_count = sqlx::query(
|
||||
"SELECT COUNT(*) as count FROM messages WHERE (from_user = ? OR to_user = ?) AND deleted_for_everyone = FALSE"
|
||||
)
|
||||
.bind(&username)
|
||||
.bind(&username)
|
||||
.fetch_one(&state.db_pool)
|
||||
.await;
|
||||
|
||||
match message_count {
|
||||
Ok(row) => {
|
||||
let count: i64 = row.get("count");
|
||||
println!(
|
||||
"📊 User {} has {} total messages in database",
|
||||
username, count
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("❌ Error counting messages: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
return Json(Vec::new());
|
||||
}
|
||||
|
||||
// Теперь получаем детальную информацию о каждом собеседнике
|
||||
let mut chats = Vec::new();
|
||||
|
||||
for partner_username in partner_usernames {
|
||||
println!("📞 Getting details for partner: {}", partner_username);
|
||||
|
||||
// Получаем информацию о пользователе
|
||||
let user_result =
|
||||
sqlx::query("SELECT username, phone, avatar_url FROM users WHERE username = ?")
|
||||
.bind(&partner_username)
|
||||
.fetch_optional(&state.db_pool)
|
||||
.await;
|
||||
|
||||
match user_result {
|
||||
Ok(Some(user_row)) => {
|
||||
let partner_name: String = user_row.get("username");
|
||||
let phone: String = user_row.get("phone");
|
||||
let avatar_url: Option<String> = user_row.get("avatar_url");
|
||||
|
||||
// Получаем последнее сообщение
|
||||
let last_message_result = sqlx::query(
|
||||
r#"
|
||||
SELECT content, timestamp
|
||||
FROM messages
|
||||
WHERE ((from_user = ? AND to_user = ?) OR (from_user = ? AND to_user = ?))
|
||||
AND deleted_for_everyone = FALSE
|
||||
AND ((from_user = ? AND deleted_for_sender = FALSE) OR (to_user = ? AND deleted_for_receiver = FALSE))
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 1
|
||||
"#
|
||||
)
|
||||
.bind(&username) // текущий пользователь
|
||||
.bind(&partner_name) // собеседник
|
||||
.bind(&partner_name) // собеседник
|
||||
.bind(&username) // текущий пользователь
|
||||
.bind(&username) // для проверки deleted_for_sender
|
||||
.bind(&username) // для проверки deleted_for_receiver
|
||||
.fetch_optional(&state.db_pool)
|
||||
.await;
|
||||
|
||||
let (last_message, last_message_time) = match last_message_result {
|
||||
Ok(Some(row)) => {
|
||||
let content: String = row.get("content");
|
||||
let timestamp: i64 = row.get("timestamp");
|
||||
println!(
|
||||
"💬 Last message with {}: '{}' at {}",
|
||||
partner_name, content, timestamp
|
||||
);
|
||||
(Some(content), Some(timestamp))
|
||||
}
|
||||
Ok(None) => {
|
||||
println!("❌ No last message found for chat with {}", partner_name);
|
||||
(None, None)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("❌ Error fetching last message: {}", e);
|
||||
(None, None)
|
||||
}
|
||||
};
|
||||
|
||||
// Получаем количество непрочитанных сообщений
|
||||
let unread_count_result = sqlx::query(
|
||||
"SELECT COUNT(*) as count FROM messages WHERE from_user = ? AND to_user = ? AND read = FALSE AND deleted_for_everyone = FALSE AND deleted_for_receiver = FALSE"
|
||||
)
|
||||
.bind(&partner_name)
|
||||
.bind(&username)
|
||||
.fetch_one(&state.db_pool)
|
||||
.await;
|
||||
|
||||
let unread_count = match unread_count_result {
|
||||
Ok(row) => row.get::<i64, _>("count"),
|
||||
Err(e) => {
|
||||
eprintln!("❌ Error counting unread messages: {}", e);
|
||||
0
|
||||
}
|
||||
};
|
||||
|
||||
let chat = model::ChatPartner {
|
||||
username: partner_name.clone(),
|
||||
phone,
|
||||
avatar_url,
|
||||
last_message,
|
||||
last_message_time,
|
||||
unread_count,
|
||||
};
|
||||
|
||||
println!(
|
||||
"✅ Added chat with {} (phone: {}), last_message: {:?}, unread: {}",
|
||||
chat.username, chat.phone, chat.last_message, chat.unread_count
|
||||
);
|
||||
chats.push(chat);
|
||||
}
|
||||
Ok(None) => {
|
||||
eprintln!("❌ User not found: {}", partner_username);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"❌ Error fetching user details for {}: {}",
|
||||
partner_username, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Сортируем по времени последнего сообщения (новые сверху)
|
||||
chats.sort_by(|a, b| {
|
||||
b.last_message_time
|
||||
.unwrap_or(0)
|
||||
.cmp(&a.last_message_time.unwrap_or(0))
|
||||
});
|
||||
|
||||
println!("🎉 Final chat list for {}: {} chats", username, chats.len());
|
||||
for chat in &chats {
|
||||
println!(
|
||||
" - {}: {:?} (unread: {})",
|
||||
chat.username, chat.last_message, chat.unread_count
|
||||
);
|
||||
}
|
||||
|
||||
Json(chats)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// use crate::state;
|
||||
// use crate::utils;
|
||||
// use axum::extract::{Json, State};
|
||||
// use sqlx::Row;
|
||||
// use std::sync::Arc;
|
||||
|
||||
// // Debug endpoint to see all messages
|
||||
// pub async fn debug_messages_handler(
|
||||
// State(state): State<Arc<state::AppState>>,
|
||||
// headers: axum::http::HeaderMap,
|
||||
// ) -> 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!({"error": "No token"}));
|
||||
// };
|
||||
|
||||
// // Get current user from token
|
||||
// let username = match utils::verify_token(&state.db_pool, &token).await {
|
||||
// Ok(Some(user)) => user,
|
||||
// _ => return Json(serde_json::json!({"error": "Invalid token"})),
|
||||
// };
|
||||
|
||||
// // Get all messages for this user
|
||||
// let messages = sqlx::query(
|
||||
// r#"
|
||||
// SELECT id, from_user, to_user, content, timestamp, message_type, read,
|
||||
// deleted_for_sender, deleted_for_receiver, deleted_for_everyone
|
||||
// FROM messages
|
||||
// WHERE from_user = ? OR to_user = ?
|
||||
// ORDER BY timestamp DESC
|
||||
// LIMIT 100
|
||||
// "#,
|
||||
// )
|
||||
// .bind(&username)
|
||||
// .bind(&username)
|
||||
// .fetch_all(&state.db_pool)
|
||||
// .await;
|
||||
|
||||
// // Get chat partners count
|
||||
// let partners_result = sqlx::query(
|
||||
// r#"
|
||||
// SELECT COUNT(DISTINCT
|
||||
// CASE
|
||||
// WHEN from_user = ? THEN to_user
|
||||
// ELSE from_user
|
||||
// END
|
||||
// ) as partner_count
|
||||
// FROM messages
|
||||
// WHERE from_user = ? OR to_user = ?
|
||||
// "#,
|
||||
// )
|
||||
// .bind(&username)
|
||||
// .bind(&username)
|
||||
// .bind(&username)
|
||||
// .fetch_one(&state.db_pool)
|
||||
// .await;
|
||||
|
||||
// match messages {
|
||||
// Ok(rows) => {
|
||||
// let mut result = Vec::new();
|
||||
// for row in rows {
|
||||
// result.push(serde_json::json!({
|
||||
// "id": row.get::<String, _>("id"),
|
||||
// "from_user": row.get::<String, _>("from_user"),
|
||||
// "to_user": row.get::<String, _>("to_user"),
|
||||
// "content": row.get::<String, _>("content"),
|
||||
// "timestamp": row.get::<i64, _>("timestamp"),
|
||||
// "message_type": row.get::<String, _>("message_type"),
|
||||
// "read": row.get::<bool, _>("read"),
|
||||
// "deleted_for_sender": row.get::<bool, _>("deleted_for_sender"),
|
||||
// "deleted_for_receiver": row.get::<bool, _>("deleted_for_receiver"),
|
||||
// "deleted_for_everyone": row.get::<bool, _>("deleted_for_everyone"),
|
||||
// "is_current_user_from": row.get::<String, _>("from_user") == username,
|
||||
// "is_current_user_to": row.get::<String, _>("to_user") == username,
|
||||
// }));
|
||||
// }
|
||||
|
||||
// let partner_count = match partners_result {
|
||||
// Ok(row) => row.get::<i64, _>("partner_count"),
|
||||
// Err(_) => 0,
|
||||
// };
|
||||
|
||||
// Json(serde_json::json!({
|
||||
// "user": username,
|
||||
// "total_messages": result.len(),
|
||||
// "chat_partners_count": partner_count,
|
||||
// "messages": result
|
||||
// }))
|
||||
// }
|
||||
// Err(e) => Json(serde_json::json!({"error": format!("Database error: {}", e)})),
|
||||
// }
|
||||
// }
|
||||
@@ -0,0 +1,77 @@
|
||||
use crate::model;
|
||||
use crate::state;
|
||||
use crate::utils;
|
||||
use axum::extract::{Json, State};
|
||||
use bcrypt::verify;
|
||||
use chrono::{Duration, Utc};
|
||||
use sqlx::Row;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub async fn login_handler(
|
||||
State(state): State<Arc<state::AppState>>,
|
||||
Json(payload): Json<model::LoginRequest>,
|
||||
) -> Json<model::AuthResponse> {
|
||||
// Get user from database
|
||||
let user_result = sqlx::query("SELECT username, password_hash FROM users WHERE phone = ?")
|
||||
.bind(&payload.phone)
|
||||
.fetch_optional(&state.db_pool)
|
||||
.await;
|
||||
|
||||
match user_result {
|
||||
Ok(Some(row)) => {
|
||||
let username: String = row.get("username");
|
||||
let password_hash: String = row.get("password_hash");
|
||||
|
||||
// Verify password
|
||||
match verify(&payload.password, &password_hash) {
|
||||
Ok(true) => {
|
||||
// Generate session token
|
||||
let token = utils::generate_token();
|
||||
let expires_at = Utc::now() + Duration::days(30);
|
||||
|
||||
// Store session
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO sessions (token, username, phone, expires_at) VALUES (?, ?, ?, ?)"
|
||||
)
|
||||
.bind(&token)
|
||||
.bind(&username)
|
||||
.bind(&payload.phone)
|
||||
.bind(expires_at.naive_utc())
|
||||
.execute(&state.db_pool)
|
||||
.await;
|
||||
|
||||
Json(model::AuthResponse {
|
||||
success: true,
|
||||
message: "Login successful".to_string(),
|
||||
username: Some(username),
|
||||
token: Some(token),
|
||||
})
|
||||
}
|
||||
Ok(false) => Json(model::AuthResponse {
|
||||
success: false,
|
||||
message: "Invalid password".to_string(),
|
||||
username: None,
|
||||
token: None,
|
||||
}),
|
||||
Err(_) => Json(model::AuthResponse {
|
||||
success: false,
|
||||
message: "Error verifying password".to_string(),
|
||||
username: None,
|
||||
token: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
Ok(None) => Json(model::AuthResponse {
|
||||
success: false,
|
||||
message: "Phone number not found".to_string(),
|
||||
username: None,
|
||||
token: None,
|
||||
}),
|
||||
Err(_) => Json(model::AuthResponse {
|
||||
success: false,
|
||||
message: "Login failed".to_string(),
|
||||
username: None,
|
||||
token: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
pub mod delete;
|
||||
use crate::model;
|
||||
use crate::state;
|
||||
use crate::utils;
|
||||
use axum::extract::{Json, State};
|
||||
use std::sync::Arc;
|
||||
|
||||
// Mark messages as read endpoint
|
||||
pub async fn mark_read_handler(
|
||||
State(state): State<Arc<state::AppState>>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(payload): Json<model::MarkReadRequest>,
|
||||
) -> 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
|
||||
let username = match utils::verify_token(&state.db_pool, &token).await {
|
||||
Ok(Some(user)) => user,
|
||||
_ => return Json(serde_json::json!({"success": false, "error": "Invalid token"})),
|
||||
};
|
||||
|
||||
// Проверяем, что текущий пользователь - это получатель сообщений
|
||||
if username != payload.to_user {
|
||||
return Json(serde_json::json!({"success": false, "error": "Unauthorized"}));
|
||||
}
|
||||
|
||||
match utils::mark_messages_as_read(&state.db_pool, &payload.from_user, &payload.to_user).await {
|
||||
Ok(_) => Json(serde_json::json!({"success": true})),
|
||||
Err(e) => {
|
||||
eprintln!("Error marking messages as read: {}", e);
|
||||
Json(serde_json::json!({"success": false, "error": "Database error"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
use crate::model;
|
||||
use crate::state;
|
||||
use crate::utils;
|
||||
use axum::extract::{Json, State, ws::Message};
|
||||
use sqlx::Row;
|
||||
use std::sync::Arc;
|
||||
|
||||
// Delete message endpoint
|
||||
pub async fn delete_message_handler(
|
||||
State(state): State<Arc<state::AppState>>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(payload): Json<model::DeleteMessageRequest>,
|
||||
) -> Json<model::DeleteMessageResponse> {
|
||||
// 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(model::DeleteMessageResponse {
|
||||
success: false,
|
||||
message: "No token".to_string(),
|
||||
});
|
||||
};
|
||||
|
||||
// Get current user from token
|
||||
let username = match utils::verify_token(&state.db_pool, &token).await {
|
||||
Ok(Some(user)) => user,
|
||||
_ => {
|
||||
return Json(model::DeleteMessageResponse {
|
||||
success: false,
|
||||
message: "Invalid token".to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Получаем информацию о сообщении
|
||||
let message_result = sqlx::query("SELECT from_user, to_user FROM messages WHERE id = ?")
|
||||
.bind(&payload.message_id)
|
||||
.fetch_optional(&state.db_pool)
|
||||
.await;
|
||||
|
||||
let message = match message_result {
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => {
|
||||
return Json(model::DeleteMessageResponse {
|
||||
success: false,
|
||||
message: "Message not found".to_string(),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error fetching message: {}", e);
|
||||
return Json(model::DeleteMessageResponse {
|
||||
success: false,
|
||||
message: "Database error".to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let from_user: String = message.get("from_user");
|
||||
let to_user: String = message.get("to_user");
|
||||
|
||||
// Проверяем права доступа
|
||||
if from_user != username && to_user != username {
|
||||
return Json(model::DeleteMessageResponse {
|
||||
success: false,
|
||||
message: "You don't have permission to delete this message".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if payload.for_everyone {
|
||||
// Удаление для всех
|
||||
if from_user != username {
|
||||
return Json(model::DeleteMessageResponse {
|
||||
success: false,
|
||||
message: "Only the sender can delete message for everyone".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let result = sqlx::query("UPDATE messages SET deleted_for_everyone = TRUE WHERE id = ?")
|
||||
.bind(&payload.message_id)
|
||||
.execute(&state.db_pool)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
// Отправляем уведомление через WebSocket
|
||||
let users = state.users.read().await;
|
||||
if let Some(recipient_tx) = users.get(&to_user) {
|
||||
let delete_msg = serde_json::json!({
|
||||
"type": "message_deleted",
|
||||
"message_id": payload.message_id,
|
||||
"for_everyone": true
|
||||
});
|
||||
let _ = recipient_tx.send(Message::Text(delete_msg.to_string()));
|
||||
}
|
||||
|
||||
Json(model::DeleteMessageResponse {
|
||||
success: true,
|
||||
message: "Message deleted for everyone".to_string(),
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error deleting message for everyone: {}", e);
|
||||
Json(model::DeleteMessageResponse {
|
||||
success: false,
|
||||
message: "Database error".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Удаление только для себя
|
||||
let field_to_update = if username == from_user {
|
||||
"deleted_for_sender"
|
||||
} else {
|
||||
"deleted_for_receiver"
|
||||
};
|
||||
|
||||
let query = format!(
|
||||
"UPDATE messages SET {} = TRUE WHERE id = ?",
|
||||
field_to_update
|
||||
);
|
||||
|
||||
let result = sqlx::query(&query)
|
||||
.bind(&payload.message_id)
|
||||
.execute(&state.db_pool)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => Json(model::DeleteMessageResponse {
|
||||
success: true,
|
||||
message: "Message deleted for you".to_string(),
|
||||
}),
|
||||
Err(e) => {
|
||||
eprintln!("Error deleting message for user: {}", e);
|
||||
Json(model::DeleteMessageResponse {
|
||||
success: false,
|
||||
message: "Database error".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod avatar;
|
||||
pub mod get;
|
||||
@@ -0,0 +1,116 @@
|
||||
use crate::model;
|
||||
use crate::state;
|
||||
use crate::utils;
|
||||
use axum::extract::{Json, Multipart, State};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
// Upload avatar endpoint
|
||||
pub async fn upload_avatar_handler(
|
||||
State(state): State<Arc<state::AppState>>,
|
||||
headers: axum::http::HeaderMap,
|
||||
mut multipart: Multipart,
|
||||
) -> Json<model::UploadAvatarResponse> {
|
||||
// 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(model::UploadAvatarResponse {
|
||||
success: false,
|
||||
avatar_url: None,
|
||||
message: "No token".to_string(),
|
||||
});
|
||||
};
|
||||
|
||||
// Get current user from token
|
||||
let username = match utils::verify_token(&state.db_pool, &token).await {
|
||||
Ok(Some(user)) => user,
|
||||
_ => {
|
||||
return Json(model::UploadAvatarResponse {
|
||||
success: false,
|
||||
avatar_url: None,
|
||||
message: "Invalid token".to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Process multipart form
|
||||
while let Some(field) = multipart.next_field().await.unwrap() {
|
||||
let field_name = field.name().unwrap_or("").to_string();
|
||||
|
||||
if field_name == "avatar" {
|
||||
let data = field.bytes().await.unwrap();
|
||||
|
||||
// Validate image size using config
|
||||
if data.len() > state.config.files.max_avatar_size {
|
||||
return Json(model::UploadAvatarResponse {
|
||||
success: false,
|
||||
avatar_url: None,
|
||||
message: format!(
|
||||
"Image too large (max {} bytes)",
|
||||
state.config.files.max_avatar_size
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Process image
|
||||
match utils::process_avatar(&data).await {
|
||||
Ok(processed_data) => {
|
||||
// Generate unique filename
|
||||
let filename = format!("{}.jpg", Uuid::new_v4());
|
||||
let avatar_path = format!("{}/{}", state.config.files.avatars_dir, filename);
|
||||
|
||||
// Save file
|
||||
if let Err(e) = tokio::fs::write(&avatar_path, &processed_data).await {
|
||||
eprintln!("Error saving avatar: {}", e);
|
||||
return Json(model::UploadAvatarResponse {
|
||||
success: false,
|
||||
avatar_url: None,
|
||||
message: "Error saving image".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let avatar_url = format!("/avatars/{}", filename);
|
||||
|
||||
// Update user in database
|
||||
let result = sqlx::query("UPDATE users SET avatar_url = ? WHERE username = ?")
|
||||
.bind(&avatar_url)
|
||||
.bind(&username)
|
||||
.execute(&state.db_pool)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
return Json(model::UploadAvatarResponse {
|
||||
success: true,
|
||||
avatar_url: Some(avatar_url),
|
||||
message: "Avatar uploaded successfully".to_string(),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error updating user avatar: {}", e);
|
||||
return Json(model::UploadAvatarResponse {
|
||||
success: false,
|
||||
avatar_url: None,
|
||||
message: "Database error".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Json(model::UploadAvatarResponse {
|
||||
success: false,
|
||||
avatar_url: None,
|
||||
message: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Json(model::UploadAvatarResponse {
|
||||
success: false,
|
||||
avatar_url: None,
|
||||
message: "No avatar file provided".to_string(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
use crate::model;
|
||||
use crate::state;
|
||||
use crate::utils;
|
||||
use axum::extract::{Json, State};
|
||||
use sqlx::Row;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub async fn profile_handler(
|
||||
State(state): State<Arc<state::AppState>>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Json<model::UserProfile> {
|
||||
// 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(model::UserProfile {
|
||||
username: "user".to_string(),
|
||||
phone: "+1234567890".to_string(),
|
||||
avatar_url: None,
|
||||
});
|
||||
};
|
||||
|
||||
// Get current user from token
|
||||
let username = match utils::verify_token(&state.db_pool, &token).await {
|
||||
Ok(Some(user)) => user,
|
||||
_ => {
|
||||
return Json(model::UserProfile {
|
||||
username: "user".to_string(),
|
||||
phone: "+1234567890".to_string(),
|
||||
avatar_url: None,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get user details
|
||||
let user_result =
|
||||
sqlx::query("SELECT username, phone, avatar_url FROM users WHERE username = ?")
|
||||
.bind(&username)
|
||||
.fetch_optional(&state.db_pool)
|
||||
.await;
|
||||
|
||||
match user_result {
|
||||
Ok(Some(row)) => Json(model::UserProfile {
|
||||
username: row.get("username"),
|
||||
phone: row.get("phone"),
|
||||
avatar_url: row.get("avatar_url"),
|
||||
}),
|
||||
_ => Json(model::UserProfile {
|
||||
username: "user".to_string(),
|
||||
phone: "+1234567890".to_string(),
|
||||
avatar_url: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
use crate::model;
|
||||
use crate::state;
|
||||
use crate::utils;
|
||||
use axum::extract::{Json, State};
|
||||
use bcrypt::{DEFAULT_COST, hash};
|
||||
use chrono::{Duration, Utc};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub async fn register_handler(
|
||||
State(state): State<Arc<state::AppState>>,
|
||||
Json(payload): Json<model::RegisterRequest>,
|
||||
) -> Json<model::AuthResponse> {
|
||||
// Validate input
|
||||
if payload.phone.len() < 5 {
|
||||
return Json(model::AuthResponse {
|
||||
success: false,
|
||||
message: "Phone number is too short".to_string(),
|
||||
username: None,
|
||||
token: None,
|
||||
});
|
||||
}
|
||||
|
||||
if payload.username.len() < 3
|
||||
|| payload.username.len() > state.config.limits.max_username_length
|
||||
{
|
||||
return Json(model::AuthResponse {
|
||||
success: false,
|
||||
message: format!(
|
||||
"Username must be between 3 and {} characters",
|
||||
state.config.limits.max_username_length
|
||||
),
|
||||
username: None,
|
||||
token: None,
|
||||
});
|
||||
}
|
||||
|
||||
if payload.password.len() < state.config.limits.min_password_length {
|
||||
return Json(model::AuthResponse {
|
||||
success: false,
|
||||
message: format!(
|
||||
"Password must be at least {} characters",
|
||||
state.config.limits.min_password_length
|
||||
),
|
||||
username: None,
|
||||
token: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Hash password
|
||||
let password_hash = match hash(&payload.password, DEFAULT_COST) {
|
||||
Ok(hash) => hash,
|
||||
Err(_) => {
|
||||
return Json(model::AuthResponse {
|
||||
success: false,
|
||||
message: "Error hashing password".to_string(),
|
||||
username: None,
|
||||
token: None,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Insert user into database
|
||||
let result = sqlx::query("INSERT INTO users (phone, username, password_hash) VALUES (?, ?, ?)")
|
||||
.bind(&payload.phone)
|
||||
.bind(&payload.username)
|
||||
.bind(&password_hash)
|
||||
.execute(&state.db_pool)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
// Generate session token
|
||||
let token = utils::generate_token();
|
||||
let expires_at = Utc::now() + Duration::days(30);
|
||||
|
||||
// Store session
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO sessions (token, username, phone, expires_at) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&token)
|
||||
.bind(&payload.username)
|
||||
.bind(&payload.phone)
|
||||
.bind(expires_at.naive_utc())
|
||||
.execute(&state.db_pool)
|
||||
.await;
|
||||
|
||||
Json(model::AuthResponse {
|
||||
success: true,
|
||||
message: "Registration successful".to_string(),
|
||||
username: Some(payload.username),
|
||||
token: Some(token),
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
let error_message = if e
|
||||
.to_string()
|
||||
.contains("UNIQUE constraint failed: users.phone")
|
||||
{
|
||||
"Phone number already registered".to_string()
|
||||
} else if e
|
||||
.to_string()
|
||||
.contains("UNIQUE constraint failed: users.username")
|
||||
{
|
||||
"Username already taken".to_string()
|
||||
} else {
|
||||
"Registration failed".to_string()
|
||||
};
|
||||
|
||||
Json(model::AuthResponse {
|
||||
success: false,
|
||||
message: error_message,
|
||||
username: None,
|
||||
token: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use crate::model;
|
||||
use crate::state;
|
||||
use axum::extract::{Json, Query, State};
|
||||
use sqlx::Row;
|
||||
use std::sync::Arc;
|
||||
|
||||
// New endpoint to search users
|
||||
pub async fn search_users_handler(
|
||||
State(state): State<Arc<state::AppState>>,
|
||||
Query(params): Query<model::SearchQuery>,
|
||||
// headers: axum::http::HeaderMap,
|
||||
) -> Json<Vec<model::ChatPartner>> {
|
||||
let search_pattern = format!("%{}%", params.q);
|
||||
|
||||
let users = sqlx::query(
|
||||
r#"
|
||||
SELECT username, phone, avatar_url
|
||||
FROM users
|
||||
WHERE (username LIKE ? OR phone LIKE ?)
|
||||
LIMIT 20
|
||||
"#,
|
||||
)
|
||||
.bind(&search_pattern)
|
||||
.bind(&search_pattern)
|
||||
.fetch_all(&state.db_pool)
|
||||
.await;
|
||||
|
||||
match users {
|
||||
Ok(rows) => {
|
||||
let mut results = Vec::new();
|
||||
for row in rows {
|
||||
results.push(model::ChatPartner {
|
||||
username: row.get("username"),
|
||||
phone: row.get("phone"),
|
||||
avatar_url: row.get("avatar_url"),
|
||||
last_message: None,
|
||||
last_message_time: None,
|
||||
unread_count: 0,
|
||||
});
|
||||
}
|
||||
Json(results)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error searching users: {}", e);
|
||||
Json(Vec::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
use crate::model;
|
||||
use crate::state;
|
||||
use crate::utils;
|
||||
use axum::extract::{Json, State};
|
||||
use sqlx::Row;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub async fn validate_token_handler(
|
||||
State(state): State<Arc<state::AppState>>,
|
||||
Json(payload): Json<model::ValidateTokenRequest>,
|
||||
) -> Json<model::ValidateTokenResponse> {
|
||||
match utils::verify_token(&state.db_pool, &payload.token).await {
|
||||
Ok(Some(username)) => {
|
||||
// Get user details
|
||||
let user_result = sqlx::query("SELECT phone, avatar_url FROM users WHERE username = ?")
|
||||
.bind(&username)
|
||||
.fetch_optional(&state.db_pool)
|
||||
.await;
|
||||
|
||||
match user_result {
|
||||
Ok(Some(row)) => {
|
||||
let phone: String = row.get("phone");
|
||||
let avatar_url: Option<String> = row.get("avatar_url");
|
||||
Json(model::ValidateTokenResponse {
|
||||
success: true,
|
||||
username: Some(username),
|
||||
phone: Some(phone),
|
||||
avatar_url,
|
||||
message: None,
|
||||
})
|
||||
}
|
||||
Ok(None) => Json(model::ValidateTokenResponse {
|
||||
success: false,
|
||||
username: None,
|
||||
phone: None,
|
||||
avatar_url: None,
|
||||
message: Some("User not found".to_string()),
|
||||
}),
|
||||
Err(_) => Json(model::ValidateTokenResponse {
|
||||
success: false,
|
||||
username: None,
|
||||
phone: None,
|
||||
avatar_url: None,
|
||||
message: Some("Database error".to_string()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
Ok(None) => Json(model::ValidateTokenResponse {
|
||||
success: false,
|
||||
username: None,
|
||||
phone: None,
|
||||
avatar_url: None,
|
||||
message: Some("Invalid or expired token".to_string()),
|
||||
}),
|
||||
Err(_) => Json(model::ValidateTokenResponse {
|
||||
success: false,
|
||||
username: None,
|
||||
phone: None,
|
||||
avatar_url: None,
|
||||
message: Some("Token verification error".to_string()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
use crate::model;
|
||||
use crate::state;
|
||||
use crate::utils;
|
||||
use axum::{
|
||||
extract::{
|
||||
State,
|
||||
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||
},
|
||||
response::Response,
|
||||
};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
// WebSocket handlers
|
||||
pub async fn websocket_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<Arc<state::AppState>>,
|
||||
) -> Response {
|
||||
ws.on_upgrade(|socket| handle_websocket(socket, state))
|
||||
}
|
||||
|
||||
async fn handle_websocket(socket: WebSocket, state: Arc<state::AppState>) {
|
||||
let (mut sender, mut receiver) = socket.split();
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
// Handle authentication
|
||||
let username = match receiver.next().await {
|
||||
Some(Ok(Message::Text(auth_msg))) => {
|
||||
match serde_json::from_str::<serde_json::Value>(&auth_msg) {
|
||||
Ok(auth) => {
|
||||
if let (Some(token), Some(auth_type)) =
|
||||
(auth["token"].as_str(), auth["type"].as_str())
|
||||
{
|
||||
if auth_type == "auth" {
|
||||
match utils::verify_token(&state.db_pool, token).await {
|
||||
Ok(Some(user_username)) => {
|
||||
if state.users.read().await.contains_key(&user_username) {
|
||||
let _ = sender
|
||||
.send(Message::Text(
|
||||
serde_json::json!({
|
||||
"type": "error",
|
||||
"content": "User already connected"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
state.users.write().await.insert(user_username.clone(), tx);
|
||||
|
||||
let _ = sender
|
||||
.send(Message::Text(
|
||||
serde_json::json!({
|
||||
"type": "system",
|
||||
"content": "Connected successfully"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.await;
|
||||
|
||||
// utils::broadcast_user_list(&state.users).await;
|
||||
|
||||
if let Ok(messages) = utils::get_message_history(
|
||||
&state.db_pool,
|
||||
&user_username,
|
||||
"",
|
||||
)
|
||||
.await
|
||||
{
|
||||
for msg in messages {
|
||||
let message_json = serde_json::to_string(&msg).unwrap();
|
||||
let _ = sender.send(Message::Text(message_json)).await;
|
||||
}
|
||||
}
|
||||
|
||||
user_username
|
||||
}
|
||||
Ok(None) => {
|
||||
let _ = sender
|
||||
.send(Message::Text(
|
||||
serde_json::json!({
|
||||
"type": "error",
|
||||
"content": "Invalid or expired token"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = sender
|
||||
.send(Message::Text(
|
||||
serde_json::json!({
|
||||
"type": "error",
|
||||
"content": "Authentication error"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(_) => return,
|
||||
}
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
|
||||
println!("User {} connected", username);
|
||||
|
||||
let state_clone = state.clone();
|
||||
let username_clone = username.clone();
|
||||
|
||||
let mut send_sender = sender;
|
||||
let send_task = tokio::spawn(async move {
|
||||
while let Some(message) = rx.recv().await {
|
||||
if send_sender.send(message).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let recv_state = state.clone();
|
||||
let recv_task = tokio::spawn(async move {
|
||||
while let Some(result) = receiver.next().await {
|
||||
match result {
|
||||
Ok(Message::Text(text)) => {
|
||||
if let Ok(mut chat_msg) = serde_json::from_str::<model::ChatMessage>(&text) {
|
||||
chat_msg.from_user = username.clone();
|
||||
|
||||
if let Err(e) = utils::save_message(&recv_state.db_pool, &chat_msg).await {
|
||||
eprintln!("Failed to save message: {}", e);
|
||||
continue;
|
||||
}
|
||||
|
||||
let users = recv_state.users.read().await;
|
||||
if chat_msg.to_user != chat_msg.from_user {
|
||||
if let Some(recipient_tx) = users.get(&chat_msg.to_user) {
|
||||
let message_json = serde_json::to_string(&chat_msg).unwrap();
|
||||
let _ = recipient_tx.send(Message::Text(message_json));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(sender_tx) = users.get(&username) {
|
||||
let message_json = serde_json::to_string(&chat_msg).unwrap();
|
||||
let _ = sender_tx.send(Message::Text(message_json));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Message::Close(_)) => break,
|
||||
Err(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tokio::select! {
|
||||
_ = send_task => {},
|
||||
_ = recv_task => {},
|
||||
}
|
||||
|
||||
state_clone.users.write().await.remove(&username_clone);
|
||||
// utils::broadcast_user_list(&state_clone.users).await;
|
||||
println!("User {} disconnected", username_clone);
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
use config::{Config as ConfigLoader, File, FileFormat};
|
||||
use serde::Deserialize;
|
||||
|
||||
// Структура для конфигурации
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct AppConfig {
|
||||
pub server: ServerConfig,
|
||||
pub database: DatabaseConfig,
|
||||
pub files: FilesConfig,
|
||||
pub limits: LimitsConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct ServerConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub max_connections: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct DatabaseConfig {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct FilesConfig {
|
||||
pub avatars_dir: String,
|
||||
pub web_dir: String,
|
||||
pub max_avatar_size: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct LimitsConfig {
|
||||
// max_message_length: usize,
|
||||
pub max_username_length: usize,
|
||||
pub min_password_length: usize,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let config_loader = ConfigLoader::builder()
|
||||
// Добавляем конфиг по умолчанию
|
||||
.add_source(File::from_str(
|
||||
r#"
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
port = 3333
|
||||
max_connections = 10000
|
||||
|
||||
[database]
|
||||
url = "sqlite:chat.db"
|
||||
|
||||
[files]
|
||||
avatars_dir = "web/avatars"
|
||||
web_dir = "web"
|
||||
max_avatar_size = 5242880
|
||||
|
||||
[limits]
|
||||
max_message_length = 4096
|
||||
max_username_length = 50
|
||||
min_password_length = 6
|
||||
"#,
|
||||
FileFormat::Toml,
|
||||
))
|
||||
// Перезаписываем значения из config.json если он существует
|
||||
.add_source(File::with_name("config").required(false))
|
||||
.build()?;
|
||||
|
||||
Ok(config_loader.try_deserialize()?)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
use sqlx::{SqlitePool, sqlite::SqliteConnectOptions};
|
||||
|
||||
pub async fn init_db(database_url: &str) -> Result<SqlitePool, Box<dyn std::error::Error>> {
|
||||
let options = SqliteConnectOptions::new()
|
||||
.filename(database_url)
|
||||
.create_if_missing(true);
|
||||
let pool = SqlitePool::connect_with(options).await?;
|
||||
|
||||
// Create users table
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
phone TEXT UNIQUE NOT NULL,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
avatar_url TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
// Create messages table
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
from_user TEXT NOT NULL,
|
||||
to_user TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
message_type TEXT NOT NULL,
|
||||
read BOOLEAN DEFAULT FALSE,
|
||||
deleted_for_sender BOOLEAN DEFAULT FALSE,
|
||||
deleted_for_receiver BOOLEAN DEFAULT FALSE,
|
||||
deleted_for_everyone BOOLEAN DEFAULT FALSE
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
// Create sessions table
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
phone TEXT NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
+33
-1506
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AuthResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub username: Option<String>,
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RegisterRequest {
|
||||
pub phone: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub phone: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ValidateTokenResponse {
|
||||
pub success: bool,
|
||||
pub username: Option<String>,
|
||||
pub phone: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ValidateTokenRequest {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ChatPartner {
|
||||
pub username: String,
|
||||
pub phone: String,
|
||||
pub avatar_url: Option<String>,
|
||||
pub last_message: Option<String>,
|
||||
pub last_message_time: Option<i64>,
|
||||
pub unread_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SearchQuery {
|
||||
pub q: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MarkReadRequest {
|
||||
pub from_user: String,
|
||||
pub to_user: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeleteMessageRequest {
|
||||
pub message_id: String,
|
||||
pub for_everyone: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DeleteMessageResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UploadAvatarResponse {
|
||||
pub success: bool,
|
||||
pub avatar_url: Option<String>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UserProfile {
|
||||
pub username: String,
|
||||
pub phone: String,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ChatMessage {
|
||||
pub id: String,
|
||||
pub from_user: String,
|
||||
pub to_user: String,
|
||||
pub content: String,
|
||||
pub timestamp: i64,
|
||||
pub message_type: String,
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
use crate::conf;
|
||||
use axum::extract::ws::Message;
|
||||
use sqlx::sqlite::SqlitePool;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use tokio::sync::{RwLock, mpsc};
|
||||
|
||||
pub type UserMap = Arc<RwLock<HashMap<String, mpsc::UnboundedSender<Message>>>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub users: UserMap,
|
||||
pub db_pool: SqlitePool,
|
||||
pub config: conf::AppConfig,
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
use crate::model;
|
||||
// use crate::state;
|
||||
// use axum::extract::ws::Message;
|
||||
use rand::distributions::Alphanumeric;
|
||||
use rand::{Rng, thread_rng};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
use std::io::Cursor;
|
||||
|
||||
// Utility functions
|
||||
pub fn generate_token() -> String {
|
||||
thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(32)
|
||||
.map(char::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn verify_token(pool: &SqlitePool, token: &str) -> Result<Option<String>, sqlx::Error> {
|
||||
let result = sqlx::query(
|
||||
"SELECT username FROM sessions WHERE token = ? AND expires_at > datetime('now')",
|
||||
)
|
||||
.bind(token)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.map(|row| row.get("username")))
|
||||
}
|
||||
|
||||
pub async fn mark_messages_as_read(
|
||||
pool: &SqlitePool,
|
||||
from_user: &str,
|
||||
to_user: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let result = sqlx::query(
|
||||
"UPDATE messages SET read = TRUE WHERE from_user = ? AND to_user = ? AND read = FALSE AND deleted_for_everyone = FALSE"
|
||||
)
|
||||
.bind(from_user)
|
||||
.bind(to_user)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
println!(
|
||||
"✅ Marked {} messages as read from {} to {}",
|
||||
result.rows_affected(),
|
||||
from_user,
|
||||
to_user
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Process and resize avatar
|
||||
pub async fn process_avatar(data: &[u8]) -> Result<Vec<u8>, String> {
|
||||
// Load image
|
||||
let img = match image::load_from_memory(data) {
|
||||
Ok(img) => img,
|
||||
Err(_) => return Err("Invalid image format".to_string()),
|
||||
};
|
||||
|
||||
// Resize image to 150x150 pixels
|
||||
let resized = img.resize_to_fill(150, 150, image::imageops::FilterType::Lanczos3);
|
||||
|
||||
// Convert to JPEG
|
||||
let mut jpeg_data = Vec::new();
|
||||
match resized.write_to(&mut Cursor::new(&mut jpeg_data), image::ImageFormat::Jpeg) {
|
||||
Ok(_) => Ok(jpeg_data),
|
||||
Err(_) => Err("Error processing image".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_message_history(
|
||||
pool: &SqlitePool,
|
||||
username: &str,
|
||||
other_user: &str,
|
||||
) -> Result<Vec<model::ChatMessage>, sqlx::Error> {
|
||||
// Если указан конкретный собеседник, помечаем сообщения как прочитанные
|
||||
if !other_user.is_empty() {
|
||||
mark_messages_as_read(pool, other_user, username).await?;
|
||||
}
|
||||
|
||||
let query = if other_user.is_empty() {
|
||||
// Получаем все сообщения пользователя (исключая удаленные)
|
||||
r#"
|
||||
SELECT id, from_user, to_user, content, timestamp, message_type
|
||||
FROM messages
|
||||
WHERE (from_user = ? OR to_user = ?)
|
||||
AND deleted_for_everyone = FALSE
|
||||
AND ((from_user = ? AND deleted_for_sender = FALSE) OR (to_user = ? AND deleted_for_receiver = FALSE))
|
||||
ORDER BY timestamp
|
||||
"#
|
||||
} else {
|
||||
// Получаем сообщения только с конкретным пользователем (исключая удаленные)
|
||||
r#"
|
||||
SELECT id, from_user, to_user, content, timestamp, message_type
|
||||
FROM messages
|
||||
WHERE ((from_user = ? AND to_user = ?) OR (from_user = ? AND to_user = ?))
|
||||
AND deleted_for_everyone = FALSE
|
||||
AND ((from_user = ? AND deleted_for_sender = FALSE) OR (to_user = ? AND deleted_for_receiver = FALSE))
|
||||
ORDER BY timestamp
|
||||
"#
|
||||
};
|
||||
|
||||
let rows = if other_user.is_empty() {
|
||||
sqlx::query(query)
|
||||
.bind(username)
|
||||
.bind(username)
|
||||
.bind(username)
|
||||
.bind(username)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query(query)
|
||||
.bind(username)
|
||||
.bind(other_user)
|
||||
.bind(other_user)
|
||||
.bind(username)
|
||||
.bind(username)
|
||||
.bind(username)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
};
|
||||
|
||||
let mut messages = Vec::new();
|
||||
for row in rows {
|
||||
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"),
|
||||
};
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
// pub async fn broadcast_user_list(users: &state::UserMap) {
|
||||
// let user_list: Vec<String> = users.read().await.keys().cloned().collect();
|
||||
// let user_list_msg = serde_json::json!({
|
||||
// "type": "user_list",
|
||||
// "users": user_list
|
||||
// });
|
||||
|
||||
// for tx in users.read().await.values() {
|
||||
// let _ = tx.send(Message::Text(user_list_msg.to_string()));
|
||||
// }
|
||||
// }
|
||||
|
||||
pub async fn save_message(pool: &SqlitePool, msg: &model::ChatMessage) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO messages (id, from_user, to_user, content, timestamp, message_type) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&msg.id)
|
||||
.bind(&msg.from_user)
|
||||
.bind(&msg.to_user)
|
||||
.bind(&msg.content)
|
||||
.bind(msg.timestamp)
|
||||
.bind(&msg.message_type)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+1287
File diff suppressed because it is too large
Load Diff
+338
-2381
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
function updateAllAvatars() {
|
||||
const avatarContent = currentUserAvatar
|
||||
? `<img src="${currentUserAvatar}" alt="${currentUser}">`
|
||||
: currentUser.charAt(0).toUpperCase();
|
||||
|
||||
// Update all avatar locations (убран profileHeaderAvatarMain)
|
||||
const avatarLocations = [
|
||||
"profileHeaderAvatar",
|
||||
"profileHeaderAvatarDesktop",
|
||||
"currentUserAvatar",
|
||||
];
|
||||
|
||||
avatarLocations.forEach((id) => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.innerHTML = avatarContent;
|
||||
}
|
||||
});
|
||||
|
||||
// Update profile modal avatar
|
||||
updateProfileModal();
|
||||
}
|
||||
|
||||
// Function to upload avatar
|
||||
async function uploadAvatar(file) {
|
||||
const formData = new FormData();
|
||||
formData.append("avatar", file);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/upload-avatar", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${currentToken}`,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
currentUserAvatar = data.avatar_url;
|
||||
updateAllAvatars();
|
||||
showAlert("Avatar uploaded successfully!", "success");
|
||||
loadChats(); // Refresh chats to show updated avatar
|
||||
} else {
|
||||
showAlert(data.message, "error");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error uploading avatar:", error);
|
||||
showAlert("Error uploading avatar", "error");
|
||||
}
|
||||
}
|
||||
|
||||
// Avatar upload handler
|
||||
document.getElementById("avatarInput").addEventListener("change", function (e) {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
// Validate file type
|
||||
if (!file.type.startsWith("image/")) {
|
||||
showAlert("Please select an image file", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
showAlert("Image must be less than 5MB", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
uploadAvatar(file);
|
||||
e.target.value = ""; // Reset input
|
||||
}
|
||||
});
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
async function selectChat(username, phone, avatarUrl) {
|
||||
if (!username) return;
|
||||
|
||||
currentRecipient = username;
|
||||
|
||||
// Update mobile header
|
||||
document.getElementById("mobileChatTitle").textContent = username;
|
||||
|
||||
// Update chat header with recipient info
|
||||
const chatHeaderInfo = document.querySelector(".chat-header-info");
|
||||
const avatarContent = avatarUrl
|
||||
? `<img src="${avatarUrl}" alt="${username}">`
|
||||
: username.charAt(0).toUpperCase();
|
||||
|
||||
chatHeaderInfo.innerHTML = `
|
||||
<button class="back-btn" onclick="showChatsPanel()">←</button>
|
||||
<div class="chat-header-avatar">${avatarContent}</div>
|
||||
<div class="chat-header-text">
|
||||
<div class="chat-header-username">${username}</div>
|
||||
<div class="chat-header-phone">${phone}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Помечаем сообщения как прочитанные при открытии чата
|
||||
await markMessagesAsRead(username);
|
||||
|
||||
// Update active chat in list
|
||||
document.querySelectorAll(".chat-item").forEach((item) => {
|
||||
item.classList.remove("active");
|
||||
const usernameElement = item.querySelector(".chat-username");
|
||||
if (usernameElement && usernameElement.textContent === username) {
|
||||
item.classList.add("active");
|
||||
}
|
||||
});
|
||||
|
||||
// Enable message input
|
||||
document.getElementById("sendButton").disabled = false;
|
||||
document.getElementById("messageInput").disabled = false;
|
||||
|
||||
// Focus on input after a short delay to ensure UI is updated
|
||||
setTimeout(() => {
|
||||
document.getElementById("messageInput").focus();
|
||||
}, 100);
|
||||
|
||||
displayChatHistory(username);
|
||||
}
|
||||
|
||||
function displayChatHistory(username) {
|
||||
const chatMessages = document.getElementById("chatMessages");
|
||||
chatMessages.innerHTML = "";
|
||||
|
||||
if (messageHistory[username] && messageHistory[username].length > 0) {
|
||||
messageHistory[username].forEach((msg) => displayMessage(msg, false));
|
||||
// Scroll to bottom after rendering
|
||||
setTimeout(() => {
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
}, 50);
|
||||
} else {
|
||||
chatMessages.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<h3>Start a conversation</h3>
|
||||
<p>Send a message to start chatting with ${username}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderChatsList() {
|
||||
const chatsList = document.getElementById("chatsList");
|
||||
chatsList.innerHTML = "";
|
||||
|
||||
console.log("Rendering chats:", chats);
|
||||
|
||||
if (chats.length === 0) {
|
||||
const emptyState = document.createElement("div");
|
||||
emptyState.className = "empty-state";
|
||||
emptyState.innerHTML = `
|
||||
<h3>No chats yet</h3>
|
||||
<p>Start a new conversation by clicking "New Chat"</p>
|
||||
`;
|
||||
chatsList.appendChild(emptyState);
|
||||
return;
|
||||
}
|
||||
|
||||
chats.forEach((chat) => {
|
||||
const li = document.createElement("li");
|
||||
li.className = "chat-item";
|
||||
if (chat.username === currentRecipient) {
|
||||
li.classList.add("active");
|
||||
}
|
||||
|
||||
const lastMessageTime = chat.last_message_time
|
||||
? formatTime(chat.last_message_time)
|
||||
: "";
|
||||
const lastMessage = chat.last_message || "No messages yet";
|
||||
|
||||
const avatarContent = chat.avatar_url
|
||||
? `<img src="${chat.avatar_url}" alt="${chat.username}">`
|
||||
: chat.username.charAt(0).toUpperCase();
|
||||
|
||||
li.innerHTML = `
|
||||
<div class="chat-avatar">${avatarContent}</div>
|
||||
<div class="chat-info">
|
||||
<div class="chat-username">${chat.username}</div>
|
||||
<div class="chat-phone">${chat.phone}</div>
|
||||
<div class="chat-last-message">${lastMessage}</div>
|
||||
</div>
|
||||
<div class="chat-meta">
|
||||
<div class="chat-time">${lastMessageTime}</div>
|
||||
${chat.unread_count > 0 ? `<div class="unread-badge">${chat.unread_count}</div>` : ""}
|
||||
</div>
|
||||
`;
|
||||
|
||||
li.onclick = () => {
|
||||
selectChat(chat.username, chat.phone, chat.avatar_url);
|
||||
if (isMobile) {
|
||||
hideChatsPanel();
|
||||
}
|
||||
};
|
||||
chatsList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function showChatTab(tab) {
|
||||
currentChatTab = tab;
|
||||
document
|
||||
.querySelectorAll(".chat-tab")
|
||||
.forEach((tab) => tab.classList.remove("active"));
|
||||
|
||||
if (tab === "existing") {
|
||||
document.querySelector(".chat-tab:nth-child(1)").classList.add("active");
|
||||
document.getElementById("searchContainer").style.display = "none";
|
||||
renderChatsList();
|
||||
} else {
|
||||
document.querySelector(".chat-tab:nth-child(2)").classList.add("active");
|
||||
document.getElementById("searchContainer").style.display = "block";
|
||||
document.getElementById("searchResults").innerHTML =
|
||||
'<div class="empty-state"><p>Search for users by phone or username</p></div>';
|
||||
document.getElementById("searchInput").value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChats() {
|
||||
try {
|
||||
console.log("Loading chats for user:", currentUser);
|
||||
const response = await fetch("/api/chats", {
|
||||
headers: { Authorization: `Bearer ${currentToken}` },
|
||||
});
|
||||
|
||||
console.log("Chats response status:", response.status);
|
||||
if (response.ok) {
|
||||
chats = await response.json();
|
||||
console.log("Loaded chats:", chats);
|
||||
renderChatsList();
|
||||
} else {
|
||||
console.error("Failed to load chats, status:", response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading chats:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile navigation functions
|
||||
function toggleChatsPanel() {
|
||||
const chatsPanel = document.getElementById("chatsPanel");
|
||||
chatsPanel.classList.toggle("active");
|
||||
}
|
||||
|
||||
function showChatsPanel() {
|
||||
const chatsPanel = document.getElementById("chatsPanel");
|
||||
chatsPanel.classList.add("active");
|
||||
}
|
||||
|
||||
function hideChatsPanel() {
|
||||
const chatsPanel = document.getElementById("chatsPanel");
|
||||
chatsPanel.classList.remove("active");
|
||||
}
|
||||
|
||||
// Close chats panel when clicking on overlay (mobile)
|
||||
document.getElementById("chatsPanel").addEventListener("click", function (e) {
|
||||
if (e.target === this && isMobile) {
|
||||
hideChatsPanel();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
function reconnectWebSocket() {
|
||||
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
||||
reconnectAttempts++;
|
||||
updateConnectionStatus(
|
||||
"connecting",
|
||||
`Reconnecting... (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`,
|
||||
);
|
||||
|
||||
reconnectTimeout = setTimeout(() => {
|
||||
console.log(`Attempting to reconnect... (${reconnectAttempts})`);
|
||||
connectWebSocket();
|
||||
}, RECONNECT_DELAY);
|
||||
} else {
|
||||
updateConnectionStatus(
|
||||
"disconnected",
|
||||
"Failed to connect. Please refresh the page.",
|
||||
);
|
||||
showAlert("Connection lost. Please refresh the page.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function clearReconnection() {
|
||||
if (reconnectTimeout) {
|
||||
clearTimeout(reconnectTimeout);
|
||||
reconnectTimeout = null;
|
||||
}
|
||||
reconnectAttempts = 0;
|
||||
}
|
||||
|
||||
function updateConnectionStatus(status, message = "") {
|
||||
const statusElement = document.getElementById("connectionStatus");
|
||||
statusElement.textContent = message || status;
|
||||
statusElement.className = `connection-status ${status}`;
|
||||
statusElement.style.display = "block";
|
||||
|
||||
if (status === "connected") {
|
||||
setTimeout(() => {
|
||||
statusElement.style.display = "none";
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
function connectWebSocket() {
|
||||
// Clear any existing reconnection attempts
|
||||
clearReconnection();
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
||||
|
||||
updateConnectionStatus("connecting", "Connecting...");
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log("WebSocket connected");
|
||||
updateConnectionStatus("connected", "Connected");
|
||||
clearReconnection();
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "auth",
|
||||
token: currentToken,
|
||||
}),
|
||||
);
|
||||
addSystemMessage("Connected to chat server");
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
console.log("Received message:", event.data);
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === "error") {
|
||||
showAlert(data.content, "error");
|
||||
if (
|
||||
data.content.includes("Invalid") ||
|
||||
data.content.includes("expired") ||
|
||||
data.content.includes("auth")
|
||||
) {
|
||||
logout();
|
||||
}
|
||||
} else if (data.type === "system") {
|
||||
addSystemMessage(data.content);
|
||||
} else if (data.type === "message_deleted") {
|
||||
// Handle message deletion from server
|
||||
const messageElement = document.querySelector(
|
||||
`[data-message-id="${data.message_id}"]`,
|
||||
);
|
||||
if (messageElement) {
|
||||
messageElement.remove();
|
||||
}
|
||||
|
||||
// Update chat history
|
||||
if (messageHistory[currentRecipient]) {
|
||||
messageHistory[currentRecipient] = messageHistory[
|
||||
currentRecipient
|
||||
].filter((msg) => msg.id !== data.message_id);
|
||||
}
|
||||
|
||||
// Reload chats to update last message
|
||||
loadChats();
|
||||
} else {
|
||||
handleChatMessage(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error parsing message:", e, "Raw data:", event.data);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
console.log("WebSocket disconnected:", event.code, event.reason);
|
||||
updateConnectionStatus("disconnected", "Disconnected");
|
||||
addSystemMessage("Disconnected from server");
|
||||
document.getElementById("sendButton").disabled = true;
|
||||
document.getElementById("messageInput").disabled = true;
|
||||
|
||||
// Attempt to reconnect if this wasn't a normal closure
|
||||
if (event.code !== 1000 && event.code !== 1001) {
|
||||
reconnectWebSocket();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error("WebSocket error:", error);
|
||||
updateConnectionStatus("disconnected", "Connection error");
|
||||
addSystemMessage("Connection error");
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
document.getElementById("deleteModal").addEventListener("click", function (e) {
|
||||
if (e.target === this) {
|
||||
closeDeleteModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Modal functions
|
||||
function showDeleteModal(messageId) {
|
||||
messageToDelete = messageId;
|
||||
document.getElementById("deleteModal").style.display = "flex";
|
||||
document.getElementById("deleteModal").style.zIndex = 10;
|
||||
document.body.classList.add("modal-open");
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
messageToDelete = null;
|
||||
document.getElementById("deleteModal").style.display = "none";
|
||||
document.body.classList.remove("modal-open");
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
let ws = null;
|
||||
let currentUser = "";
|
||||
let currentToken = "";
|
||||
let currentRecipient = "";
|
||||
let currentUserAvatar = "";
|
||||
let currentUserPhone = "";
|
||||
let chats = [];
|
||||
let searchResults = [];
|
||||
let messageHistory = {};
|
||||
let currentChatTab = "existing";
|
||||
let messageToDelete = null;
|
||||
let isMobile = window.innerWidth <= 768;
|
||||
let reconnectTimeout = null;
|
||||
let reconnectAttempts = 0;
|
||||
const MAX_RECONNECT_ATTEMPTS = 5;
|
||||
const RECONNECT_DELAY = 3000;
|
||||
|
||||
function changePhoneNumber() {
|
||||
const newPhone = prompt("Enter your new phone number:", currentUserPhone);
|
||||
if (newPhone && newPhone.trim() !== "") {
|
||||
// In a real app, you would send this to the server
|
||||
currentUserPhone = newPhone.trim();
|
||||
localStorage.setItem("chatUserPhone", currentUserPhone);
|
||||
updateProfileModal();
|
||||
showAlert("Phone number updated successfully!", "success");
|
||||
}
|
||||
}
|
||||
|
||||
function showTab(tabName) {
|
||||
document
|
||||
.querySelectorAll(".auth-tab")
|
||||
.forEach((tab) => tab.classList.remove("active"));
|
||||
document
|
||||
.querySelectorAll(".auth-form")
|
||||
.forEach((form) => form.classList.remove("active"));
|
||||
|
||||
if (tabName === "login") {
|
||||
document.querySelector(".auth-tab:nth-child(1)").classList.add("active");
|
||||
document.getElementById("loginForm").classList.add("active");
|
||||
} else {
|
||||
document.querySelector(".auth-tab:nth-child(2)").classList.add("active");
|
||||
document.getElementById("registerForm").classList.add("active");
|
||||
}
|
||||
|
||||
hideAlert();
|
||||
}
|
||||
|
||||
function showAlert(message, type) {
|
||||
const alert = document.getElementById("alert");
|
||||
alert.textContent = message;
|
||||
alert.className = `alert alert-${type}`;
|
||||
alert.style.display = "block";
|
||||
|
||||
// Auto hide after 5 seconds
|
||||
setTimeout(hideAlert, 5000);
|
||||
}
|
||||
|
||||
function hideAlert() {
|
||||
document.getElementById("alert").style.display = "none";
|
||||
}
|
||||
|
||||
async function validateToken(token, username) {
|
||||
try {
|
||||
const response = await fetch("/api/validate-token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
currentUser = data.username;
|
||||
currentToken = token;
|
||||
currentUserAvatar = data.avatar_url;
|
||||
|
||||
localStorage.setItem("chatToken", token);
|
||||
localStorage.setItem("chatUsername", data.username);
|
||||
|
||||
document.getElementById("authContainer").style.display = "none";
|
||||
document.getElementById("chatContainer").style.display = "flex";
|
||||
|
||||
updateUserProfile();
|
||||
loadChats();
|
||||
connectWebSocket();
|
||||
} else {
|
||||
localStorage.removeItem("chatToken");
|
||||
localStorage.removeItem("chatUsername");
|
||||
showAlert("Session expired. Please login again.", "error");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Token validation failed:", error);
|
||||
localStorage.removeItem("chatToken");
|
||||
localStorage.removeItem("chatUsername");
|
||||
showAlert("Session validation failed. Please login again.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function updateUserProfile() {
|
||||
document.getElementById("currentUsername").textContent = currentUser;
|
||||
updateAllAvatars();
|
||||
}
|
||||
|
||||
function generateId() {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
}
|
||||
|
||||
function formatTime(timestamp) {
|
||||
if (!timestamp) return "";
|
||||
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diff = now - date;
|
||||
|
||||
if (diff < 24 * 60 * 60 * 1000) {
|
||||
return date.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} else {
|
||||
return date.toLocaleDateString([], {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
async function login() {
|
||||
const phone = document.getElementById("loginPhone").value.trim();
|
||||
const password = document.getElementById("loginPassword").value;
|
||||
|
||||
if (!phone || !password) {
|
||||
showAlert("Please fill all fields", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone, password }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
localStorage.setItem("chatToken", data.token);
|
||||
localStorage.setItem("chatUsername", data.username);
|
||||
localStorage.setItem("chatUserPhone", phone);
|
||||
|
||||
currentUser = data.username;
|
||||
currentToken = data.token;
|
||||
currentUserPhone = phone;
|
||||
|
||||
// Get user profile to load avatar
|
||||
const profileResponse = await fetch("/api/profile", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${currentToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (profileResponse.ok) {
|
||||
const profile = await profileResponse.json();
|
||||
currentUserAvatar = profile.avatar_url;
|
||||
}
|
||||
|
||||
document.getElementById("authContainer").style.display = "none";
|
||||
document.getElementById("chatContainer").style.display = "flex";
|
||||
|
||||
updateUserProfile();
|
||||
loadChats();
|
||||
connectWebSocket();
|
||||
} else {
|
||||
showAlert(data.message, "error");
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert("Login failed. Please try again.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem("chatToken");
|
||||
localStorage.removeItem("chatUsername");
|
||||
localStorage.removeItem("chatUserPhone");
|
||||
closeProfileModal();
|
||||
|
||||
if (ws) {
|
||||
ws.close();
|
||||
ws = null;
|
||||
}
|
||||
|
||||
clearReconnection();
|
||||
|
||||
currentUser = "";
|
||||
currentToken = "";
|
||||
currentRecipient = "";
|
||||
currentUserAvatar = "";
|
||||
currentUserPhone = "";
|
||||
chats = [];
|
||||
searchResults = [];
|
||||
messageHistory = {};
|
||||
|
||||
document.getElementById("authContainer").style.display = "block";
|
||||
document.getElementById("chatContainer").style.display = "none";
|
||||
|
||||
document.getElementById("loginPhone").value = "";
|
||||
document.getElementById("loginPassword").value = "";
|
||||
document.getElementById("registerPhone").value = "";
|
||||
document.getElementById("registerUsername").value = "";
|
||||
document.getElementById("registerPassword").value = "";
|
||||
|
||||
showTab("login");
|
||||
}
|
||||
|
||||
document
|
||||
.getElementById("loginPassword")
|
||||
.addEventListener("keypress", function (e) {
|
||||
if (e.key === "Enter") login();
|
||||
});
|
||||
|
||||
document.getElementById("loginPhone").focus();
|
||||
@@ -0,0 +1,245 @@
|
||||
// Function to delete message
|
||||
async function deleteMessage(messageId, forEveryone) {
|
||||
try {
|
||||
const response = await fetch("/api/delete-message", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${currentToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message_id: messageId,
|
||||
for_everyone: forEveryone,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
console.log(`✅ Message deleted: ${data.message}`);
|
||||
showAlert(data.message, "success");
|
||||
|
||||
// Remove message from UI
|
||||
const messageElement = document.querySelector(
|
||||
`[data-message-id="${messageId}"]`,
|
||||
);
|
||||
if (messageElement) {
|
||||
messageElement.remove();
|
||||
}
|
||||
|
||||
// Update chat history
|
||||
if (messageHistory[currentRecipient]) {
|
||||
messageHistory[currentRecipient] = messageHistory[
|
||||
currentRecipient
|
||||
].filter((msg) => msg.id !== messageId);
|
||||
}
|
||||
|
||||
// Reload chats to update last message
|
||||
loadChats();
|
||||
} else {
|
||||
console.error("❌ Failed to delete message:", data.message);
|
||||
showAlert(data.message, "error");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Error deleting message:", error);
|
||||
showAlert("Error deleting message", "error");
|
||||
}
|
||||
}
|
||||
|
||||
// Function to mark messages as read
|
||||
async function markMessagesAsRead(fromUser) {
|
||||
try {
|
||||
const response = await fetch("/api/mark-read", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${currentToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from_user: fromUser,
|
||||
to_user: currentUser,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
console.log(`✅ Marked messages as read from ${fromUser}`);
|
||||
// Обновляем список чатов чтобы убрать красный badge
|
||||
loadChats();
|
||||
} else {
|
||||
console.error("❌ Failed to mark messages as read:", data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Error marking messages as read:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function deleteMessageForMe() {
|
||||
if (messageToDelete) {
|
||||
deleteMessage(messageToDelete, false);
|
||||
closeDeleteModal();
|
||||
}
|
||||
}
|
||||
|
||||
function deleteMessageForEveryone() {
|
||||
if (messageToDelete) {
|
||||
deleteMessage(messageToDelete, true);
|
||||
closeDeleteModal();
|
||||
}
|
||||
}
|
||||
|
||||
function handleChatMessage(msg) {
|
||||
console.log("Handling chat message:", msg);
|
||||
const otherUser = msg.from_user === currentUser ? msg.to_user : msg.from_user;
|
||||
|
||||
if (!messageHistory[otherUser]) {
|
||||
messageHistory[otherUser] = [];
|
||||
}
|
||||
|
||||
const messageExists = messageHistory[otherUser].some(
|
||||
(existingMsg) => existingMsg.id === msg.id,
|
||||
);
|
||||
|
||||
if (!messageExists) {
|
||||
messageHistory[otherUser].push(msg);
|
||||
}
|
||||
|
||||
loadChats();
|
||||
|
||||
if (otherUser === currentRecipient) {
|
||||
displayMessage(msg, true);
|
||||
// Если сообщение пришло в открытый чат, сразу помечаем как прочитанное
|
||||
if (msg.from_user === currentRecipient) {
|
||||
markMessagesAsRead(currentRecipient);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function displayMessage(msg, scrollToBottom) {
|
||||
const chatMessages = document.getElementById("chatMessages");
|
||||
|
||||
// Remove empty state if it exists
|
||||
const emptyState = chatMessages.querySelector(".empty-state");
|
||||
if (emptyState) {
|
||||
emptyState.remove();
|
||||
}
|
||||
|
||||
const messageDiv = document.createElement("div");
|
||||
messageDiv.className = `message ${msg.from_user === currentUser ? "outgoing" : "incoming"}`;
|
||||
messageDiv.setAttribute("data-message-id", msg.id);
|
||||
|
||||
if (msg.message_type === "system") {
|
||||
messageDiv.className = "system-message";
|
||||
messageDiv.textContent = msg.content;
|
||||
} else {
|
||||
const isOutgoing = msg.from_user === currentUser;
|
||||
|
||||
if (!isOutgoing) {
|
||||
const senderDiv = document.createElement("div");
|
||||
senderDiv.className = "message-sender";
|
||||
senderDiv.textContent = msg.from_user;
|
||||
messageDiv.appendChild(senderDiv);
|
||||
}
|
||||
|
||||
const contentDiv = document.createElement("div");
|
||||
contentDiv.className = "message-content";
|
||||
contentDiv.textContent = msg.content;
|
||||
messageDiv.appendChild(contentDiv);
|
||||
|
||||
const timeDiv = document.createElement("div");
|
||||
timeDiv.className = "message-time";
|
||||
const date = new Date(msg.timestamp);
|
||||
timeDiv.textContent = date.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
messageDiv.appendChild(timeDiv);
|
||||
|
||||
// Add delete button for user's own messages or all messages
|
||||
if (msg.from_user === currentUser) {
|
||||
const actionsDiv = document.createElement("div");
|
||||
actionsDiv.className = "message-actions";
|
||||
const deleteBtn = document.createElement("button");
|
||||
deleteBtn.className = "delete-btn";
|
||||
deleteBtn.innerHTML = "×";
|
||||
deleteBtn.title = "Delete message";
|
||||
deleteBtn.onclick = (e) => {
|
||||
console.log("delete message button clicked");
|
||||
e.stopPropagation();
|
||||
showDeleteModal(msg.id);
|
||||
};
|
||||
actionsDiv.appendChild(deleteBtn);
|
||||
messageDiv.appendChild(actionsDiv);
|
||||
}
|
||||
}
|
||||
|
||||
chatMessages.appendChild(messageDiv);
|
||||
console.log("message added");
|
||||
|
||||
if (scrollToBottom) {
|
||||
setTimeout(() => {
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
function addSystemMessage(content) {
|
||||
const chatMessages = document.getElementById("chatMessages");
|
||||
|
||||
// Remove empty state if it exists
|
||||
const emptyState = chatMessages.querySelector(".empty-state");
|
||||
if (emptyState) {
|
||||
emptyState.remove();
|
||||
}
|
||||
|
||||
const systemDiv = document.createElement("div");
|
||||
systemDiv.className = "system-message";
|
||||
systemDiv.textContent = content;
|
||||
chatMessages.appendChild(systemDiv);
|
||||
|
||||
setTimeout(() => {
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function sendMessage() {
|
||||
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;
|
||||
}
|
||||
|
||||
const input = document.getElementById("messageInput");
|
||||
const content = input.value.trim();
|
||||
|
||||
if (!content) return;
|
||||
|
||||
const message = {
|
||||
id: generateId(),
|
||||
from_user: currentUser,
|
||||
to_user: currentRecipient,
|
||||
content: content,
|
||||
timestamp: Date.now(),
|
||||
message_type: "text",
|
||||
};
|
||||
|
||||
console.log("Sending message:", message);
|
||||
ws.send(JSON.stringify(message));
|
||||
input.value = "";
|
||||
input.style.height = "auto";
|
||||
|
||||
setTimeout(() => loadChats(), 100);
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
document
|
||||
.getElementById("messageInput")
|
||||
.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
function openProfileModal() {
|
||||
updateProfileModal();
|
||||
document.getElementById("profileModal").style.display = "flex";
|
||||
document.body.classList.add("modal-open");
|
||||
}
|
||||
|
||||
function closeProfileModal() {
|
||||
document.getElementById("profileModal").style.display = "none";
|
||||
document.body.classList.remove("modal-open");
|
||||
}
|
||||
|
||||
function updateProfileModal() {
|
||||
// Update profile modal content
|
||||
document.getElementById("profileModalName").textContent = currentUser;
|
||||
document.getElementById("profileModalUsername").textContent = currentUser;
|
||||
document.getElementById("profileModalPhone").textContent = currentUserPhone;
|
||||
document.getElementById("profileModalPhoneNumber").textContent =
|
||||
currentUserPhone;
|
||||
|
||||
// Update large avatar in modal
|
||||
const modalAvatar = document.getElementById("profileModalAvatar");
|
||||
if (currentUserAvatar) {
|
||||
modalAvatar.innerHTML = `<img src="${currentUserAvatar}" alt="${currentUser}">`;
|
||||
} else {
|
||||
modalAvatar.innerHTML = currentUser.charAt(0).toUpperCase();
|
||||
}
|
||||
|
||||
// Add the upload overlay
|
||||
modalAvatar.innerHTML +=
|
||||
'<div class="avatar-upload-overlay">Change Photo</div>';
|
||||
}
|
||||
|
||||
document.getElementById("profileModal").addEventListener("click", function (e) {
|
||||
if (e.target === this) {
|
||||
closeProfileModal();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
async function register() {
|
||||
const phone = document.getElementById("registerPhone").value.trim();
|
||||
const username = document.getElementById("registerUsername").value.trim();
|
||||
const password = document.getElementById("registerPassword").value;
|
||||
|
||||
if (!phone || !username || !password) {
|
||||
showAlert("Please fill all fields", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
showAlert("Password must be at least 6 characters", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone, username, password }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
localStorage.setItem("chatToken", data.token);
|
||||
localStorage.setItem("chatUsername", data.username);
|
||||
localStorage.setItem("chatUserPhone", phone);
|
||||
|
||||
currentUser = data.username;
|
||||
currentToken = data.token;
|
||||
currentUserPhone = phone;
|
||||
|
||||
document.getElementById("authContainer").style.display = "none";
|
||||
document.getElementById("chatContainer").style.display = "flex";
|
||||
|
||||
updateUserProfile();
|
||||
loadChats();
|
||||
connectWebSocket();
|
||||
|
||||
showAlert("Registration successful!", "success");
|
||||
} else {
|
||||
showAlert(data.message, "error");
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert("Registration failed. Please try again.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
document
|
||||
.getElementById("registerPassword")
|
||||
.addEventListener("keypress", function (e) {
|
||||
if (e.key === "Enter") register();
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
let searchTimeout;
|
||||
document.getElementById("searchInput").addEventListener("input", function (e) {
|
||||
clearTimeout(searchTimeout);
|
||||
const query = e.target.value.trim();
|
||||
|
||||
if (query.length < 2) {
|
||||
document.getElementById("searchResults").innerHTML =
|
||||
'<div class="empty-state"><p>Enter at least 2 characters to search</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(() => searchUsers(query), 300);
|
||||
});
|
||||
|
||||
async function searchUsers(query) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/search-users?q=${encodeURIComponent(query)}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${currentToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
searchResults = await response.json();
|
||||
renderSearchResults();
|
||||
} else {
|
||||
console.error("Search failed");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error searching users:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function renderSearchResults() {
|
||||
const searchResultsContainer = document.getElementById("searchResults");
|
||||
searchResultsContainer.innerHTML = "";
|
||||
|
||||
if (searchResults.length === 0) {
|
||||
searchResultsContainer.innerHTML =
|
||||
'<div class="empty-state"><p>No users found</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
searchResults.forEach((user) => {
|
||||
const div = document.createElement("div");
|
||||
div.className = "chat-item";
|
||||
|
||||
const avatarContent = user.avatar_url
|
||||
? `<img src="${user.avatar_url}" alt="${user.username}">`
|
||||
: user.username.charAt(0).toUpperCase();
|
||||
|
||||
div.innerHTML = `
|
||||
<div class="chat-avatar">${avatarContent}</div>
|
||||
<div class="chat-info">
|
||||
<div class="chat-username">${user.username}</div>
|
||||
<div class="chat-phone">${user.phone}</div>
|
||||
<div class="chat-last-message">Click to start chatting</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
div.onclick = () => {
|
||||
selectChat(user.username, user.phone, user.avatar_url);
|
||||
showChatTab("existing");
|
||||
document.getElementById("searchInput").value = "";
|
||||
if (isMobile) {
|
||||
hideChatsPanel();
|
||||
}
|
||||
};
|
||||
|
||||
searchResultsContainer.appendChild(div);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
let currentTheme = localStorage.getItem("theme") || "light";
|
||||
|
||||
// Initialize theme
|
||||
document.body.setAttribute("data-theme", currentTheme);
|
||||
|
||||
function toggleTheme() {
|
||||
currentTheme = currentTheme === "light" ? "dark" : "light";
|
||||
document.body.setAttribute("data-theme", currentTheme);
|
||||
localStorage.setItem("theme", currentTheme);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Check for saved session on page load
|
||||
window.addEventListener("DOMContentLoaded", function () {
|
||||
const savedToken = localStorage.getItem("chatToken");
|
||||
const savedUsername = localStorage.getItem("chatUsername");
|
||||
const savedPhone = localStorage.getItem("chatUserPhone");
|
||||
|
||||
if (savedToken && savedUsername) {
|
||||
currentUserPhone = savedPhone;
|
||||
validateToken(savedToken, savedUsername);
|
||||
}
|
||||
|
||||
// Auto-resize textarea
|
||||
const messageInput = document.getElementById("messageInput");
|
||||
messageInput.addEventListener("input", function () {
|
||||
this.style.height = "auto";
|
||||
this.style.height = Math.min(this.scrollHeight, 100) + "px";
|
||||
});
|
||||
|
||||
// Update mobile detection on resize
|
||||
window.addEventListener("resize", function () {
|
||||
isMobile = window.innerWidth <= 768;
|
||||
if (!isMobile) {
|
||||
// On desktop, ensure chats panel is visible
|
||||
document.getElementById("chatsPanel").classList.remove("active");
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user