diff --git a/Cargo.lock b/Cargo.lock
index 624f5ca..a642299 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -11,17 +11,6 @@ dependencies = [
"libc",
]
-[[package]]
-name = "async-trait"
-version = "0.1.89"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -199,13 +188,13 @@ dependencies = [
"axum",
"dotenvy",
"futures",
+ "jsonwebtoken",
"oauth2",
"reqwest 0.13.2",
"serde",
"serde_json",
"time",
"tokio",
- "tower-sessions",
]
[[package]]
@@ -241,17 +230,6 @@ dependencies = [
"memchr",
]
-[[package]]
-name = "cookie"
-version = "0.18.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
-dependencies = [
- "percent-encoding",
- "time",
- "version_check",
-]
-
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -310,7 +288,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587"
dependencies = [
"powerfmt",
- "serde_core",
]
[[package]]
@@ -937,6 +914,21 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "jsonwebtoken"
+version = "9.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
+dependencies = [
+ "base64 0.22.1",
+ "js-sys",
+ "pem",
+ "ring",
+ "serde",
+ "serde_json",
+ "simple_asn1",
+]
+
[[package]]
name = "libc"
version = "0.2.180"
@@ -999,12 +991,31 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "num-bigint"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
[[package]]
name = "num-conv"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
+[[package]]
+name = "num-integer"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+dependencies = [
+ "num-traits",
+]
+
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -1069,6 +1080,16 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "pem"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
+dependencies = [
+ "base64 0.22.1",
+ "serde_core",
+]
+
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -1639,6 +1660,18 @@ dependencies = [
"libc",
]
+[[package]]
+name = "simple_asn1"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb"
+dependencies = [
+ "num-bigint",
+ "num-traits",
+ "thiserror 2.0.14",
+ "time",
+]
+
[[package]]
name = "slab"
version = "0.4.12"
@@ -1947,22 +1980,6 @@ dependencies = [
"tracing",
]
-[[package]]
-name = "tower-cookies"
-version = "0.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36"
-dependencies = [
- "axum-core",
- "cookie",
- "futures-util",
- "http 1.4.0",
- "parking_lot",
- "pin-project-lite",
- "tower-layer",
- "tower-service",
-]
-
[[package]]
name = "tower-http"
version = "0.6.8"
@@ -1993,57 +2010,6 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
-[[package]]
-name = "tower-sessions"
-version = "0.15.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "518dca34b74a17cadfcee06e616a09d2bd0c3984eff1769e1e76d58df978fc78"
-dependencies = [
- "async-trait",
- "http 1.4.0",
- "time",
- "tokio",
- "tower-cookies",
- "tower-layer",
- "tower-service",
- "tower-sessions-core",
- "tower-sessions-memory-store",
- "tracing",
-]
-
-[[package]]
-name = "tower-sessions-core"
-version = "0.15.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "568531ec3dfcf3ffe493de1958ae5662a0284ac5d767476ecdb6a34ff8c6b06c"
-dependencies = [
- "async-trait",
- "axum-core",
- "base64 0.22.1",
- "futures",
- "http 1.4.0",
- "parking_lot",
- "rand 0.9.2",
- "serde",
- "serde_json",
- "thiserror 2.0.14",
- "time",
- "tokio",
- "tracing",
-]
-
-[[package]]
-name = "tower-sessions-memory-store"
-version = "0.15.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "713fabf882b6560a831e2bbed6204048b35bdd60e50bbb722902c74f8df33460"
-dependencies = [
- "async-trait",
- "time",
- "tokio",
- "tower-sessions-core",
-]
-
[[package]]
name = "tracing"
version = "0.1.44"
@@ -2052,21 +2018,9 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
- "tracing-attributes",
"tracing-core",
]
-[[package]]
-name = "tracing-attributes"
-version = "0.1.31"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
[[package]]
name = "tracing-core"
version = "0.1.36"
diff --git a/Cargo.toml b/Cargo.toml
index 233fcda..23cfb31 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -8,16 +8,9 @@ axum = { version = "0.8.8", features = ["ws"] }
dotenvy = "0.15.7"
futures = "0.3.31"
oauth2 = "4.4.2"
+jsonwebtoken = "9.3.0"
reqwest = { version = "0.13.2", features = ["json"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
time = "0.3.47"
tokio = { version = "1.49.0", features = ["full"] }
-tower-sessions = "0.15.0"
-
-[profile.release]
-strip = true # Automatically strip symbols from the binary.
-opt-level = "z" # Optimize for size.
-lto = true # Enable Link Time Optimization
-codegen-units = 1 # Maximize LTO
-panic = "abort" # Abort on panic
diff --git a/index.html b/index.html
index ffe5d7b..8b9bf5e 100644
--- a/index.html
+++ b/index.html
@@ -11,6 +11,11 @@
--parchment: #f5e6d3;
--steam: #e0e0e0;
--gear-color: rgba(0, 0, 0, 0.2);
+ --online-green: #4caf50;
+ }
+
+ * {
+ box-sizing: border-box;
}
body {
@@ -34,11 +39,97 @@
padding-bottom: 10px;
letter-spacing: 2px;
text-transform: uppercase;
+ margin-bottom: 20px;
}
- #chat-container {
+ #main-container {
width: 100%;
- max-width: 800px;
+ max-width: 1000px;
+ display: flex;
+ gap: 15px;
+ height: 600px;
+ }
+
+ /* Contacts Sidebar */
+ #contacts-panel {
+ width: 250px;
+ background: rgba(43, 29, 14, 0.9);
+ border: 4px solid var(--copper);
+ border-radius: 10px;
+ box-shadow:
+ 0 0 15px var(--brass),
+ inset 0 0 20px #000;
+ display: flex;
+ flex-direction: column;
+ position: relative;
+ }
+
+ #contacts-header {
+ padding: 15px;
+ border-bottom: 2px solid var(--copper);
+ font-weight: bold;
+ color: var(--brass);
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ }
+
+ #contacts-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 10px;
+ scrollbar-width: thin;
+ scrollbar-color: var(--brass) var(--leather);
+ }
+
+ .contact {
+ padding: 12px 15px;
+ margin-bottom: 8px;
+ background: rgba(0, 0, 0, 0.3);
+ border: 1px solid var(--copper);
+ border-radius: 5px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ transition: all 0.2s;
+ }
+
+ .contact:hover {
+ background: rgba(212, 175, 55, 0.2);
+ border-color: var(--brass);
+ }
+
+ .contact.selected {
+ background: rgba(212, 175, 55, 0.3);
+ border-color: var(--brass);
+ box-shadow: 0 0 10px rgba(212, 175, 55, 0.3);
+ }
+
+ .contact .status-dot {
+ width: 10px;
+ height: 10px;
+ background: var(--online-green);
+ border-radius: 50%;
+ box-shadow: 0 0 5px var(--online-green);
+ }
+
+ .contact .username {
+ flex: 1;
+ font-weight: bold;
+ }
+
+ .contact .unread {
+ background: var(--copper);
+ color: var(--parchment);
+ padding: 2px 8px;
+ border-radius: 10px;
+ font-size: 0.8em;
+ font-weight: bold;
+ }
+
+ /* Chat Panel */
+ #chat-container {
+ flex: 1;
background: rgba(43, 29, 14, 0.9);
border: 4px solid var(--copper);
border-radius: 10px;
@@ -74,14 +165,37 @@
right: 10px;
}
+ #chat-header {
+ padding: 10px;
+ border-bottom: 2px solid var(--copper);
+ font-weight: bold;
+ color: var(--brass);
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ }
+
+ #no-chat-selected {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--copper);
+ font-style: italic;
+ }
+
#chat {
- height: 500px;
- overflow-y: scroll;
+ flex: 1;
+ overflow-y: auto;
border: 2px inset var(--copper);
background: rgba(0, 0, 0, 0.3);
padding: 15px;
scrollbar-width: thin;
scrollbar-color: var(--brass) var(--leather);
+ display: none;
+ }
+
+ #chat.active {
+ display: block;
}
#chat::-webkit-scrollbar {
@@ -107,16 +221,28 @@
box-shadow: 2px 2px 2px rgba(0, 0, 0, 0.3);
font-weight: bold;
position: relative;
+ max-width: 80%;
}
- .message::after {
- content: '';
- position: absolute;
- bottom: -5px;
- left: 10px;
- border-width: 5px 5px 0;
- border-style: solid;
- border-color: var(--copper) transparent;
+ .message.sent {
+ margin-left: auto;
+ background: linear-gradient(to bottom, var(--brass), var(--copper));
+ color: #2b1d0e;
+ }
+
+ .message.received {
+ margin-right: auto;
+ }
+
+ .message .sender {
+ font-size: 0.8em;
+ color: var(--copper);
+ margin-bottom: 4px;
+ }
+
+ .message.sent .sender {
+ color: #2b1d0e;
+ opacity: 0.7;
}
.system-msg {
@@ -128,14 +254,11 @@
background: none;
border: none;
box-shadow: none;
- }
-
- .system-msg::after {
- display: none;
+ max-width: 100%;
}
#controls {
- display: flex;
+ display: none;
gap: 10px;
padding: 10px;
background: #1a1a1a;
@@ -143,6 +266,10 @@
border-radius: 5px;
}
+ #controls.active {
+ display: flex;
+ }
+
input[type="text"] {
flex-grow: 1;
padding: 10px;
@@ -179,62 +306,261 @@
⚙ AetherChat ⚙
-
-
-
-
-
+
+
+
+
+
+
+
+
Click on a contact to begin transmission...
+
+
+
+
+
diff --git a/src/main.rs b/src/main.rs
index 69fb1d7..fb9106f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,211 +1,243 @@
use axum::{
+ Router,
extract::{
- ws::{Message, WebSocket, WebSocketUpgrade},
Query, State,
+ ws::{Message, WebSocket, WebSocketUpgrade},
},
http::StatusCode,
- response::{Html, IntoResponse, Redirect},
+ response::{Html, IntoResponse},
routing::get,
- Router,
};
use dotenvy::dotenv;
use futures::{sink::SinkExt, stream::StreamExt};
-use oauth2::{
- basic::BasicClient,AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenResponse,
- TokenUrl,
-};
-use std::sync::Arc;
-use tokio::sync::broadcast;
-use tower_sessions::{cookie::SameSite, Expiry, MemoryStore, Session, SessionManagerLayer};
+use serde::{Deserialize, Serialize};
+use std::{collections::HashMap, sync::Arc};
+use tokio::sync::{RwLock, mpsc};
mod user;
use user::User;
-// Define application state
-struct AppState {
- tx: broadcast::Sender
,
- oauth_client: BasicClient,
+// WebSocket message types for client-server communication
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "type")]
+enum WsMessage {
+ #[serde(rename = "user_list")]
+ UserList { users: Vec },
+ #[serde(rename = "private_message")]
+ PrivateMessage {
+ from: String,
+ to: String,
+ content: String,
+ },
+ #[serde(rename = "user_joined")]
+ UserJoined { username: String },
+ #[serde(rename = "system")]
+ System { content: String },
}
-const AFTER_LOGIN_URL: &str = "/";
+// Per-user connection channel
+type UserTx = mpsc::UnboundedSender;
+
+// Shared state for tracking online users and their connections
+struct ChatState {
+ // Map of username -> sender channel
+ connections: RwLock>,
+}
+
+impl ChatState {
+ fn new() -> Self {
+ Self {
+ connections: RwLock::new(HashMap::new()),
+ }
+ }
+
+ async fn add_user(&self, username: String, tx: UserTx) {
+ let mut conns = self.connections.write().await;
+ conns.insert(username, tx);
+ }
+
+ async fn remove_user(&self, username: &str) {
+ let mut conns = self.connections.write().await;
+ conns.remove(username);
+ }
+
+ async fn get_online_users(&self) -> Vec {
+ let conns = self.connections.read().await;
+ conns.keys().cloned().collect()
+ }
+
+ async fn send_to_user(&self, username: &str, message: &str) -> bool {
+ let conns = self.connections.read().await;
+ if let Some(tx) = conns.get(username) {
+ tx.send(message.to_string()).is_ok()
+ } else {
+ false
+ }
+ }
+
+ async fn broadcast_except(&self, message: &str, exclude: &str) {
+ let conns = self.connections.read().await;
+ for (username, tx) in conns.iter() {
+ if username != exclude {
+ let _ = tx.send(message.to_string());
+ }
+ }
+ }
+}
+
+// Application state
+struct AppState {
+ chat: ChatState,
+}
#[tokio::main]
async fn main() {
dotenv().ok();
- // Create a broadcast channel
- let (tx, _rx) = broadcast::channel(100);
-
- // OAuth configuration
- let client_id = dotenvy::var("CLIENT_ID").unwrap();
- let client_secret = dotenvy::var("CLIENT_SECRET").unwrap();
- let port = dotenvy::var("PORT").unwrap_or_else(|_| "3001".to_string());
- let host = dotenvy::var("HOST").unwrap_or_else(|_| "http://localhost:".to_string() + &port);
- // NOTE: In production, do not hardcode localhost
- let auth_url = AuthUrl::new("https://idm.flegr.me/application/o/authorize/".to_string())
- .expect("Invalid authorization endpoint URL");
- let token_url = TokenUrl::new("https://idm.flegr.me/application/o/token/".to_string())
- .expect("Invalid token endpoint URL");
-
- let client = BasicClient::new(
- ClientId::new(client_id),
- Some(ClientSecret::new(client_secret)),
- auth_url,
- Some(token_url),
- )
- .set_redirect_uri(
- RedirectUrl::new(host + "/auth/callback")
- .expect("Invalid redirect URL"),
- );
-
let app_state = Arc::new(AppState {
- tx,
- oauth_client: client,
+ chat: ChatState::new(),
});
- // Session configuration
- let session_store = MemoryStore::default();
- let session_layer = SessionManagerLayer::new(session_store)
- .with_secure(false) // For localhost; set to true in production with HTTPS
- .with_same_site(SameSite::Lax) // Ensure we can receive cookies from OAuth redirect
- .with_expiry(Expiry::OnInactivity(time::Duration::minutes(30)));
-
// Build application with routes
let app = Router::new()
.route("/", get(index))
- .route("/login", get(login))
- .route("/logout", get(logout))
- .route("/auth/callback", get(auth_callback))
.route("/ws", get(websocket_handler))
- .layer(session_layer)
.with_state(app_state);
// Run the app
let port = dotenvy::var("PORT").unwrap_or_else(|_| "3001".to_string());
- let listener = tokio::net::TcpListener::bind("0.0.0.0:".to_string() + &port).await.unwrap();
+ let listener = tokio::net::TcpListener::bind("0.0.0.0:".to_string() + &port)
+ .await
+ .unwrap();
println!("Chat server listening on http://0.0.0.0:{}", port);
axum::serve(listener, app).await.unwrap();
}
// Handlers
-async fn index(session: Session) -> impl IntoResponse {
- if let Some(_user) = session.get::("user").await.unwrap() {
- return Html(include_str!("../index.html")).into_response();
- }
- Redirect::to("/login").into_response()
+async fn index() -> impl IntoResponse {
+ Html(include_str!("../index.html"))
}
-async fn login(State(state): State>) -> impl IntoResponse {
- let (auth_url, _csrf_token) = state
- .oauth_client
- .authorize_url(oauth2::CsrfToken::new_random)
- .url();
-
- Redirect::to(auth_url.as_str())
+#[derive(Deserialize)]
+struct WsParams {
+ token: String,
}
-async fn logout(session: Session) -> impl IntoResponse {
- session.delete().await.unwrap();
- Redirect::to("/login")
-}
-
-#[derive(serde::Deserialize)]
-struct AuthRequest {
- code: String,
- state: String,
-}
-
-async fn auth_callback(
- Query(query): Query,
- State(state): State>,
- session: Session,
-) -> impl IntoResponse {
- let token = match state
- .oauth_client
- .exchange_code(oauth2::AuthorizationCode::new(query.code))
- .request_async(oauth2::reqwest::async_http_client)
- .await
- {
- Ok(t) => t,
- Err(e) => {
- return (
- StatusCode::BAD_REQUEST,
- format!("Failed to exchange authorization code: {}", e),
- )
- .into_response();
- }
- };
-
- let client = reqwest::Client::new();
- let user_info_resp = client
- .get("https://idm.flegr.me/application/o/userinfo/")
- .header("User-Agent", "axum-chat-app")
- .bearer_auth(token.access_token().secret())
- .send()
- .await;
-
- let user_data: User = match user_info_resp {
- Ok(resp) => match resp.json().await {
- Ok(u) => u,
- Err(e) => return (
- StatusCode::INTERNAL_SERVER_ERROR,
- format!("Failed to parse user info: {}", e)
- ).into_response()
- },
- Err(e) => return (
- StatusCode::INTERNAL_SERVER_ERROR,
- format!("Failed to fetch user info: {}", e)
- ).into_response()
- };
-
- session.insert("user", user_data).await.unwrap();
-
- Redirect::to(AFTER_LOGIN_URL).into_response()
+#[derive(Debug, Serialize, Deserialize)]
+struct Claims {
+ preferred_username: String,
+ // Add other fields as needed
}
async fn websocket_handler(
ws: WebSocketUpgrade,
+ Query(params): Query,
State(state): State>,
- session: Session,
) -> impl IntoResponse {
- if let Some(user) = session.get::("user").await.unwrap() {
- return ws.on_upgrade(move |socket| websocket(socket, state, user));
+ // For now, we'll verify the token by calling the userinfo endpoint.
+ // In a production app, you should verify the JWT signature locally using JWKS.
+ let client = reqwest::Client::new();
+ let user_info_resp = client
+ .get("https://idm.flegr.me/application/o/userinfo/")
+ .header("User-Agent", "axum-chat-app")
+ .bearer_auth(¶ms.token)
+ .send()
+ .await;
+
+ match user_info_resp {
+ Ok(resp) if resp.status().is_success() => {
+ let user: User = match resp.json().await {
+ Ok(u) => u,
+ Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
+ };
+ ws.on_upgrade(move |socket| handle_websocket(socket, state, user))
+ }
+ _ => StatusCode::UNAUTHORIZED.into_response(),
}
- StatusCode::UNAUTHORIZED.into_response()
}
-// WebSocket handler
-async fn websocket(stream: WebSocket, state: Arc, user: User) {
- let (mut sender, mut receiver) = stream.split();
- let mut rx = state.tx.subscribe();
+// WebSocket connection handler
+async fn handle_websocket(stream: WebSocket, state: Arc, user: User) {
+ let (mut ws_sender, mut ws_receiver) = stream.split();
+ let username = user.login.clone();
+
+ // Create channel for this user
+ let (tx, mut rx) = mpsc::unbounded_channel::();
+
+ // Add user to connections
+ state.chat.add_user(username.clone(), tx).await;
+ println!("User connected: {}", username);
+
+ // Send current user list to the newly connected user
+ let users = state.chat.get_online_users().await;
+ println!("Sending user list to {}: {:?}", username, users);
+ let user_list_msg = serde_json::to_string(&WsMessage::UserList { users }).unwrap();
+ let _ = ws_sender.send(Message::Text(user_list_msg.into())).await;
// Send welcome message
- let msg = format!("System: Welcome, {}!", user.login);
- let _ = sender.send(Message::Text(msg.into())).await;
+ let welcome = serde_json::to_string(&WsMessage::System {
+ content: format!("Welcome, {}!", username),
+ })
+ .unwrap();
+ let _ = ws_sender.send(Message::Text(welcome.into())).await;
- // Send task
- let mut send_task = tokio::spawn(async move {
- while let Ok(msg) = rx.recv().await {
- if sender.send(Message::Text(msg.into())).await.is_err() {
+ // Task to forward messages from channel to WebSocket
+ let send_task = tokio::spawn(async move {
+ while let Some(msg) = rx.recv().await {
+ if ws_sender.send(Message::Text(msg.into())).await.is_err() {
break;
}
}
});
- // Receive task
- let tx = state.tx.clone();
- let username = user.login.clone();
- let mut recv_task = tokio::spawn(async move {
- while let Some(Ok(Message::Text(text))) = receiver.next().await {
- // Prepend username
- let msg = format!("{}: {}", username, text);
- let _ = tx.send(msg);
+ // Broadcast user joined to everyone else (after send_task is spawned so others receive it)
+ let join_msg = serde_json::to_string(&WsMessage::UserJoined {
+ username: username.clone(),
+ })
+ .unwrap();
+ state.chat.broadcast_except(&join_msg, &username).await;
+
+ // Task to receive messages from WebSocket and route them
+ let state_clone = state.clone();
+ let username_clone = username.clone();
+ let recv_task = tokio::spawn(async move {
+ while let Some(Ok(Message::Text(text))) = ws_receiver.next().await {
+ // Parse incoming message
+ if let Ok(msg) = serde_json::from_str::(&text) {
+ // Create private message
+ let private_msg = serde_json::to_string(&WsMessage::PrivateMessage {
+ from: username_clone.clone(),
+ to: msg.to.clone(),
+ content: msg.content.clone(),
+ })
+ .unwrap();
+
+ // Send to recipient
+ state_clone.chat.send_to_user(&msg.to, &private_msg).await;
+
+ // Also send back to sender (for their own chat view)
+ state_clone
+ .chat
+ .send_to_user(&username_clone, &private_msg)
+ .await;
+ }
}
});
+ // Wait for either task to complete
tokio::select! {
- _ = (&mut send_task) => recv_task.abort(),
- _ = (&mut recv_task) => send_task.abort(),
- };
+ _ = send_task => {},
+ _ = recv_task => {},
+ }
+
+ // User disconnected - clean up
+ state.chat.remove_user(&username).await;
+}
+
+// Message structure from client
+#[derive(Debug, Deserialize)]
+struct ClientMessage {
+ to: String,
+ content: String,
}