diff --git a/services/frontend-leptos/frontend/src/api/client.rs b/services/frontend-leptos/frontend/src/api/client.rs index 648e7a6..88a5de7 100644 --- a/services/frontend-leptos/frontend/src/api/client.rs +++ b/services/frontend-leptos/frontend/src/api/client.rs @@ -18,15 +18,15 @@ impl std::fmt::Display for ApiError { impl std::error::Error for ApiError {} fn get_base_url() -> String { - // Try to read from a JS global set by index.html, or fall back to localhost - let default = "http://localhost:3001"; - js_sys::global() - .unchecked_ref::() - .location() - .hostname() - .ok() - .map(|_| format!("http://localhost:3001")) - .unwrap_or_else(|| default.to_string()) + if let Some(window) = web_sys::window() { + let location = window.location(); + let protocol = location.protocol().unwrap_or_else(|_| "http:".to_string()); + let protocol = protocol.trim_end_matches(':'); + let host = location.host().unwrap_or_else(|_| "localhost:3001".to_string()); + format!("{}://{}", protocol, host) + } else { + "http://localhost:3001".to_string() + } } fn get_auth_header() -> Option { @@ -42,7 +42,7 @@ pub async fn request( ) -> Result { let url = format!("{}{}", get_base_url(), path); - let mut headers = Headers::new().map_err(|_| ApiError { + let headers = Headers::new().map_err(|_| ApiError { message: "Failed to create headers".to_string(), status_code: 0, })?; @@ -51,13 +51,16 @@ pub async fn request( headers.set("X-Admin-Password", &password).ok(); } - let mut opts = RequestInit::new(); + if body.is_some() { + headers.set("Content-Type", "application/json").ok(); + } + + let opts = RequestInit::new(); opts.set_method(method); opts.set_headers(&headers); opts.set_mode(RequestMode::Cors); if let Some(json_body) = body { - headers.set("Content-Type", "application/json").ok(); opts.set_body(&JsValue::from_str(json_body)); } diff --git a/services/frontend-leptos/frontend/src/api/voice.rs b/services/frontend-leptos/frontend/src/api/voice.rs index 1d63a25..617132d 100644 --- a/services/frontend-leptos/frontend/src/api/voice.rs +++ b/services/frontend-leptos/frontend/src/api/voice.rs @@ -26,6 +26,7 @@ pub async fn get_voice_status() -> Result { /// POST /api/voice/connect { guildId, channelId } #[derive(Serialize)] +#[serde(rename_all = "camelCase")] struct ConnectPayload { guild_id: String, channel_id: String, diff --git a/services/frontend-leptos/frontend/src/app.rs b/services/frontend-leptos/frontend/src/app.rs index d60f57f..5ad896c 100644 --- a/services/frontend-leptos/frontend/src/app.rs +++ b/services/frontend-leptos/frontend/src/app.rs @@ -34,7 +34,7 @@ pub fn App() -> impl IntoView { provide_context(auth.clone()); provide_context(ui.clone()); - let ws = WsContext::new("ws://localhost:3001"); + let ws = WsContext::new("ws://localhost:3001/ws"); provide_context(ws.clone()); // Auth check: redirect "live" tab to "messages" if not authenticated diff --git a/services/frontend-leptos/frontend/src/ws/context.rs b/services/frontend-leptos/frontend/src/ws/context.rs index 8cea164..35ad58c 100644 --- a/services/frontend-leptos/frontend/src/ws/context.rs +++ b/services/frontend-leptos/frontend/src/ws/context.rs @@ -101,7 +101,11 @@ impl WsContext { } } "media_state" => { - if let Some(d) = data.and_then(|v| serde_json::from_value::(v.clone()).ok()) { + // Backend sends initial state with "state" key, live updates with "data" + let raw = data + .or_else(|| parsed.get("state")) + .cloned(); + if let Some(d) = raw.and_then(|v| serde_json::from_value::(v).ok()) { if let Some(cb) = self.on_media_state.borrow().as_ref() { cb(d); } diff --git a/services/frontend-leptos/frontend/src/ws/socket.rs b/services/frontend-leptos/frontend/src/ws/socket.rs index 6336d44..6ad8e32 100644 --- a/services/frontend-leptos/frontend/src/ws/socket.rs +++ b/services/frontend-leptos/frontend/src/ws/socket.rs @@ -55,40 +55,81 @@ impl WsHandle { let url = self.url.clone(); let status_clone = self.set_status.clone(); - // Clone the Rc wrapper (cheap pointer copy, inner RefCell is shared) let event_clone: std::rc::Rc>>> = self.on_event.clone(); let ws_holder = &self.ws as *const std::cell::RefCell>; let reconnect_attempt = &self.reconnect_attempt as *const std::cell::Cell; - // Clone again for closures - let status2 = status_clone.clone(); - let status3 = status_clone.clone(); + Self::perform_connect(&url, status_clone, event_clone, ws_holder, reconnect_attempt); + } - match WebSocket::new(&url) { + /// Shared connection setup used for both initial connect and reconnection. + /// Takes raw pointers because it must be callable from `wasm_bindgen` closures + /// that cannot borrow `self`. + #[allow(unsafe_code)] + fn perform_connect( + url: &str, + set_status: WriteSignal, + on_event: std::rc::Rc>>>, + ws_holder: *const std::cell::RefCell>, + reconnect_attempt: *const std::cell::Cell, + ) { + let url_owned = url.to_string(); + let status1 = set_status.clone(); + let status2 = set_status.clone(); + let status3 = set_status.clone(); + let event_clone = on_event.clone(); + + match WebSocket::new(&url_owned) { Ok(ws) => { // Store reference unsafe { *(*ws_holder).borrow_mut() = Some(ws.clone()) }; // onopen let onopen_cb = Closure::::new(move |_| { - status_clone.set(WsStatus::Connected); + status1.set(WsStatus::Connected); unsafe { (*reconnect_attempt).set(0) }; }); ws.set_onopen(Some(onopen_cb.as_ref().unchecked_ref())); onopen_cb.forget(); - // onclose + // onclose — schedule reconnect with exponential backoff + let event_for_close = event_clone.clone(); let onclose_cb = Closure::::new(move |_| { status2.set(WsStatus::Disconnected); unsafe { *(*ws_holder).borrow_mut() = None }; + + let attempt = unsafe { (*reconnect_attempt).get() }; + if attempt >= 20 { + status2.set(WsStatus::Error("Max reconnect attempts reached".to_string())); + return; + } + // Full-jitter exponential backoff: min(1000 * 2^attempt, 30000) * (0.5 + random * 0.5) + let base = core::cmp::min(1000u32 * (1u32 << attempt), 30000u32); + let jitter = 0.5 + js_sys::Math::random() * 0.5; + let delay_ms = (base as f64 * jitter) as u32; + unsafe { (*reconnect_attempt).set(attempt + 1) }; + + let url_reconnect = url_owned.clone(); + let status_rc = status2.clone(); + let event_rc = event_for_close.clone(); + let reconnect_fn = Closure::::new(move || { + Self::perform_connect(&url_reconnect, status_rc.clone(), event_rc.clone(), ws_holder, reconnect_attempt); + }); + web_sys::window() + .and_then(|w| { + w.set_timeout_with_callback_and_timeout_and_arguments_0( + reconnect_fn.as_ref().unchecked_ref(), + delay_ms as i32, + ).ok() + }); + reconnect_fn.forget(); }); ws.set_onclose(Some(onclose_cb.as_ref().unchecked_ref())); onclose_cb.forget(); // onerror let onerror_cb = Closure::::new(move |e: ErrorEvent| { - let msg = e.message(); - status3.set(WsStatus::Error(msg)); + status3.set(WsStatus::Error(e.message())); }); ws.set_onerror(Some(onerror_cb.as_ref().unchecked_ref())); onerror_cb.forget(); @@ -105,12 +146,7 @@ impl WsHandle { u8view.copy_to(&mut bytes); cb(WsEvent::Binary(bytes)); } else { - // Try Blob - let data = e.data(); - let blob = data.dyn_ref::(); - if blob.is_some() { - // Blob handling would need async FileReader — skip for now - } + // Blob — would need async FileReader, skip for now } } }); @@ -118,7 +154,7 @@ impl WsHandle { onmsg_cb.forget(); } Err(e) => { - status_clone.set(WsStatus::Error( + set_status.set(WsStatus::Error( js_sys::Error::from(e).to_string().as_string().unwrap_or_default(), )); } diff --git a/services/frontend-leptos/shared-types/src/guild.rs b/services/frontend-leptos/shared-types/src/guild.rs index 85ecc4c..85367b9 100644 --- a/services/frontend-leptos/shared-types/src/guild.rs +++ b/services/frontend-leptos/shared-types/src/guild.rs @@ -20,6 +20,7 @@ pub struct Channel { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct GuildVoiceEntry { pub guild_id: String, pub channel_id: String, diff --git a/services/frontend-leptos/shared-types/src/recording.rs b/services/frontend-leptos/shared-types/src/recording.rs index 5cb8fba..5058069 100644 --- a/services/frontend-leptos/shared-types/src/recording.rs +++ b/services/frontend-leptos/shared-types/src/recording.rs @@ -32,5 +32,6 @@ pub struct VoiceRecordingListResponse { pub items: Vec, #[serde(rename = "nextCursor")] pub next_cursor: Option, + #[serde(rename = "hasMore")] pub has_more: bool, } diff --git a/services/frontend-leptos/shared-types/src/voice.rs b/services/frontend-leptos/shared-types/src/voice.rs index 888277c..5d0329e 100644 --- a/services/frontend-leptos/shared-types/src/voice.rs +++ b/services/frontend-leptos/shared-types/src/voice.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; use crate::guild::GuildVoiceEntry; #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct VoiceStatus { pub connected: bool, #[serde(skip_serializing_if = "Option::is_none")] @@ -19,6 +20,7 @@ pub struct ActiveSpeaker { pub id: Option, pub user_id: String, pub username: String, - pub avatar: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar: Option, pub speaking: bool, }