From 19dbb7c499edfc9bedf04ac1c77860cf24fd63cc Mon Sep 17 00:00:00 2001 From: Paul Gardiner Date: Fri, 27 Mar 2026 16:22:49 +0000 Subject: [PATCH] fix: upgrade CLI to new backend API and WebSocket protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend API/WebSocket protocol changed, breaking the CLI. This commit updates the CLI to work with the new spec. WebSocket subscription model: - Replace subscribe_server_room/unsubscribe_server_room with subscribe_servers (per-server array subscription) - TUI now subscribes to all user servers on WS connect - Remove per-room unsubscribe (no longer in spec) New WebSocket message types (server → client): - FriendRequestDeclined, FriendRequestWithdrawn, FriendRemoved - VoiceRoomUserSpeaking, ServerRoomCreated, ServerRoomDeleted - UserStatusChanged, ServerMemberStatuses, ServerInviteReceived - ServerSnapshot, RoomMessageEdited, RoomMessageDeleted, RoomTyping - DmMessageEdited, DmMessageDeleted, UnreadCounts, UserGameActivity New WebSocket message types (client → server): - SetGameActivity, MarkRoomRead, MarkDmRead REST API response format change: - Message endpoints now return {"messages": [...], "hasMore", "nextCursor"} instead of a bare array - Add extract_messages() helper to handle both formats - Update all 6 call sites (chat.rs, dms.rs, join.rs, tui/app.rs) TUI message handlers: - Friend decline/withdraw/remove trigger friend list reload - RoomMessageEdited/DmMessageEdited update message content in place - RoomMessageDeleted/DmMessageDeleted remove message from list - ServerRoomCreated/Deleted trigger server detail refresh - UnreadCounts populates dm_unread from payload - Remaining new types log at debug level (no-op for now) Bug fixes: - Fix Ctrl+C/Esc not exiting chat open (return value was discarded) - Unwrap nested "message" field in RoomMessage WS events - Add raw WS logging to non-persistent connections for debugging Other: - Add --region flag to join command for voice server region selection - Add unit tests for all new WS message types and serialization - Clean clippy warnings --- Cargo.lock | 7 +- Cargo.toml | 2 +- crates/cli/src/api.rs | 17 +++ crates/cli/src/cli.rs | 7 + crates/cli/src/commands/chat.rs | 24 +-- crates/cli/src/commands/dms.rs | 4 +- crates/cli/src/commands/join.rs | 29 ++-- crates/cli/src/commands/servers.rs | 2 + crates/cli/src/tui/app.rs | 180 ++++++++++++++++++---- crates/cli/src/ws.rs | 230 +++++++++++++++++++++++++++-- 10 files changed, 437 insertions(+), 65 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5c9dd5c..bad58d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2287,9 +2287,10 @@ dependencies = [ [[package]] name = "lag-cli" -version = "0.0.7-alpha" +version = "0.0.9-beta" dependencies = [ "anyhow", + "base64 0.22.1", "chrono", "clap 4.6.0", "cocoa 0.26.1", @@ -2317,7 +2318,7 @@ dependencies = [ [[package]] name = "lag-common" -version = "0.0.7-alpha" +version = "0.0.9-beta" dependencies = [ "anyhow", "dirs", @@ -2329,7 +2330,7 @@ dependencies = [ [[package]] name = "lag-voice-core" -version = "0.0.7-alpha" +version = "0.0.9-beta" dependencies = [ "anyhow", "audiopus", diff --git a/Cargo.toml b/Cargo.toml index 43a4e42..f9bc16d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ members = [ opt-level = 1 [workspace.package] -version = "0.0.8-alpha" +version = "0.0.9-beta" edition = "2021" license = "MIT" repository = "https://github.com/lag-app/cli" diff --git a/crates/cli/src/api.rs b/crates/cli/src/api.rs index 09aa43e..7d7a429 100644 --- a/crates/cli/src/api.rs +++ b/crates/cli/src/api.rs @@ -161,6 +161,23 @@ impl ApiClient { pub fn base_url(&self) -> &str { &self.base_url } + + /// Fetch a messages endpoint that returns `{"messages": [...]}` or a bare array. + pub async fn get_messages(&mut self, path: &str) -> Result> { + let val: serde_json::Value = self.get(path).await?; + Ok(extract_messages(val)) + } +} + +/// Extract messages from either `{"messages": [...]}` or a bare `[...]`. +pub fn extract_messages(val: serde_json::Value) -> Vec { + if let Some(arr) = val.as_array() { + arr.clone() + } else if let Some(arr) = val["messages"].as_array() { + arr.clone() + } else { + Vec::new() + } } async fn parse_response(resp: reqwest::Response) -> Result { diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index 74fc932..e71e036 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -64,6 +64,9 @@ pub enum Commands { /// Show room text chat alongside voice #[arg(long)] with_chat: bool, + /// Voice server region (e.g. eu, us) + #[arg(long)] + region: Option, }, /// Audio device configuration @@ -176,6 +179,7 @@ impl Cli { input_device, output_device, with_chat, + region, }) => { crate::commands::join::run( server, @@ -185,6 +189,7 @@ impl Cli { input_device, output_device, with_chat, + region, ) .await } @@ -247,6 +252,7 @@ mod tests { input_device, output_device, with_chat, + region, }) => { assert_eq!(server, "my-server"); assert_eq!(room, "general"); @@ -255,6 +261,7 @@ mod tests { assert_eq!(input_device.as_deref(), Some("Blue Yeti")); assert_eq!(output_device.as_deref(), Some("Speakers")); assert!(with_chat); + assert!(region.is_none()); } _ => panic!("expected Join"), } diff --git a/crates/cli/src/commands/chat.rs b/crates/cli/src/commands/chat.rs index 8b35fee..fd788c5 100644 --- a/crates/cli/src/commands/chat.rs +++ b/crates/cli/src/commands/chat.rs @@ -51,8 +51,8 @@ async fn send_message( async fn open_chat(api: &mut ApiClient, server_query: &str, room_query: &str) -> Result<()> { let (server_id, room_id) = resolve_server_room(api, server_query, room_query).await?; - let messages: Vec = api - .get(&format!( + let messages = api + .get_messages(&format!( "/servers/{}/rooms/{}/messages?limit=50", server_id, room_id )) @@ -66,9 +66,8 @@ async fn open_chat(api: &mut ApiClient, server_query: &str, room_query: &str) -> } let mut ws = WsClient::connect(api.base_url(), api.access_token()).await?; - ws.send(WsClientMessage::SubscribeServerRoom { - server_id: server_id.clone(), - room_id: Some(room_id.clone()), + ws.send(WsClientMessage::SubscribeServers { + server_ids: vec![server_id.clone()], })?; terminal::enable_raw_mode()?; @@ -82,10 +81,15 @@ async fn open_chat(api: &mut ApiClient, server_query: &str, room_query: &str) -> ws_msg = ws.recv() => { match ws_msg { Some(WsServerMessage::RoomMessage(val)) => { - let msg_room_id = val["roomId"].as_str().unwrap_or(""); + let msg = if val.get("message").is_some() { + val["message"].clone() + } else { + val + }; + let msg_room_id = msg["roomId"].as_str().unwrap_or(""); if msg_room_id == room_id { clear_line(); - print_message(&val); + print_message(&msg); print_prompt(&input_buf); } } @@ -93,7 +97,7 @@ async fn open_chat(api: &mut ApiClient, server_query: &str, room_query: &str) -> _ => {} } } - _ = async { + should_quit = async { if event::poll(std::time::Duration::from_millis(50)).unwrap_or(false) { if let Ok(Event::Key(key)) = event::read() { match (key.code, key.modifiers) { @@ -127,7 +131,9 @@ async fn open_chat(api: &mut ApiClient, server_query: &str, room_query: &str) -> } } false - } => {} + } => { + if should_quit { break; } + } } } diff --git a/crates/cli/src/commands/dms.rs b/crates/cli/src/commands/dms.rs index 996e55b..8bea90f 100644 --- a/crates/cli/src/commands/dms.rs +++ b/crates/cli/src/commands/dms.rs @@ -70,8 +70,8 @@ async fn open_dm(api: &mut ApiClient, username: &str) -> Result<()> { let conv_id = conv["id"].as_str().unwrap().to_string(); // Load recent messages - let messages: Vec = api - .get(&format!("/dms/{}/messages?limit=50", conv_id)) + let messages = api + .get_messages(&format!("/dms/{}/messages?limit=50", conv_id)) .await?; println!("-- DM with {} --", username); diff --git a/crates/cli/src/commands/join.rs b/crates/cli/src/commands/join.rs index 082367b..c53b458 100644 --- a/crates/cli/src/commands/join.rs +++ b/crates/cli/src/commands/join.rs @@ -13,6 +13,7 @@ use parking_lot::Mutex; use std::io::Write; use std::sync::Arc; +#[allow(clippy::too_many_arguments)] pub async fn run( server: String, room: String, @@ -21,6 +22,7 @@ pub async fn run( input_device: Option, output_device: Option, with_chat: bool, + region: Option, ) -> Result<()> { let creds = auth::ensure_auth().await?; let mut api = ApiClient::new(creds)?; @@ -30,11 +32,12 @@ pub async fn run( resolve_voice_room(&mut api, &server, &room).await?; // Get voice token + let body = match region { + Some(ref r) => serde_json::json!({ "region": r }), + None => serde_json::json!({}), + }; let token_resp: serde_json::Value = api - .post( - &format!("/voice/rooms/{}/token", room_id), - &serde_json::json!({}), - ) + .post(&format!("/voice/rooms/{}/token", room_id), &body) .await?; let voice_url = token_resp["voiceUrl"] @@ -115,9 +118,8 @@ pub async fn run( // Optionally connect WS for chat let mut ws = if with_chat { let ws = WsClient::connect(api.base_url(), api.access_token()).await?; - ws.send(crate::ws::WsClientMessage::SubscribeServerRoom { - server_id: server_id.clone(), - room_id: Some(room_id.clone()), + ws.send(crate::ws::WsClientMessage::SubscribeServers { + server_ids: vec![server_id.clone()], })?; Some(ws) } else { @@ -126,8 +128,8 @@ pub async fn run( if with_chat { // Load recent messages - let messages: Vec = api - .get(&format!( + let messages = api + .get_messages(&format!( "/servers/{}/rooms/{}/messages?limit=20", server_id, room_id )) @@ -196,8 +198,13 @@ pub async fn run( } } => { if let Some(WsServerMessage::RoomMessage(val)) = ws_msg { - let username = val["username"].as_str().unwrap_or("?"); - let content = val["content"].as_str().unwrap_or(""); + let msg = if val.get("message").is_some() { + val["message"].clone() + } else { + val + }; + let username = msg["username"].as_str().unwrap_or("?"); + let content = msg["content"].as_str().unwrap_or(""); println!("\r[chat] {}: {}\x1b[K", username, content); if with_chat { print!("> {}", input_buf); diff --git a/crates/cli/src/commands/servers.rs b/crates/cli/src/commands/servers.rs index b1f4dd9..e130dc9 100644 --- a/crates/cli/src/commands/servers.rs +++ b/crates/cli/src/commands/servers.rs @@ -121,6 +121,7 @@ async fn interactive_room_picker_inner( None, None, false, + None, ) .await } @@ -133,6 +134,7 @@ async fn interactive_room_picker_inner( None, None, true, + None, ) .await } diff --git a/crates/cli/src/tui/app.rs b/crates/cli/src/tui/app.rs index ef75f66..75a5e13 100644 --- a/crates/cli/src/tui/app.rs +++ b/crates/cli/src/tui/app.rs @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lag // SPDX-License-Identifier: MIT -use crate::api::ApiClient; +use crate::api::{extract_messages, ApiClient}; use crate::config::{self, Credentials}; use crate::ws::{WsClient, WsServerMessage}; use anyhow::Result; @@ -340,6 +340,20 @@ impl App { .await .ok(); + // Subscribe to all user's servers + if let Some(ref ws) = self.ws { + let server_ids: Vec = self + .servers + .iter() + .filter_map(|s| s["id"].as_str().map(String::from)) + .collect(); + if !server_ids.is_empty() { + let _ = ws.send(crate::ws::WsClientMessage::SubscribeServers { + server_ids, + }); + } + } + self.loading = None; self.init_rx = None; return Ok(()); @@ -834,9 +848,8 @@ impl App { self.selected_room_id = Some(room_id.clone()); self.selected_room_name = Some(room_name); if let Some(ref ws) = self.ws { - let _ = ws.send(crate::ws::WsClientMessage::SubscribeServerRoom { - server_id, - room_id: Some(room_id), + let _ = ws.send(crate::ws::WsClientMessage::SubscribeServers { + server_ids: vec![server_id], }); } } @@ -1115,9 +1128,9 @@ impl App { match result { Ok(conv) => { let conv_id = conv["id"].as_str().unwrap_or("").to_string(); - let msgs: Vec = self + let msgs = self .api - .get(&format!("/dms/{}/messages", conv_id)) + .get_messages(&format!("/dms/{}/messages", conv_id)) .await .unwrap_or_default(); self.loading = None; @@ -1315,7 +1328,7 @@ impl App { tokio::spawn(async move { let client = reqwest::Client::new(); - let msgs: Vec = match client + let msgs = match client .get(format!( "{}/servers/{}/rooms/{}/messages?limit=50", base_url, sid, rid @@ -1325,7 +1338,9 @@ impl App { .await { Ok(resp) if resp.status().is_success() => { - resp.json().await.unwrap_or_default() + let val: serde_json::Value = + resp.json().await.unwrap_or_default(); + extract_messages(val) } _ => Vec::new(), }; @@ -1386,14 +1401,15 @@ impl App { tokio::spawn(async move { let client = reqwest::Client::new(); - let msgs: Vec = match client + let msgs = match client .get(format!("{}/dms/{}/messages", base_url, dm_id)) .header("Authorization", format!("Bearer {}", token)) .send() .await { Ok(resp) if resp.status().is_success() => { - resp.json().await.unwrap_or_default() + let val: serde_json::Value = resp.json().await.unwrap_or_default(); + extract_messages(val) } _ => Vec::new(), }; @@ -1515,31 +1531,29 @@ impl App { } } WsServerMessage::RoomMessage(val) => { + let msg = if val.get("message").is_some() { + val["message"].clone() + } else { + val + }; if let Some(ref room_id) = self.selected_room_id { - let msg_room_id = val["roomId"] + let msg_room_id = msg["roomId"] .as_str() - .or_else(|| val["room_id"].as_str()) + .or_else(|| msg["room_id"].as_str()) .unwrap_or(""); if msg_room_id == room_id { - self.messages.push(val); + self.messages.push(msg); } } } - WsServerMessage::ServerEvent { event, payload } => { - if event.as_str() == "room_message" { - if let Some(ref room_id) = self.selected_room_id { - let msg_room_id = payload["room_id"] - .as_str() - .or_else(|| payload["roomId"].as_str()) - .unwrap_or(""); - if msg_room_id == room_id { - self.messages.push(payload); - } - } - } + WsServerMessage::ServerEvent { ref event, .. } => { + tracing::debug!("Unhandled server_event: {}", event); } WsServerMessage::FriendRequestReceived(_) - | WsServerMessage::FriendRequestAccepted(_) => { + | WsServerMessage::FriendRequestAccepted(_) + | WsServerMessage::FriendRequestDeclined(_) + | WsServerMessage::FriendRequestWithdrawn(_) + | WsServerMessage::FriendRemoved(_) => { self.pending_friend_reload = true; } WsServerMessage::FriendOnline(val) => { @@ -1567,6 +1581,120 @@ impl App { } } } + WsServerMessage::RoomMessageEdited(val) => { + let msg_id = val["messageId"] + .as_str() + .or_else(|| val["id"].as_str()) + .unwrap_or(""); + if !msg_id.is_empty() { + if let Some(existing) = self + .messages + .iter_mut() + .find(|m| m["id"].as_str() == Some(msg_id)) + { + if let Some(content) = val["content"].as_str() { + existing["content"] = serde_json::Value::String(content.to_string()); + } + } + } + } + WsServerMessage::RoomMessageDeleted(val) => { + let msg_id = val["messageId"] + .as_str() + .or_else(|| val["id"].as_str()) + .unwrap_or(""); + if !msg_id.is_empty() { + self.messages.retain(|m| m["id"].as_str() != Some(msg_id)); + } + } + WsServerMessage::DmMessageEdited(val) => { + let msg_id = val["messageId"] + .as_str() + .or_else(|| val["id"].as_str()) + .unwrap_or(""); + if !msg_id.is_empty() { + if let Some(existing) = self + .messages + .iter_mut() + .find(|m| m["id"].as_str() == Some(msg_id)) + { + if let Some(content) = val["content"].as_str() { + existing["content"] = serde_json::Value::String(content.to_string()); + } + } + } + } + WsServerMessage::DmMessageDeleted(val) => { + let msg_id = val["messageId"] + .as_str() + .or_else(|| val["id"].as_str()) + .unwrap_or(""); + if !msg_id.is_empty() { + self.messages.retain(|m| m["id"].as_str() != Some(msg_id)); + } + } + WsServerMessage::ServerRoomCreated(_) | WsServerMessage::ServerRoomDeleted(_) => { + // Trigger a refresh of the server detail if we're viewing it + if let Some(ref detail) = self.selected_server { + let server_id = detail.id.clone(); + let base_url = self.api.base_url().to_string(); + let token = self.api.access_token().to_string(); + let (tx, rx) = tokio::sync::oneshot::channel(); + tokio::spawn(async move { + let client = reqwest::Client::new(); + let resp = client + .get(format!("{}/servers/{}", base_url, server_id)) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await; + let result = match resp { + Ok(r) => match r.json::().await { + Ok(val) => { + let name = val["name"].as_str().unwrap_or("?").to_string(); + let rooms = + val["rooms"].as_array().cloned().unwrap_or_default(); + ContentLoadResult::ServerDetail { + id: server_id, + name, + rooms, + } + } + Err(e) => ContentLoadResult::Error(e.to_string()), + }, + Err(e) => ContentLoadResult::Error(e.to_string()), + }; + let _ = tx.send(result); + }); + self.content_load_rx = Some(rx); + } + } + WsServerMessage::UnreadCounts(val) => { + if let Some(counts) = val["counts"].as_array().or_else(|| val["dms"].as_array()) { + for entry in counts { + if let (Some(id), Some(count)) = ( + entry["conversationId"] + .as_str() + .or_else(|| entry["id"].as_str()), + entry["count"].as_u64(), + ) { + if count > 0 { + self.dm_unread.insert(id.to_string(), count as u32); + } else { + self.dm_unread.remove(id); + } + } + } + } + } + WsServerMessage::VoiceRoomUserSpeaking(_) + | WsServerMessage::ServerMemberStatuses(_) + | WsServerMessage::ServerInviteReceived(_) + | WsServerMessage::ServerSnapshot(_) + | WsServerMessage::UserStatusChanged(_) + | WsServerMessage::UserGameActivity(_) + | WsServerMessage::RoomTyping(_) => { + tracing::debug!("WS message (no-op): {:?}", msg); + } _ => {} } } diff --git a/crates/cli/src/ws.rs b/crates/cli/src/ws.rs index ded6fcf..b7bcb12 100644 --- a/crates/cli/src/ws.rs +++ b/crates/cli/src/ws.rs @@ -28,19 +28,10 @@ pub enum WsClientMessage { #[serde(rename = "userIds")] user_ids: Vec, }, - #[serde(rename = "subscribe_server_room")] - SubscribeServerRoom { - #[serde(rename = "serverId")] - server_id: String, - #[serde(rename = "roomId")] - room_id: Option, - }, - #[serde(rename = "unsubscribe_server_room")] - UnsubscribeServerRoom { - #[serde(rename = "serverId")] - server_id: String, - #[serde(rename = "roomId")] - room_id: Option, + #[serde(rename = "subscribe_servers")] + SubscribeServers { + #[serde(rename = "serverIds")] + server_ids: Vec, }, #[serde(rename = "typing_start")] TypingStart { @@ -66,6 +57,23 @@ pub enum WsClientMessage { #[serde(rename = "roomId")] room_id: String, }, + #[serde(rename = "set_game_activity")] + SetGameActivity { + #[serde(rename = "gameName")] + game_name: Option, + }, + #[serde(rename = "mark_room_read")] + MarkRoomRead { + #[serde(rename = "roomId")] + room_id: String, + timestamp: String, + }, + #[serde(rename = "mark_dm_read")] + MarkDmRead { + #[serde(rename = "conversationId")] + conversation_id: String, + timestamp: String, + }, } /// Raw WS message — manually parsed since serde internally-tagged enums @@ -89,6 +97,23 @@ pub enum WsServerMessage { VoiceRoomUserLeft(serde_json::Value), ServerMemberJoined(serde_json::Value), ServerMemberLeft(serde_json::Value), + FriendRequestDeclined(serde_json::Value), + FriendRequestWithdrawn(serde_json::Value), + FriendRemoved(serde_json::Value), + VoiceRoomUserSpeaking(serde_json::Value), + ServerRoomCreated(serde_json::Value), + ServerRoomDeleted(serde_json::Value), + UserStatusChanged(serde_json::Value), + ServerMemberStatuses(serde_json::Value), + ServerInviteReceived(serde_json::Value), + ServerSnapshot(serde_json::Value), + RoomMessageEdited(serde_json::Value), + RoomMessageDeleted(serde_json::Value), + RoomTyping(serde_json::Value), + DmMessageEdited(serde_json::Value), + DmMessageDeleted(serde_json::Value), + UnreadCounts(serde_json::Value), + UserGameActivity(serde_json::Value), ServerEvent { event: String, payload: serde_json::Value, @@ -117,6 +142,23 @@ impl WsServerMessage { "voice_room_user_left" => Self::VoiceRoomUserLeft(val), "server_member_joined" => Self::ServerMemberJoined(val), "server_member_left" => Self::ServerMemberLeft(val), + "friend_request_declined" => Self::FriendRequestDeclined(val), + "friend_request_withdrawn" => Self::FriendRequestWithdrawn(val), + "friend_removed" => Self::FriendRemoved(val), + "voice_room_user_speaking" => Self::VoiceRoomUserSpeaking(val), + "server_room_created" => Self::ServerRoomCreated(val), + "server_room_deleted" => Self::ServerRoomDeleted(val), + "user_status_changed" => Self::UserStatusChanged(val), + "server_member_statuses" => Self::ServerMemberStatuses(val), + "server_invite_received" => Self::ServerInviteReceived(val), + "server_snapshot" => Self::ServerSnapshot(val), + "room_message_edited" => Self::RoomMessageEdited(val), + "room_message_deleted" => Self::RoomMessageDeleted(val), + "room_typing" => Self::RoomTyping(val), + "dm_message_edited" => Self::DmMessageEdited(val), + "dm_message_deleted" => Self::DmMessageDeleted(val), + "unread_counts" => Self::UnreadCounts(val), + "user_game_activity" => Self::UserGameActivity(val), "server_event" => Self::ServerEvent { event: val["event"].as_str().unwrap_or("").to_string(), payload: val["payload"].clone(), @@ -182,6 +224,14 @@ impl WsClient { while let Some(result) = read.next().await { match result { Ok(Message::Text(text)) => { + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open("/tmp/lag-ws-raw.log") + { + use std::io::Write; + let _ = writeln!(f, "{}", text); + } if let Some(parsed) = WsServerMessage::parse(&text) { let _ = recv_tx.send(parsed); } else { @@ -449,4 +499,158 @@ mod tests { let json = serde_json::to_string(&WsClientMessage::Ping).unwrap(); assert!(json.contains(r#""type":"ping""#)); } + + #[test] + fn client_message_subscribe_servers_serializes() { + let msg = WsClientMessage::SubscribeServers { + server_ids: vec!["s1".into(), "s2".into()], + }; + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains(r#""type":"subscribe_servers""#)); + assert!(json.contains(r#""serverIds""#)); + assert!(json.contains(r#""s1""#)); + assert!(json.contains(r#""s2""#)); + } + + #[test] + fn client_message_set_game_activity_serializes() { + let msg = WsClientMessage::SetGameActivity { + game_name: Some("Chess".into()), + }; + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains(r#""type":"set_game_activity""#)); + assert!(json.contains(r#""gameName":"Chess""#)); + } + + #[test] + fn client_message_mark_room_read_serializes() { + let msg = WsClientMessage::MarkRoomRead { + room_id: "r1".into(), + timestamp: "2026-01-01T00:00:00Z".into(), + }; + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains(r#""type":"mark_room_read""#)); + assert!(json.contains(r#""roomId":"r1""#)); + } + + #[test] + fn client_message_mark_dm_read_serializes() { + let msg = WsClientMessage::MarkDmRead { + conversation_id: "c1".into(), + timestamp: "2026-01-01T00:00:00Z".into(), + }; + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains(r#""type":"mark_dm_read""#)); + assert!(json.contains(r#""conversationId":"c1""#)); + } + + #[test] + fn parse_friend_request_declined() { + let msg = + WsServerMessage::parse(r#"{"type":"friend_request_declined","userId":"u1"}"#).unwrap(); + assert!(matches!(msg, WsServerMessage::FriendRequestDeclined(_))); + } + + #[test] + fn parse_friend_request_withdrawn() { + let msg = + WsServerMessage::parse(r#"{"type":"friend_request_withdrawn","userId":"u1"}"#).unwrap(); + assert!(matches!(msg, WsServerMessage::FriendRequestWithdrawn(_))); + } + + #[test] + fn parse_friend_removed() { + let msg = WsServerMessage::parse(r#"{"type":"friend_removed","userId":"u1"}"#).unwrap(); + assert!(matches!(msg, WsServerMessage::FriendRemoved(_))); + } + + #[test] + fn parse_room_message_edited() { + let msg = WsServerMessage::parse( + r#"{"type":"room_message_edited","messageId":"m1","content":"updated"}"#, + ) + .unwrap(); + assert!(matches!(msg, WsServerMessage::RoomMessageEdited(_))); + } + + #[test] + fn parse_room_message_deleted() { + let msg = + WsServerMessage::parse(r#"{"type":"room_message_deleted","messageId":"m1"}"#).unwrap(); + assert!(matches!(msg, WsServerMessage::RoomMessageDeleted(_))); + } + + #[test] + fn parse_dm_message_edited() { + let msg = WsServerMessage::parse( + r#"{"type":"dm_message_edited","messageId":"m1","content":"updated"}"#, + ) + .unwrap(); + assert!(matches!(msg, WsServerMessage::DmMessageEdited(_))); + } + + #[test] + fn parse_dm_message_deleted() { + let msg = + WsServerMessage::parse(r#"{"type":"dm_message_deleted","messageId":"m1"}"#).unwrap(); + assert!(matches!(msg, WsServerMessage::DmMessageDeleted(_))); + } + + #[test] + fn parse_server_room_created() { + let msg = WsServerMessage::parse( + r#"{"type":"server_room_created","roomId":"r1","name":"general"}"#, + ) + .unwrap(); + assert!(matches!(msg, WsServerMessage::ServerRoomCreated(_))); + } + + #[test] + fn parse_server_room_deleted() { + let msg = + WsServerMessage::parse(r#"{"type":"server_room_deleted","roomId":"r1"}"#).unwrap(); + assert!(matches!(msg, WsServerMessage::ServerRoomDeleted(_))); + } + + #[test] + fn parse_unread_counts() { + let msg = + WsServerMessage::parse(r#"{"type":"unread_counts","counts":[{"id":"c1","count":3}]}"#) + .unwrap(); + assert!(matches!(msg, WsServerMessage::UnreadCounts(_))); + } + + #[test] + fn parse_user_status_changed() { + let msg = WsServerMessage::parse( + r#"{"type":"user_status_changed","userId":"u1","status":"away"}"#, + ) + .unwrap(); + assert!(matches!(msg, WsServerMessage::UserStatusChanged(_))); + } + + #[test] + fn parse_voice_room_user_speaking() { + let msg = WsServerMessage::parse( + r#"{"type":"voice_room_user_speaking","userId":"u1","speaking":true}"#, + ) + .unwrap(); + assert!(matches!(msg, WsServerMessage::VoiceRoomUserSpeaking(_))); + } + + #[test] + fn parse_room_typing() { + let msg = WsServerMessage::parse(r#"{"type":"room_typing","userId":"u1","roomId":"r1"}"#) + .unwrap(); + assert!(matches!(msg, WsServerMessage::RoomTyping(_))); + } + + #[test] + fn parse_user_game_activity() { + let msg = WsServerMessage::parse( + r#"{"type":"user_game_activity","userId":"u1","gameName":"Chess"}"#, + ) + .unwrap(); + assert!(matches!(msg, WsServerMessage::UserGameActivity(_))); + } }