Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e8eef8d2a | ||
|
|
97d170e10a | ||
|
|
25b1a6292f | ||
|
|
fe918433e8 | ||
|
|
9f0681563e | ||
|
|
78ca22e1b7 | ||
|
|
9016889a78 | ||
|
|
8cb6143bac | ||
|
|
bb87570ef9 |
Generated
+389
@@ -32,6 +32,15 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
@@ -47,6 +56,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
@@ -527,6 +542,12 @@ dependencies = [
|
||||
"zune-inflate",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "extended"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.3.0"
|
||||
@@ -1054,6 +1075,8 @@ checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.16.1",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1262,10 +1285,13 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"symphonia",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"utoipa",
|
||||
"utoipa-swagger-ui",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -1591,6 +1617,30 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
|
||||
dependencies = [
|
||||
"proc-macro-error-attr",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error-attr"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.103"
|
||||
@@ -1683,6 +1733,35 @@ dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c"
|
||||
|
||||
[[package]]
|
||||
name = "ron"
|
||||
version = "0.7.1"
|
||||
@@ -1714,6 +1793,40 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed"
|
||||
version = "8.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27"
|
||||
dependencies = [
|
||||
"rust-embed-impl",
|
||||
"rust-embed-utils",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed-impl"
|
||||
version = "8.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rust-embed-utils",
|
||||
"syn 2.0.111",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed-utils"
|
||||
version = "8.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1"
|
||||
dependencies = [
|
||||
"sha2",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-ini"
|
||||
version = "0.18.0"
|
||||
@@ -1749,6 +1862,15 @@ version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.28"
|
||||
@@ -2188,6 +2310,201 @@ version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "symphonia"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"symphonia-bundle-flac",
|
||||
"symphonia-bundle-mp3",
|
||||
"symphonia-codec-aac",
|
||||
"symphonia-codec-adpcm",
|
||||
"symphonia-codec-alac",
|
||||
"symphonia-codec-pcm",
|
||||
"symphonia-codec-vorbis",
|
||||
"symphonia-core",
|
||||
"symphonia-format-caf",
|
||||
"symphonia-format-isomp4",
|
||||
"symphonia-format-mkv",
|
||||
"symphonia-format-ogg",
|
||||
"symphonia-format-riff",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-bundle-flac"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
"symphonia-utils-xiph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-bundle-mp3"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-codec-aac"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-codec-adpcm"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-codec-alac"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-codec-pcm"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-codec-vorbis"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-utils-xiph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-core"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bitflags 1.3.2",
|
||||
"bytemuck",
|
||||
"lazy_static",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-format-caf"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8faf379316b6b6e6bbc274d00e7a592e0d63ff1a7e182ce8ba25e24edd3d096"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-format-isomp4"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
"symphonia-utils-xiph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-format-mkv"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
"symphonia-utils-xiph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-format-ogg"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
"symphonia-utils-xiph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-format-riff"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f"
|
||||
dependencies = [
|
||||
"extended",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-metadata"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-utils-xiph"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16"
|
||||
dependencies = [
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
@@ -2593,6 +2910,47 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "utoipa"
|
||||
version = "4.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5afb1a60e207dca502682537fefcfd9921e71d0b83e9576060f09abc6efab23"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"utoipa-gen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utoipa-gen"
|
||||
version = "4.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20c24e8ab68ff9ee746aad22d39b5535601e6416d1b0feeabf78be986a5c4392"
|
||||
dependencies = [
|
||||
"proc-macro-error",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utoipa-swagger-ui"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b39868d43c011961e04b41623e050aedf2cc93652562ff7935ce0f819aaf2da"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"mime_guess",
|
||||
"regex",
|
||||
"rust-embed",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"utoipa",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.18.1"
|
||||
@@ -2622,6 +2980,16 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||
dependencies = [
|
||||
"same-file",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
@@ -2704,6 +3072,15 @@ dependencies = [
|
||||
"wasite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
@@ -3036,6 +3413,18 @@ dependencies = [
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "0.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"flate2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-inflate"
|
||||
version = "0.2.54"
|
||||
|
||||
+4
-1
@@ -18,4 +18,7 @@ rand = "0.8"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1.0", features = ["v4"] }
|
||||
image = "0.24"
|
||||
config = "0.13"
|
||||
config = "0.13"
|
||||
symphonia = { version = "0.5", features = ["all"] }
|
||||
utoipa = { version = "4.2", features = ["axum_extras", "chrono"] }
|
||||
utoipa-swagger-ui = { version = "6.0", features = ["axum"] }
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
@@ -6,4 +6,5 @@ pub mod profile;
|
||||
pub mod register;
|
||||
pub mod search;
|
||||
pub mod security;
|
||||
pub mod upload;
|
||||
pub mod websock;
|
||||
|
||||
@@ -6,6 +6,15 @@ use sqlx::Row;
|
||||
use std::sync::Arc;
|
||||
|
||||
// УПРОЩЕННАЯ версия получения чатов
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/chats",
|
||||
responses(
|
||||
(status = 200, description = "List of chat partners", body = Vec<ChatPartner>),
|
||||
(status = 401, description = "Unauthorized", body = Vec<ChatPartner>)
|
||||
),
|
||||
security(("bearerAuth" = []))
|
||||
)]
|
||||
pub async fn get_chats_handler(
|
||||
State(state): State<Arc<state::AppState>>,
|
||||
headers: axum::http::HeaderMap,
|
||||
|
||||
@@ -7,6 +7,15 @@ use chrono::{Duration, Utc};
|
||||
use sqlx::Row;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/login",
|
||||
request_body = LoginRequest,
|
||||
responses(
|
||||
(status = 200, description = "Login successful", body = AuthResponse),
|
||||
(status = 400, description = "Invalid credentials", body = AuthResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn login_handler(
|
||||
State(state): State<Arc<state::AppState>>,
|
||||
Json(payload): Json<model::LoginRequest>,
|
||||
|
||||
@@ -6,6 +6,16 @@ use axum::extract::{Json, State};
|
||||
use std::sync::Arc;
|
||||
|
||||
// Mark messages as read endpoint
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/mark-read",
|
||||
request_body = MarkReadRequest,
|
||||
responses(
|
||||
(status = 200, description = "Messages marked as read", body = serde_json::Value),
|
||||
(status = 401, description = "Unauthorized", body = serde_json::Value)
|
||||
),
|
||||
security(("bearerAuth" = []))
|
||||
)]
|
||||
pub async fn mark_read_handler(
|
||||
State(state): State<Arc<state::AppState>>,
|
||||
headers: axum::http::HeaderMap,
|
||||
|
||||
@@ -4,8 +4,19 @@ use crate::utils;
|
||||
use axum::extract::{Json, State, ws::Message};
|
||||
use sqlx::Row;
|
||||
use std::sync::Arc;
|
||||
use tokio::fs;
|
||||
|
||||
// Delete message endpoint
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/delete-message",
|
||||
request_body = DeleteMessageRequest,
|
||||
responses(
|
||||
(status = 200, description = "Message deletion result", body = DeleteMessageResponse),
|
||||
(status = 401, description = "Unauthorized", body = DeleteMessageResponse)
|
||||
),
|
||||
security(("bearerAuth" = []))
|
||||
)]
|
||||
pub async fn delete_message_handler(
|
||||
State(state): State<Arc<state::AppState>>,
|
||||
headers: axum::http::HeaderMap,
|
||||
@@ -66,6 +77,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 +112,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)
|
||||
|
||||
@@ -6,6 +6,16 @@ use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
// Upload avatar endpoint
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/upload-avatar",
|
||||
request_body(content = Vec<u8>, description = "Avatar image file", content_type = "multipart/form-data"),
|
||||
responses(
|
||||
(status = 200, description = "Avatar upload result", body = UploadAvatarResponse),
|
||||
(status = 401, description = "Unauthorized", body = UploadAvatarResponse)
|
||||
),
|
||||
security(("bearerAuth" = []))
|
||||
)]
|
||||
pub async fn upload_avatar_handler(
|
||||
State(state): State<Arc<state::AppState>>,
|
||||
headers: axum::http::HeaderMap,
|
||||
|
||||
@@ -5,6 +5,15 @@ use axum::extract::{Json, State};
|
||||
use sqlx::Row;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/profile",
|
||||
responses(
|
||||
(status = 200, description = "User profile information", body = UserProfile),
|
||||
(status = 401, description = "Unauthorized", body = UserProfile)
|
||||
),
|
||||
security(("bearerAuth" = []))
|
||||
)]
|
||||
pub async fn profile_handler(
|
||||
State(state): State<Arc<state::AppState>>,
|
||||
headers: axum::http::HeaderMap,
|
||||
|
||||
+10
-1
@@ -6,6 +6,15 @@ use bcrypt::{DEFAULT_COST, hash};
|
||||
use chrono::{Duration, Utc};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/register",
|
||||
request_body = RegisterRequest,
|
||||
responses(
|
||||
(status = 200, description = "Registration successful", body = AuthResponse),
|
||||
(status = 400, description = "Registration failed", body = AuthResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn register_handler(
|
||||
State(state): State<Arc<state::AppState>>,
|
||||
Json(payload): Json<model::RegisterRequest>,
|
||||
@@ -20,7 +29,7 @@ pub async fn register_handler(
|
||||
});
|
||||
}
|
||||
|
||||
if payload.username.len() < 3
|
||||
if payload.username.len() < 2
|
||||
|| payload.username.len() > state.config.limits.max_username_length
|
||||
{
|
||||
return Json(model::AuthResponse {
|
||||
|
||||
@@ -5,6 +5,16 @@ use sqlx::Row;
|
||||
use std::sync::Arc;
|
||||
|
||||
// New endpoint to search users
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/search-users",
|
||||
params(
|
||||
("q" = String, Query, description = "Search query string")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "List of matching users", body = Vec<ChatPartner>)
|
||||
)
|
||||
)]
|
||||
pub async fn search_users_handler(
|
||||
State(state): State<Arc<state::AppState>>,
|
||||
Query(params): Query<model::SearchQuery>,
|
||||
|
||||
@@ -5,6 +5,15 @@ use axum::extract::{Json, State};
|
||||
use sqlx::Row;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/validate-token",
|
||||
request_body = ValidateTokenRequest,
|
||||
responses(
|
||||
(status = 200, description = "Token validation result", body = ValidateTokenResponse),
|
||||
(status = 400, description = "Invalid token", body = ValidateTokenResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn validate_token_handler(
|
||||
State(state): State<Arc<state::AppState>>,
|
||||
Json(payload): Json<model::ValidateTokenRequest>,
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
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 symphonia::core::formats::FormatOptions;
|
||||
use symphonia::core::meta::MetadataOptions;
|
||||
use symphonia::core::probe::Hint;
|
||||
use tokio::fs;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/upload",
|
||||
request_body(content = Vec<u8>, description = "File to upload", content_type = "multipart/form-data"),
|
||||
responses(
|
||||
(status = 200, description = "File upload result", body = serde_json::Value),
|
||||
(status = 401, description = "Unauthorized", body = serde_json::Value)
|
||||
),
|
||||
security(("bearerAuth" = []))
|
||||
)]
|
||||
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 => {
|
||||
// Extract duration for video files
|
||||
let duration = extract_duration(&filepath).await;
|
||||
|
||||
let video_info = VideoInfo {
|
||||
filename: filename.clone(),
|
||||
filepath: filepathweb.clone(),
|
||||
size,
|
||||
duration,
|
||||
mime_type: content_type.clone(),
|
||||
};
|
||||
|
||||
Json(serde_json::json!({
|
||||
"success": true,
|
||||
"file_type": "video",
|
||||
"info": video_info
|
||||
}))
|
||||
}
|
||||
FileType::Audio => {
|
||||
// Extract duration for audio files
|
||||
let duration = extract_duration(&filepath).await;
|
||||
|
||||
let audio_info = AudioInfo {
|
||||
filename: filename.clone(),
|
||||
filepath: filepathweb.clone(),
|
||||
size,
|
||||
duration,
|
||||
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,
|
||||
}
|
||||
|
||||
// Function to extract duration from audio/video files
|
||||
async fn extract_duration(filepath: &str) -> Option<f64> {
|
||||
use std::panic;
|
||||
use symphonia::core::io::MediaSourceStream;
|
||||
|
||||
// Wrap the Symphonia call in catch_unwind to prevent panics
|
||||
let result = panic::catch_unwind(|| {
|
||||
// Ensure the file is fully written before trying to read it
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
|
||||
// Open the file using std::fs::File to avoid async file issues with Symphonia
|
||||
let file = match std::fs::File::open(filepath) {
|
||||
Ok(file) => file,
|
||||
Err(e) => {
|
||||
eprintln!("Error opening media file: {}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Create a MediaSourceStream with proper buffering
|
||||
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
||||
|
||||
let mut hint = Hint::new();
|
||||
if let Some(ext) = Path::new(filepath).extension().and_then(|ext| ext.to_str()) {
|
||||
hint.with_extension(ext);
|
||||
}
|
||||
|
||||
// Configure format options to be more permissive with EBML formats like WebM
|
||||
let format_opts = FormatOptions {
|
||||
enable_gapless: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let metadata_opts = MetadataOptions::default();
|
||||
|
||||
let probed = match symphonia::default::get_probe().format(
|
||||
&hint,
|
||||
mss,
|
||||
&format_opts,
|
||||
&metadata_opts,
|
||||
) {
|
||||
Ok(probed) => probed,
|
||||
Err(e) => {
|
||||
eprintln!("Error probing media file: {}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Try to get the duration from the format reader's capabilities
|
||||
// Note: Symphonia doesn't have a direct duration() method, so we'll skip this approach
|
||||
// and rely on track-based duration calculation
|
||||
|
||||
// Fallback: try to get duration from track parameters
|
||||
let track = match probed
|
||||
.format
|
||||
.tracks()
|
||||
.iter()
|
||||
.find(|t| t.codec_params.codec != symphonia::core::codecs::CODEC_TYPE_NULL)
|
||||
{
|
||||
Some(track) => track,
|
||||
None => return None,
|
||||
};
|
||||
|
||||
// Try to get duration from track's time base and frame count
|
||||
let duration = track.codec_params.time_base.and_then(|tb| {
|
||||
track
|
||||
.codec_params
|
||||
.n_frames
|
||||
.and_then(|frames| Some(tb.calc_time(frames).seconds as f64))
|
||||
});
|
||||
|
||||
duration
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(duration) => duration,
|
||||
Err(panic_info) => {
|
||||
eprintln!("Symphonia panic caught: {:?}", panic_info);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
+21
-1
@@ -12,7 +12,27 @@ use futures_util::{SinkExt, StreamExt};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
// WebSocket handlers
|
||||
/// WebSocket endpoint for real-time chat messaging
|
||||
///
|
||||
/// ## Connection Flow
|
||||
/// 1. Client connects to `/ws`
|
||||
/// 2. Client sends authentication message: `{"type": "auth", "token": "your_token"}`
|
||||
/// 3. Server responds with system message on success or error message on failure
|
||||
/// 4. After successful authentication, client can send/receive chat messages
|
||||
///
|
||||
/// ## Message Types
|
||||
/// - Authentication: `{"type": "auth", "token": "jwt_token"}`
|
||||
/// - Chat message: `{"id": "...", "from_user": "...", "to_user": "...", "content": "...", "timestamp": 12345, "message_type": "text"}`
|
||||
/// - System message: `{"type": "system", "content": "Connected successfully"}`
|
||||
/// - Error message: `{"type": "error", "content": "Invalid token"}`
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/ws",
|
||||
responses(
|
||||
(status = 101, description = "WebSocket upgrade successful")
|
||||
),
|
||||
tag = "WebSocket"
|
||||
)]
|
||||
pub async fn websocket_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<Arc<state::AppState>>,
|
||||
|
||||
@@ -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
|
||||
|
||||
+11
@@ -1,17 +1,22 @@
|
||||
use axum::{
|
||||
Router,
|
||||
extract::DefaultBodyLimit,
|
||||
routing::{get, post},
|
||||
};
|
||||
use std::{collections::HashMap, net::SocketAddr, sync::Arc};
|
||||
use tokio::sync::RwLock;
|
||||
use tower_http::services::ServeDir;
|
||||
use utoipa::OpenApi;
|
||||
use utoipa_swagger_ui::SwaggerUi;
|
||||
|
||||
mod api;
|
||||
mod conf;
|
||||
mod db;
|
||||
mod model;
|
||||
mod openapi;
|
||||
mod state;
|
||||
mod utils;
|
||||
mod websocket_doc;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
@@ -25,6 +30,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 +66,13 @@ 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))
|
||||
.merge(
|
||||
SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", openapi::ApiDoc::openapi()),
|
||||
)
|
||||
.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)
|
||||
|
||||
+290
-14
@@ -1,93 +1,369 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct AuthResponse {
|
||||
/// Indicates if the operation was successful
|
||||
pub success: bool,
|
||||
/// Message describing the result
|
||||
pub message: String,
|
||||
/// Username of the authenticated user
|
||||
pub username: Option<String>,
|
||||
/// Authentication token
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct RegisterRequest {
|
||||
/// Phone number for registration
|
||||
pub phone: String,
|
||||
/// Desired username
|
||||
pub username: String,
|
||||
/// Password for account
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct LoginRequest {
|
||||
/// Phone number associated with the account
|
||||
pub phone: String,
|
||||
/// Password for the account
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ValidateTokenResponse {
|
||||
/// Indicates if the token is valid
|
||||
pub success: bool,
|
||||
/// Username associated with the token
|
||||
pub username: Option<String>,
|
||||
/// Phone number associated with the token
|
||||
pub phone: Option<String>,
|
||||
/// URL to the user's avatar
|
||||
pub avatar_url: Option<String>,
|
||||
/// Additional message about the validation
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct ValidateTokenRequest {
|
||||
/// Authentication token to validate
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ChatPartner {
|
||||
/// Username of the chat partner
|
||||
pub username: String,
|
||||
/// Phone number of the chat partner
|
||||
pub phone: String,
|
||||
/// URL to the partner's avatar
|
||||
pub avatar_url: Option<String>,
|
||||
/// Content of the last message
|
||||
pub last_message: Option<String>,
|
||||
/// Timestamp of the last message
|
||||
pub last_message_time: Option<i64>,
|
||||
/// Number of unread messages
|
||||
pub unread_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct SearchQuery {
|
||||
/// Search query string
|
||||
pub q: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct MarkReadRequest {
|
||||
/// User who sent the messages
|
||||
pub from_user: String,
|
||||
/// User who received the messages
|
||||
pub to_user: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct DeleteMessageRequest {
|
||||
/// ID of the message to delete
|
||||
pub message_id: String,
|
||||
/// Whether to delete for everyone
|
||||
pub for_everyone: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct DeleteMessageResponse {
|
||||
/// Indicates if the deletion was successful
|
||||
pub success: bool,
|
||||
/// Message describing the result
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct UploadAvatarResponse {
|
||||
/// Indicates if the upload was successful
|
||||
pub success: bool,
|
||||
/// URL to the uploaded avatar
|
||||
pub avatar_url: Option<String>,
|
||||
/// Message describing the result
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct UserProfile {
|
||||
/// Username of the profile
|
||||
pub username: String,
|
||||
/// Phone number of the profile
|
||||
pub phone: String,
|
||||
/// URL to the user's avatar
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
|
||||
pub enum MessageType {
|
||||
/// Text message
|
||||
#[serde(rename = "text")]
|
||||
Text,
|
||||
/// File attachment
|
||||
#[serde(rename = "file")]
|
||||
File,
|
||||
/// Photo attachment
|
||||
#[serde(rename = "photo")]
|
||||
Photo,
|
||||
/// Video attachment
|
||||
#[serde(rename = "video")]
|
||||
Video,
|
||||
/// Audio attachment
|
||||
#[serde(rename = "audio")]
|
||||
Audio,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
|
||||
pub struct ChatMessage {
|
||||
/// Unique identifier for the message
|
||||
pub id: String,
|
||||
/// User who sent the message
|
||||
pub from_user: String,
|
||||
/// User who received the message
|
||||
pub to_user: String,
|
||||
/// Content of the message
|
||||
pub content: String, // Store serialized content as JSON string
|
||||
/// Timestamp of when the message was sent
|
||||
pub timestamp: i64,
|
||||
/// Type of the message
|
||||
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, ToSchema)]
|
||||
pub struct FileInfo {
|
||||
/// Original filename
|
||||
pub filename: String,
|
||||
/// Path to the stored file
|
||||
pub filepath: String,
|
||||
/// Size of the file in bytes
|
||||
pub size: u64,
|
||||
/// MIME type of the file
|
||||
pub mime_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
|
||||
pub struct PhotoInfo {
|
||||
/// Original filename
|
||||
pub filename: String,
|
||||
/// Path to the stored photo
|
||||
pub filepath: String,
|
||||
/// Size of the photo in bytes
|
||||
pub size: u64,
|
||||
/// Width of the photo in pixels
|
||||
pub width: u32,
|
||||
/// Height of the photo in pixels
|
||||
pub height: u32,
|
||||
/// MIME type of the photo
|
||||
pub mime_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
|
||||
pub struct VideoInfo {
|
||||
/// Original filename
|
||||
pub filename: String,
|
||||
/// Path to the stored video
|
||||
pub filepath: String,
|
||||
/// Size of the video in bytes
|
||||
pub size: u64,
|
||||
/// Duration of the video in seconds
|
||||
pub duration: Option<f64>, // in seconds
|
||||
/// MIME type of the video
|
||||
pub mime_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
|
||||
pub struct AudioInfo {
|
||||
/// Original filename
|
||||
pub filename: String,
|
||||
/// Path to the stored audio
|
||||
pub filepath: String,
|
||||
/// Size of the audio in bytes
|
||||
pub size: u64,
|
||||
/// Duration of the audio in seconds
|
||||
pub duration: Option<f64>, // in seconds
|
||||
/// MIME type of the audio
|
||||
pub mime_type: String,
|
||||
}
|
||||
|
||||
// WebSocket message types
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
|
||||
pub struct WsAuthMessage {
|
||||
/// Type of the message (should be "auth")
|
||||
#[schema(example = "auth")]
|
||||
pub r#type: String,
|
||||
/// Authentication token
|
||||
#[schema(example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")]
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
|
||||
pub struct WsChatMessage {
|
||||
/// Unique identifier for the message
|
||||
#[schema(example = "123e4567-e89b-12d3-a456-426614174000")]
|
||||
pub id: String,
|
||||
/// User who sent the message
|
||||
#[schema(example = "john_doe")]
|
||||
pub from_user: String,
|
||||
/// User who received the message
|
||||
#[schema(example = "jane_doe")]
|
||||
pub to_user: String,
|
||||
/// Content of the message
|
||||
#[schema(example = "Hello, world!")]
|
||||
pub content: String,
|
||||
/// Timestamp of when the message was sent
|
||||
#[schema(example = 1678886400)]
|
||||
pub timestamp: i64,
|
||||
pub message_type: String,
|
||||
/// Type of the message
|
||||
pub message_type: MessageType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
|
||||
pub struct WsSystemMessage {
|
||||
/// Type of the system message
|
||||
#[schema(example = "system")]
|
||||
pub r#type: String,
|
||||
/// Content of the system message
|
||||
#[schema(example = "Connected successfully")]
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
|
||||
pub struct WsErrorMessage {
|
||||
/// Type of the error message
|
||||
#[schema(example = "error")]
|
||||
pub r#type: String,
|
||||
/// Content of the error message
|
||||
#[schema(example = "Invalid or expired token")]
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
use crate::api;
|
||||
use crate::model::*;
|
||||
use crate::websocket_doc::WebSocketApiDoc;
|
||||
use utoipa::OpenApi;
|
||||
|
||||
/// Chat messaging API
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
info(
|
||||
title = "Chat Messaging API",
|
||||
description = "A simple chat messaging API with user registration, login, and message handling.",
|
||||
version = "1.0.0"
|
||||
),
|
||||
paths(
|
||||
api::websock::websocket_handler,
|
||||
api::login::login_handler,
|
||||
api::register::register_handler,
|
||||
api::security::validate_token_handler,
|
||||
api::chats::get_chats_handler,
|
||||
api::search::search_users_handler,
|
||||
api::messages::mark_read_handler,
|
||||
api::messages::delete::delete_message_handler,
|
||||
api::profile::get::profile_handler,
|
||||
api::profile::avatar::upload_avatar_handler,
|
||||
api::upload::upload_file_handler
|
||||
),
|
||||
components(
|
||||
schemas(
|
||||
AuthResponse,
|
||||
RegisterRequest,
|
||||
LoginRequest,
|
||||
ValidateTokenResponse,
|
||||
ValidateTokenRequest,
|
||||
ChatPartner,
|
||||
SearchQuery,
|
||||
MarkReadRequest,
|
||||
DeleteMessageRequest,
|
||||
DeleteMessageResponse,
|
||||
UploadAvatarResponse,
|
||||
UserProfile,
|
||||
MessageType,
|
||||
ChatMessage,
|
||||
FileInfo,
|
||||
PhotoInfo,
|
||||
VideoInfo,
|
||||
AudioInfo,
|
||||
WsAuthMessage,
|
||||
WsChatMessage,
|
||||
WsSystemMessage,
|
||||
WsErrorMessage
|
||||
)
|
||||
),
|
||||
modifiers(&SecurityAddon, &WebSocketApiDoc)
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
|
||||
struct SecurityAddon;
|
||||
|
||||
impl utoipa::Modify for SecurityAddon {
|
||||
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
|
||||
if let Some(components) = openapi.components.as_mut() {
|
||||
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
|
||||
components.add_security_scheme(
|
||||
"bearerAuth",
|
||||
SecurityScheme::Http(
|
||||
HttpBuilder::new()
|
||||
.scheme(HttpAuthScheme::Bearer)
|
||||
.bearer_format("JWT")
|
||||
.build(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-2
@@ -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?;
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
use utoipa::Modify;
|
||||
|
||||
/// Расширенная документация для WebSocket API
|
||||
pub struct WebSocketApiDoc;
|
||||
|
||||
impl Modify for WebSocketApiDoc {
|
||||
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
|
||||
// Обновляем описание, добавляя информацию о WebSocket
|
||||
let current_description = openapi.info.description.clone().unwrap_or_default();
|
||||
let extended_description = format!(
|
||||
"{}\n\n## WebSocket API\n\n### Connection Flow\n1. Connect to `/ws`\n2. Send auth message: `{{\"type\": \"auth\", \"token\": \"your_jwt\"}}`\n3. Receive system/error message\n4. Send/receive chat messages\n\n### Message Types:\n- **Auth**: `{{\"type\": \"auth\", \"token\": \"...\"}}`\n- **Chat**: `{{\"id\": \"...\", \"from_user\": \"...\", \"to_user\": \"...\", \"content\": \"...\", \"timestamp\": 12345, \"message_type\": \"text\"}}`\n- **System**: `{{\"type\": \"system\", \"content\": \"...\"}}`\n- **Error**: `{{\"type\": \"error\", \"content\": \"...\"}}`",
|
||||
current_description
|
||||
);
|
||||
openapi.info.description = Some(extended_description);
|
||||
|
||||
// Добавляем тег для WebSocket
|
||||
let websocket_tag = utoipa::openapi::tag::TagBuilder::new()
|
||||
.name("WebSocket")
|
||||
.description(Some("Real-time chat messaging via WebSocket"))
|
||||
.build();
|
||||
|
||||
if let Some(tags) = &mut openapi.tags {
|
||||
tags.push(websocket_tag);
|
||||
} else {
|
||||
openapi.tags = Some(vec![websocket_tag]);
|
||||
}
|
||||
}
|
||||
}
|
||||
+248
-22
@@ -36,14 +36,14 @@
|
||||
|
||||
body {
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
background: var(--bg-primary);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
[data-theme="dark"] body {
|
||||
background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
/* Auth Container */
|
||||
@@ -241,9 +241,11 @@ body {
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
height: calc(100vh-20px);
|
||||
transition: transform 0.3s ease;
|
||||
border-right: 1px solid var(--border-color);
|
||||
border-radius: 20px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.chats-header {
|
||||
@@ -310,11 +312,13 @@ body {
|
||||
|
||||
.chat-item {
|
||||
padding: 12px 15px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
/*border-bottom: 1px solid var(--border-color);*/
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 15px;
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
.chat-item:hover {
|
||||
@@ -364,13 +368,13 @@ body {
|
||||
|
||||
.chat-phone {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
/*color: var(--text-secondary);*/
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.chat-last-message {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
/*color: var(--text-secondary);*/
|
||||
opacity: 0.8;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -387,7 +391,7 @@ body {
|
||||
|
||||
.chat-time {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
/*color: var(--text-secondary);*/
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
@@ -411,6 +415,7 @@ body {
|
||||
background: var(--bg-primary);
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
@@ -566,10 +571,14 @@ body {
|
||||
.chat-input-container {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: var(--bg-primary);
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: 15px;
|
||||
/*background: var(--bg-primary);*/
|
||||
/*border-top: 1px solid var(--border-color);*/
|
||||
/*padding: 15px;*/
|
||||
z-index: 10;
|
||||
background: #212121;
|
||||
padding: 2px;
|
||||
border-radius: 25px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.chat-input-area {
|
||||
@@ -577,17 +586,20 @@ body {
|
||||
gap: 10px;
|
||||
align-items: flex-end;
|
||||
max-height: 120px;
|
||||
/*background: #212121;*/
|
||||
}
|
||||
|
||||
.message-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
padding: 12px 15px;
|
||||
border: 2px solid var(--border-color);
|
||||
/*border: 2px solid var(--border-color);*/
|
||||
border-radius: 25px;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.3s ease;
|
||||
background: var(--bg-primary);
|
||||
/*background: var(--bg-primary);*/
|
||||
background: #212121;
|
||||
color: var(--text-primary);
|
||||
resize: none;
|
||||
min-height: 44px;
|
||||
@@ -597,16 +609,13 @@ body {
|
||||
}
|
||||
|
||||
.message-input:focus {
|
||||
border-color: var(--primary-color);
|
||||
/*border-color: var(--primary-color);*/
|
||||
}
|
||||
|
||||
.send-button {
|
||||
padding: 12px 20px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--primary-color),
|
||||
var(--primary-hover)
|
||||
);
|
||||
position: relative;
|
||||
padding: 0 28px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 25px;
|
||||
@@ -619,8 +628,13 @@ body {
|
||||
}
|
||||
|
||||
.send-button:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(52, 152, 219, 0.4);
|
||||
/*background: hsl(
|
||||
from var(--primary-color) calc(h - 2%) calc(s - 2%) calc(l - 2%)
|
||||
);*/
|
||||
/*transform: translateY(-2px);*/
|
||||
/*box-shadow: 0 5px 15px rgba(52, 152, 219, 0.4);*/
|
||||
/*background-opacity: 0.8;*/
|
||||
background: color-mix(in srgb, var(--primary-color) 70%, transparent);
|
||||
}
|
||||
|
||||
.send-button:disabled {
|
||||
@@ -662,6 +676,53 @@ body {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.record-button {
|
||||
padding: 12px 20px;
|
||||
background: linear-gradient(135deg, #e74c3c, #c0392b);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 25px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
flex-shrink: 0;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.record-button:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(231, 76, 60, 0.4);
|
||||
}
|
||||
|
||||
.record-button:disabled {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.record-button.recording {
|
||||
background: linear-gradient(135deg, #ff6b6b, #ff5252);
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(231, 76, 60, 0.7);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(231, 76, 60, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(231, 76, 60, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.system-message {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
@@ -1017,6 +1078,7 @@ body {
|
||||
.chat-panel {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.mobile-header {
|
||||
@@ -1170,7 +1232,7 @@ body {
|
||||
/* Safe area support for notches */
|
||||
@supports (padding: max(0px)) {
|
||||
.chat-input-container {
|
||||
padding-bottom: max(15px, env(safe-area-inset-bottom));
|
||||
/*padding-bottom: max(15px, env(safe-area-inset-bottom));*/
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -1285,3 +1347,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%;
|
||||
}
|
||||
}
|
||||
|
||||
+54
-2
@@ -194,14 +194,65 @@
|
||||
disabled
|
||||
rows="1"
|
||||
></textarea>
|
||||
<button class="add-button">+</button>
|
||||
<input
|
||||
type="file"
|
||||
id="fileInput"
|
||||
class="file-input"
|
||||
style="display: none"
|
||||
/>
|
||||
<button
|
||||
class="add-button"
|
||||
onclick="
|
||||
document.getElementById('fileInput').click()
|
||||
"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
id="recordVoiceBtn"
|
||||
class="record-button"
|
||||
title="Record voice message"
|
||||
onclick="toggleVoiceRecording()"
|
||||
>
|
||||
●
|
||||
</button>
|
||||
<button
|
||||
id="recordVideoBtn"
|
||||
class="record-button"
|
||||
title="Record video message"
|
||||
onclick="toggleVideoRecording()"
|
||||
>
|
||||
◉
|
||||
</button>
|
||||
<button
|
||||
onclick="sendMessage()"
|
||||
class="send-button"
|
||||
id="sendButton"
|
||||
disabled
|
||||
>
|
||||
Send
|
||||
<svg
|
||||
style="
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 6px;
|
||||
/*bottom: 5px;*/
|
||||
"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="55"
|
||||
height="34"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M10 14l11 -11" />
|
||||
<path
|
||||
d="M21 3l-6.5 18a.55 .55 0 0 1 -1 0l-3.5 -7l-7 -3.5a.55 .55 0 0 1 0 -1l18 -6.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -344,5 +395,6 @@
|
||||
<script src="js/deletemodal.js"></script>
|
||||
<script src="js/search.js"></script>
|
||||
<script src="js/avatar.js"></script>
|
||||
<script src="js/recording.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+276
-1
@@ -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">×</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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// Voice and video recording functionality
|
||||
|
||||
let mediaRecorder;
|
||||
let recordedChunks = [];
|
||||
let isRecording = false;
|
||||
let recordingType = null; // 'voice' or 'video'
|
||||
|
||||
// Toggle voice recording
|
||||
async function toggleVoiceRecording() {
|
||||
if (isRecording) {
|
||||
stopRecording();
|
||||
} else {
|
||||
await startVoiceRecording();
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle video recording
|
||||
async function toggleVideoRecording() {
|
||||
if (isRecording) {
|
||||
stopRecording();
|
||||
} else {
|
||||
await startVideoRecording();
|
||||
}
|
||||
}
|
||||
|
||||
// Start voice recording
|
||||
async function startVoiceRecording() {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
|
||||
mediaRecorder = new MediaRecorder(stream);
|
||||
recordedChunks = [];
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
recordedChunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = async () => {
|
||||
const blob = new Blob(recordedChunks, { type: 'audio/webm' });
|
||||
|
||||
// Create a temporary file object
|
||||
const file = new File([blob], `voice_message_${Date.now()}.webm`, { type: 'audio/webm' });
|
||||
|
||||
// Send the voice message
|
||||
await sendFileMessage(file);
|
||||
|
||||
// Stop the tracks
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
};
|
||||
|
||||
mediaRecorder.start();
|
||||
isRecording = true;
|
||||
recordingType = 'voice';
|
||||
|
||||
// Update UI to show recording state
|
||||
updateRecordingUI(true);
|
||||
|
||||
console.log('Voice recording started');
|
||||
} catch (error) {
|
||||
console.error('Error starting voice recording:', error);
|
||||
showAlert('Microphone access denied. Please allow microphone access to record voice messages.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Start video recording
|
||||
async function startVideoRecording() {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { width: { ideal: 640 }, height: { ideal: 480 } },
|
||||
audio: true
|
||||
});
|
||||
|
||||
mediaRecorder = new MediaRecorder(stream);
|
||||
recordedChunks = [];
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
recordedChunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = async () => {
|
||||
const blob = new Blob(recordedChunks, { type: 'video/webm' });
|
||||
|
||||
// Create a temporary file object
|
||||
const file = new File([blob], `video_message_${Date.now()}.webm`, { type: 'video/webm' });
|
||||
|
||||
// Send the video message
|
||||
await sendFileMessage(file);
|
||||
|
||||
// Stop the tracks
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
};
|
||||
|
||||
mediaRecorder.start();
|
||||
isRecording = true;
|
||||
recordingType = 'video';
|
||||
|
||||
// Update UI to show recording state
|
||||
updateRecordingUI(true);
|
||||
|
||||
console.log('Video recording started');
|
||||
} catch (error) {
|
||||
console.error('Error starting video recording:', error);
|
||||
showAlert('Camera access denied. Please allow camera and microphone access to record video messages.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Stop recording
|
||||
function stopRecording() {
|
||||
if (mediaRecorder && isRecording) {
|
||||
mediaRecorder.stop();
|
||||
isRecording = false;
|
||||
recordingType = null;
|
||||
|
||||
// Update UI to show stopped state
|
||||
updateRecordingUI(false);
|
||||
|
||||
console.log('Recording stopped');
|
||||
}
|
||||
}
|
||||
|
||||
// Update UI based on recording state
|
||||
function updateRecordingUI(isRecording) {
|
||||
const voiceBtn = document.getElementById('recordVoiceBtn');
|
||||
const videoBtn = document.getElementById('recordVideoBtn');
|
||||
|
||||
if (isRecording) {
|
||||
// Add recording class to the active button
|
||||
if (recordingType === 'voice') {
|
||||
voiceBtn.classList.add('recording');
|
||||
voiceBtn.textContent = '●'; // Solid circle to indicate recording
|
||||
} else if (recordingType === 'video') {
|
||||
videoBtn.classList.add('recording');
|
||||
videoBtn.textContent = '●'; // Solid circle to indicate recording
|
||||
}
|
||||
} else {
|
||||
// Remove recording class from both buttons
|
||||
voiceBtn.classList.remove('recording');
|
||||
videoBtn.classList.remove('recording');
|
||||
|
||||
// Reset button texts
|
||||
voiceBtn.textContent = '●';
|
||||
videoBtn.textContent = '◉';
|
||||
}
|
||||
}
|
||||
|
||||
// Handle window unload to clean up resources
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (isRecording) {
|
||||
stopRecording();
|
||||
}
|
||||
});
|
||||
|
||||
// Also stop recording when leaving the page
|
||||
window.addEventListener('pagehide', () => {
|
||||
if (isRecording) {
|
||||
stopRecording();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user