From 6192875d0d30c0afacd755edaba0d3ec04071c17052a94be9d0a9ba0218b7f0d Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Fri, 23 Jan 2026 11:00:04 -0600 Subject: [PATCH] fix: websocket cleanup --- .../src/components/ws_client.rs | 325 +++++----- crates/chattyness-user-ui/src/pages/realm.rs | 599 +++++++----------- 2 files changed, 396 insertions(+), 528 deletions(-) diff --git a/crates/chattyness-user-ui/src/components/ws_client.rs b/crates/chattyness-user-ui/src/components/ws_client.rs index 7485e03..9e47baf 100644 --- a/crates/chattyness-user-ui/src/components/ws_client.rs +++ b/crates/chattyness-user-ui/src/components/ws_client.rs @@ -124,6 +124,60 @@ pub struct MemberIdentityInfo { pub is_guest: bool, } +/// Consolidated WebSocket event enum. +/// +/// All WebSocket events are routed through this enum for a cleaner API. +#[derive(Clone, Debug)] +pub enum WsEvent { + /// Members list updated. + MembersUpdated(Vec), + /// Chat message received. + ChatMessage(ChatMessage), + /// Loose props synchronized (initial list). + LoosePropsSync(Vec), + /// A prop was dropped. + PropDropped(LooseProp), + /// A prop was picked up (by prop ID). + PropPickedUp(uuid::Uuid), + /// A member started fading out (timeout disconnect). + MemberFading(FadingMember), + /// Welcome message received with current user info. + Welcome(ChannelMemberInfo), + /// Error from server. + Error(WsError), + /// Teleport approved - navigate to new scene. + TeleportApproved(TeleportInfo), + /// Summoned by moderator. + Summoned(SummonInfo), + /// Moderator command result. + ModCommandResult(ModCommandResultInfo), + /// Member identity updated (e.g., guest → user). + MemberIdentityUpdated(MemberIdentityInfo), +} + +/// Consolidated internal state to reduce Rc> proliferation. +#[cfg(feature = "hydrate")] +struct WsInternalState { + ws: Option, + members: Vec, + current_user_id: Option, + is_intentional_close: bool, + heartbeat_handle: Option, +} + +#[cfg(feature = "hydrate")] +impl Default for WsInternalState { + fn default() -> Self { + Self { + ws: None, + members: Vec::new(), + current_user_id: None, + is_intentional_close: false, + heartbeat_handle: None, + } + } +} + /// Hook to manage WebSocket connection for a channel. /// /// Returns a tuple of: @@ -135,18 +189,7 @@ pub fn use_channel_websocket( realm_slug: Signal, channel_id: Signal>, reconnect_trigger: RwSignal, - on_members_update: Callback>, - on_chat_message: Callback, - on_loose_props_sync: Callback>, - on_prop_dropped: Callback, - on_prop_picked_up: Callback, - on_member_fading: Callback, - on_welcome: Option>, - on_error: Option>, - on_teleport_approved: Option>, - on_summoned: Option>, - on_mod_command_result: Option>, - on_member_identity_updated: Option>, + on_event: Callback, ) -> (Signal, WsSenderStorage, WsCloserStorage) { use std::cell::RefCell; use std::rc::Rc; @@ -156,21 +199,17 @@ pub fn use_channel_websocket( use web_sys::{CloseEvent, ErrorEvent, MessageEvent, WebSocket}; let (ws_state, set_ws_state) = signal(WsState::Disconnected); - let ws_ref: Rc>> = Rc::new(RefCell::new(None)); - let members: Rc>> = Rc::new(RefCell::new(Vec::new())); - // Track current user's ID to ignore self MemberLeft during reconnection - let current_user_id: Rc>> = Rc::new(RefCell::new(None)); - // Flag to track intentional closes (teleport, scene change) - guarantees local state - // even if close code doesn't arrive correctly due to browser/server quirks - let is_intentional_close: Rc> = Rc::new(RefCell::new(false)); + + let state: Rc> = Rc::new(RefCell::new(WsInternalState::default())); // Flag to prevent accessing disposed reactive values after component unmount let is_disposed: Arc = Arc::new(AtomicBool::new(false)); // Create a stored sender function (using new_local for WASM single-threaded environment) - let ws_ref_for_send = ws_ref.clone(); + let state_for_send = state.clone(); let sender: WsSenderStorage = StoredValue::new_local(Some(Box::new(move |msg: ClientMessage| { - if let Some(ws) = ws_ref_for_send.borrow().as_ref() { + let state = state_for_send.borrow(); + if let Some(ws) = state.ws.as_ref() { if ws.ready_state() == WebSocket::OPEN { if let Ok(json) = serde_json::to_string(&msg) { #[cfg(debug_assertions)] @@ -181,15 +220,8 @@ pub fn use_channel_websocket( } }))); - // Effect to manage WebSocket lifecycle - let ws_ref_clone = ws_ref.clone(); - let members_clone = members.clone(); - let is_intentional_close_for_cleanup = is_intentional_close.clone(); - let is_disposed_for_effect = is_disposed.clone(); - - // Clone for closer callback (must be done before Effect captures ws_ref and is_intentional_close) - let ws_ref_for_close = ws_ref.clone(); - let is_intentional_close_for_closer = is_intentional_close.clone(); + // Clone for closer callback (must be done before Effect captures state) + let state_for_close = state.clone(); // Set disposed flag on cleanup to prevent accessing disposed reactive values let is_disposed_for_cleanup = is_disposed.clone(); @@ -197,6 +229,10 @@ pub fn use_channel_websocket( is_disposed_for_cleanup.store(true, Ordering::Relaxed); }); + // Effect to manage WebSocket lifecycle + let state_for_effect = state.clone(); + let is_disposed_for_effect = is_disposed.clone(); + Effect::new(move |_| { let slug = realm_slug.get(); let ch_id = channel_id.get(); @@ -204,15 +240,20 @@ pub fn use_channel_websocket( let _trigger = reconnect_trigger.get(); // Cleanup previous connection - if let Some(old_ws) = ws_ref_clone.borrow_mut().take() { - #[cfg(debug_assertions)] - web_sys::console::log_1( - &format!("[WS] Closing old connection, readyState={}", old_ws.ready_state()).into(), - ); - // Set flag BEFORE closing - guarantees local state even if close code doesn't arrive - *is_intentional_close_for_cleanup.borrow_mut() = true; - // Close with SCENE_CHANGE code so onclose handler knows this was intentional - let _ = old_ws.close_with_code_and_reason(close_codes::SCENE_CHANGE, "scene change"); + { + let mut state = state_for_effect.borrow_mut(); + if let Some(old_ws) = state.ws.take() { + #[cfg(debug_assertions)] + web_sys::console::log_1( + &format!("[WS] Closing old connection, readyState={}", old_ws.ready_state()).into(), + ); + // Set flag BEFORE closing - guarantees local state even if close code doesn't arrive + state.is_intentional_close = true; + // Cancel existing heartbeat + state.heartbeat_handle = None; + // Close with SCENE_CHANGE code so onclose handler knows this was intentional + let _ = old_ws.close_with_code_and_reason(close_codes::SCENE_CHANGE, "scene change"); + } } let Some(ch_id) = ch_id else { @@ -265,26 +306,8 @@ pub fn use_channel_websocket( onopen.forget(); // onmessage - let members_for_msg = members_clone.clone(); - let on_members_update_clone = on_members_update.clone(); - let on_chat_message_clone = on_chat_message.clone(); - let on_loose_props_sync_clone = on_loose_props_sync.clone(); - let on_prop_dropped_clone = on_prop_dropped.clone(); - let on_prop_picked_up_clone = on_prop_picked_up.clone(); - let on_member_fading_clone = on_member_fading.clone(); - let on_welcome_clone = on_welcome.clone(); - let on_error_clone = on_error.clone(); - let on_teleport_approved_clone = on_teleport_approved.clone(); - let on_summoned_clone = on_summoned.clone(); - let on_mod_command_result_clone = on_mod_command_result.clone(); - let on_member_identity_updated_clone = on_member_identity_updated.clone(); - // For starting heartbeat on Welcome - let ws_ref_for_heartbeat = ws_ref.clone(); - let heartbeat_started: Rc> = Rc::new(RefCell::new(false)); - let heartbeat_started_clone = heartbeat_started.clone(); - // For tracking current user ID to ignore self MemberLeft during reconnection - let current_user_id_for_msg = current_user_id.clone(); - // For checking if component is disposed + let state_for_msg = state_for_effect.clone(); + let on_event_for_msg = on_event.clone(); let is_disposed_for_msg = is_disposed_for_effect.clone(); let onmessage = Closure::wrap(Box::new(move |e: MessageEvent| { // Skip if component has been disposed @@ -305,12 +328,13 @@ pub fn use_channel_websocket( } = msg { // Track current user ID for MemberLeft filtering - *current_user_id_for_msg.borrow_mut() = Some(member.user_id); + state_for_msg.borrow_mut().current_user_id = Some(member.user_id); - if !*heartbeat_started_clone.borrow() { - *heartbeat_started_clone.borrow_mut() = true; + // Start heartbeat if not already running + let needs_heartbeat = state_for_msg.borrow().heartbeat_handle.is_none(); + if needs_heartbeat { let ping_interval_ms = config.ping_interval_secs * 1000; - let ws_ref_ping = ws_ref_for_heartbeat.clone(); + let state_for_ping = state_for_msg.clone(); #[cfg(debug_assertions)] web_sys::console::log_1( &format!( @@ -322,7 +346,8 @@ pub fn use_channel_websocket( let heartbeat = gloo_timers::callback::Interval::new( ping_interval_ms as u32, move || { - if let Some(ws) = ws_ref_ping.borrow().as_ref() { + let state = state_for_ping.borrow(); + if let Some(ws) = state.ws.as_ref() { if ws.ready_state() == WebSocket::OPEN { if let Ok(json) = serde_json::to_string(&ClientMessage::Ping) @@ -333,34 +358,17 @@ pub fn use_channel_websocket( } }, ); - std::mem::forget(heartbeat); + state_for_msg.borrow_mut().heartbeat_handle = Some(heartbeat); } // Call on_welcome callback with current user info - if let Some(ref callback) = on_welcome_clone { - let info = ChannelMemberInfo { - user_id: member.user_id, - display_name: member.display_name.clone(), - is_guest: member.is_guest, - }; - callback.run(info); - } + let info = ChannelMemberInfo { + user_id: member.user_id, + display_name: member.display_name.clone(), + is_guest: member.is_guest, + }; + on_event_for_msg.run(WsEvent::Welcome(info)); } - handle_server_message( - msg, - &members_for_msg, - &on_members_update_clone, - &on_chat_message_clone, - &on_loose_props_sync_clone, - &on_prop_dropped_clone, - &on_prop_picked_up_clone, - &on_member_fading_clone, - &on_error_clone, - &on_teleport_approved_clone, - &on_summoned_clone, - &on_mod_command_result_clone, - &on_member_identity_updated_clone, - ¤t_user_id_for_msg, - ); + handle_server_message(msg, &state_for_msg, &on_event_for_msg); } } }) as Box); @@ -417,9 +425,9 @@ pub fn use_channel_websocket( onerror.forget(); // onclose + let state_for_close = state_for_effect.clone(); let set_ws_state_close = set_ws_state; let reconnect_trigger_for_close = reconnect_trigger; - let is_intentional_close_for_onclose = is_intentional_close.clone(); let is_disposed_for_close = is_disposed_for_effect.clone(); let onclose = Closure::wrap(Box::new(move |e: CloseEvent| { // Skip if component has been disposed @@ -432,6 +440,12 @@ pub fn use_channel_websocket( &format!("[WS] Closed: code={}, reason={}", code, e.reason()).into(), ); + // Cancel heartbeat on close + state_for_close.borrow_mut().heartbeat_handle = None; + + // Check if this was an intentional close + let is_intentional = state_for_close.borrow().is_intentional_close; + // Handle based on close code with defense-in-depth using flag if code == close_codes::SERVER_TIMEOUT { // Server timeout - attempt silent reconnection (highest priority) @@ -446,14 +460,14 @@ pub fn use_channel_websocket( .forget(); } else if code == close_codes::SCENE_CHANGE || code == close_codes::LOGOUT - || *is_intentional_close_for_onclose.borrow() + || is_intentional { // Intentional close (scene change/teleport/logout) - don't show disconnection // Check both code AND flag for defense-in-depth (flag is guaranteed local state) #[cfg(debug_assertions)] web_sys::console::log_1(&"[WS] Intentional close, not setting Disconnected".into()); // Reset the flag for future connections - *is_intentional_close_for_onclose.borrow_mut() = false; + state_for_close.borrow_mut().is_intentional_close = false; } else { // Other close codes - treat as disconnection set_ws_state_close.set(WsState::Disconnected); @@ -462,15 +476,18 @@ pub fn use_channel_websocket( ws.set_onclose(Some(onclose.as_ref().unchecked_ref())); onclose.forget(); - *ws_ref_clone.borrow_mut() = Some(ws); + state_for_effect.borrow_mut().ws = Some(ws); }); // Create closer function for explicit WebSocket closure (e.g., logout) - // Uses clones created before the Effect closure captured the originals let closer: WsCloserStorage = StoredValue::new_local(Some(Box::new(move |code: u16, reason: String| { - if let Some(ws) = ws_ref_for_close.borrow().as_ref() { - // Set intentional close flag BEFORE closing - *is_intentional_close_for_closer.borrow_mut() = true; + let mut state = state_for_close.borrow_mut(); + // Set intentional close flag BEFORE closing + state.is_intentional_close = true; + // Cancel heartbeat + state.heartbeat_handle = None; + // Get the WebSocket (if any) and close it + if let Some(ws) = state.ws.as_ref() { #[cfg(debug_assertions)] web_sys::console::log_1( &format!("[WS] Closing with code={}, reason={}", code, reason).into(), @@ -486,21 +503,10 @@ pub fn use_channel_websocket( #[cfg(feature = "hydrate")] fn handle_server_message( msg: ServerMessage, - members: &std::rc::Rc>>, - on_update: &Callback>, - on_chat_message: &Callback, - on_loose_props_sync: &Callback>, - on_prop_dropped: &Callback, - on_prop_picked_up: &Callback, - on_member_fading: &Callback, - on_error: &Option>, - on_teleport_approved: &Option>, - on_summoned: &Option>, - on_mod_command_result: &Option>, - on_member_identity_updated: &Option>, - current_user_id: &std::rc::Rc>>, + state: &std::rc::Rc>, + on_event: &Callback, ) { - // Process message and collect any callbacks to run AFTER releasing the borrow + // Process message and collect any events to emit AFTER releasing the borrow enum PostAction { None, UpdateMembers(Vec), @@ -517,8 +523,8 @@ fn handle_server_message( } let action = { - let mut members_vec = members.borrow_mut(); - let own_user_id = *current_user_id.borrow(); + let mut state = state.borrow_mut(); + let own_user_id = state.current_user_id; match msg { ServerMessage::Welcome { @@ -526,14 +532,14 @@ fn handle_server_message( members: initial_members, config: _, // Config is handled in the caller for heartbeat setup } => { - *members_vec = initial_members; - PostAction::UpdateMembers(members_vec.clone()) + state.members = initial_members; + PostAction::UpdateMembers(state.members.clone()) } ServerMessage::MemberJoined { member } => { // Remove if exists (rejoin case), then add - members_vec.retain(|m| m.member.user_id != member.member.user_id); - members_vec.push(member); - PostAction::UpdateMembers(members_vec.clone()) + state.members.retain(|m| m.member.user_id != member.member.user_id); + state.members.push(member); + PostAction::UpdateMembers(state.members.clone()) } ServerMessage::MemberLeft { user_id, reason } => { // Check if this is our own MemberLeft due to timeout - ignore it during reconnection @@ -548,14 +554,14 @@ fn handle_server_message( } // Find the member before removing - let leaving_member = members_vec + let leaving_member = state.members .iter() .find(|m| m.member.user_id == user_id) .cloned(); // Always remove from active members list - members_vec.retain(|m| m.member.user_id != user_id); - let updated = members_vec.clone(); + state.members.retain(|m| m.member.user_id != user_id); + let updated = state.members.clone(); // For timeout disconnects, trigger fading animation if reason == DisconnectReason::Timeout { @@ -574,21 +580,21 @@ fn handle_server_message( } } ServerMessage::PositionUpdated { user_id, x, y } => { - if let Some(m) = members_vec + if let Some(m) = state.members .iter_mut() .find(|m| m.member.user_id == user_id) { m.member.position_x = x; m.member.position_y = y; } - PostAction::UpdateMembers(members_vec.clone()) + PostAction::UpdateMembers(state.members.clone()) } ServerMessage::EmotionUpdated { user_id, emotion, emotion_layer, } => { - if let Some(m) = members_vec + if let Some(m) = state.members .iter_mut() .find(|m| m.member.user_id == user_id) { @@ -598,7 +604,7 @@ fn handle_server_message( .unwrap_or_default(); m.avatar.emotion_layer = emotion_layer; } - PostAction::UpdateMembers(members_vec.clone()) + PostAction::UpdateMembers(state.members.clone()) } ServerMessage::Pong => { // Heartbeat acknowledged - nothing to do @@ -650,7 +656,7 @@ fn handle_server_message( } ServerMessage::AvatarUpdated { user_id, avatar } => { // Find member and update their avatar layers - if let Some(m) = members_vec + if let Some(m) = state.members .iter_mut() .find(|m| m.member.user_id == user_id) { @@ -659,7 +665,7 @@ fn handle_server_message( m.avatar.accessories_layer = avatar.accessories_layer.clone(); m.avatar.emotion_layer = avatar.emotion_layer.clone(); } - PostAction::UpdateMembers(members_vec.clone()) + PostAction::UpdateMembers(state.members.clone()) } ServerMessage::TeleportApproved { scene_id, @@ -690,7 +696,7 @@ fn handle_server_message( is_guest, } => { // Update the internal members list so subsequent updates don't overwrite - if let Some(member) = members_vec + if let Some(member) = state.members .iter_mut() .find(|m| m.member.user_id == user_id) { @@ -698,7 +704,7 @@ fn handle_server_message( member.member.is_guest = is_guest; } PostAction::UpdateMembersAndIdentity( - members_vec.clone(), + state.members.clone(), MemberIdentityInfo { user_id, display_name, @@ -713,13 +719,13 @@ fn handle_server_message( forced_by: _, } => { // Update the forced user's avatar - if let Some(m) = members_vec.iter_mut().find(|m| m.member.user_id == user_id) { + if let Some(m) = state.members.iter_mut().find(|m| m.member.user_id == user_id) { m.avatar.skin_layer = avatar.skin_layer.clone(); m.avatar.clothes_layer = avatar.clothes_layer.clone(); m.avatar.accessories_layer = avatar.accessories_layer.clone(); m.avatar.emotion_layer = avatar.emotion_layer.clone(); } - PostAction::UpdateMembers(members_vec.clone()) + PostAction::UpdateMembers(state.members.clone()) } ServerMessage::AvatarCleared { user_id, @@ -727,64 +733,54 @@ fn handle_server_message( cleared_by: _, } => { // Restore the user's original avatar - if let Some(m) = members_vec.iter_mut().find(|m| m.member.user_id == user_id) { + if let Some(m) = state.members.iter_mut().find(|m| m.member.user_id == user_id) { m.avatar.skin_layer = avatar.skin_layer.clone(); m.avatar.clothes_layer = avatar.clothes_layer.clone(); m.avatar.accessories_layer = avatar.accessories_layer.clone(); m.avatar.emotion_layer = avatar.emotion_layer.clone(); } - PostAction::UpdateMembers(members_vec.clone()) + PostAction::UpdateMembers(state.members.clone()) } } - }; // members_vec borrow is dropped here + }; // state borrow is dropped here - // Now run callbacks without holding any borrows + // Now emit events without holding any borrows match action { PostAction::None => {} PostAction::UpdateMembers(members) => { - on_update.run(members); + on_event.run(WsEvent::MembersUpdated(members)); } PostAction::UpdateMembersAndFade(members, fading) => { - on_update.run(members); - on_member_fading.run(fading); + on_event.run(WsEvent::MembersUpdated(members)); + on_event.run(WsEvent::MemberFading(fading)); } PostAction::UpdateMembersAndIdentity(members, info) => { - on_update.run(members); - if let Some(callback) = on_member_identity_updated { - callback.run(info); - } + on_event.run(WsEvent::MembersUpdated(members)); + on_event.run(WsEvent::MemberIdentityUpdated(info)); } PostAction::ChatMessage(msg) => { - on_chat_message.run(msg); + on_event.run(WsEvent::ChatMessage(msg)); } PostAction::LoosePropsSync(props) => { - on_loose_props_sync.run(props); + on_event.run(WsEvent::LoosePropsSync(props)); } PostAction::PropDropped(prop) => { - on_prop_dropped.run(prop); + on_event.run(WsEvent::PropDropped(prop)); } PostAction::PropPickedUp(prop_id) => { - on_prop_picked_up.run(prop_id); + on_event.run(WsEvent::PropPickedUp(prop_id)); } PostAction::Error(err) => { - if let Some(callback) = on_error { - callback.run(err); - } + on_event.run(WsEvent::Error(err)); } PostAction::TeleportApproved(info) => { - if let Some(callback) = on_teleport_approved { - callback.run(info); - } + on_event.run(WsEvent::TeleportApproved(info)); } PostAction::Summoned(info) => { - if let Some(callback) = on_summoned { - callback.run(info); - } + on_event.run(WsEvent::Summoned(info)); } PostAction::ModCommandResult(info) => { - if let Some(callback) = on_mod_command_result { - callback.run(info); - } + on_event.run(WsEvent::ModCommandResult(info)); } } } @@ -795,18 +791,7 @@ pub fn use_channel_websocket( _realm_slug: Signal, _channel_id: Signal>, _reconnect_trigger: RwSignal, - _on_members_update: Callback>, - _on_chat_message: Callback, - _on_loose_props_sync: Callback>, - _on_prop_dropped: Callback, - _on_prop_picked_up: Callback, - _on_member_fading: Callback, - _on_welcome: Option>, - _on_error: Option>, - _on_teleport_approved: Option>, - _on_summoned: Option>, - _on_mod_command_result: Option>, - _on_member_identity_updated: Option>, + _on_event: Callback, ) -> (Signal, WsSenderStorage, WsCloserStorage) { let (ws_state, _) = signal(WsState::Disconnected); let sender: WsSenderStorage = StoredValue::new_local(None); diff --git a/crates/chattyness-user-ui/src/pages/realm.rs b/crates/chattyness-user-ui/src/pages/realm.rs index ba56c06..a1c2293 100644 --- a/crates/chattyness-user-ui/src/pages/realm.rs +++ b/crates/chattyness-user-ui/src/pages/realm.rs @@ -19,8 +19,7 @@ use crate::components::{ }; #[cfg(feature = "hydrate")] use crate::components::{ - ChannelMemberInfo, ChatMessage, DEFAULT_BUBBLE_TIMEOUT_MS, FADE_DURATION_MS, - MemberIdentityInfo, ModCommandResultInfo, SummonInfo, TeleportInfo, WsError, + ChatMessage, DEFAULT_BUBBLE_TIMEOUT_MS, FADE_DURATION_MS, WsEvent, add_whisper_to_history, use_channel_websocket, }; use crate::utils::LocalStoragePersist; @@ -238,340 +237,246 @@ pub fn RealmPage() -> impl IntoView { }); } - // WebSocket connection for real-time updates + // Helper to navigate to a new scene (used by teleport and summon) #[cfg(feature = "hydrate")] - let on_members_update = Callback::new(move |new_members: Vec| { - // When members are updated (including rejoins), remove any matching fading members - set_fading_members.update(|fading| { - fading.retain(|f| { - !new_members.iter().any(|m| { - m.member.user_id == f.member.member.user_id - }) - }); - }); - set_members.set(new_members); - }); + let navigate_to_scene = { + let slug = slug.clone(); + move |scene_id: Uuid, scene_slug: String| { + let realm_slug = slug.get_untracked(); + let scene_slug_for_url = scene_slug.clone(); + let realm_slug_for_url = realm_slug.clone(); - // Chat message callback - #[cfg(feature = "hydrate")] - let on_chat_message = Callback::new(move |msg: ChatMessage| { - // Add to message log - message_log.update_value(|log| log.push(msg.clone())); + spawn_local(async move { + use gloo_net::http::Request; + let response = Request::get(&format!( + "/api/realms/{}/scenes/{}", + realm_slug, scene_slug + )) + .send() + .await; - // Handle whispers - if msg.is_whisper { - // Track whisper for conversation view - whisper_messages.update_value(|msgs| { - msgs.push(msg.clone()); - // Keep last 100 whisper messages - if msgs.len() > 100 { - msgs.remove(0); + if let Ok(resp) = response { + if resp.ok() { + if let Ok(scene) = resp.json::().await { + if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) { + set_scene_dimensions.set((w as f64, h as f64)); + } + + if let Some(window) = web_sys::window() { + if let Ok(history) = window.history() { + let new_url = if scene.is_entry_point { + format!("/realms/{}", realm_slug_for_url) + } else { + format!("/realms/{}/scenes/{}", realm_slug_for_url, scene_slug_for_url) + }; + let _ = history.replace_state_with_url( + &wasm_bindgen::JsValue::NULL, + "", + Some(&new_url), + ); + } + } + + set_current_scene.set(Some(scene)); + } + } } - }); - // Add to persistent whisper history in LocalStorage - add_whisper_to_history(msg.clone()); - - if msg.is_same_scene { - // Same scene whisper: show as italic bubble (handled by bubble rendering) - let key = msg.user_id; - let expires_at = msg.timestamp + DEFAULT_BUBBLE_TIMEOUT_MS; - set_active_bubbles.update(|bubbles| { - bubbles.insert( - key, - ActiveBubble { - message: msg, - expires_at, - }, - ); - }); - } else { - // Cross-scene whisper: show as notification toast - set_current_notification.set(Some(NotificationMessage::from_chat_message(msg))); - } - } else { - // Regular broadcast: show as bubble - let key = msg.user_id; - let expires_at = msg.timestamp + DEFAULT_BUBBLE_TIMEOUT_MS; - set_active_bubbles.update(|bubbles| { - bubbles.insert( - key, - ActiveBubble { - message: msg, - expires_at, - }, - ); + set_channel_id.set(Some(scene_id)); + set_members.set(Vec::new()); + reconnect_trigger.update(|t| *t += 1); }); } - }); + }; - // Loose props callbacks + // Consolidated WebSocket event handler #[cfg(feature = "hydrate")] - let on_loose_props_sync = Callback::new(move |props: Vec| { - set_loose_props.set(props); - }); - + let navigate_to_scene_for_event = navigate_to_scene.clone(); #[cfg(feature = "hydrate")] - let on_prop_dropped = Callback::new(move |prop: LooseProp| { - set_loose_props.update(|props| { - props.push(prop); - }); - }); + let on_ws_event = Callback::new(move |event: WsEvent| { + match event { + WsEvent::MembersUpdated(new_members) => { + // When members are updated (including rejoins), remove any matching fading members + set_fading_members.update(|fading| { + fading.retain(|f| { + !new_members.iter().any(|m| { + m.member.user_id == f.member.member.user_id + }) + }); + }); + set_members.set(new_members); + } + WsEvent::ChatMessage(msg) => { + // Add to message log + message_log.update_value(|log| log.push(msg.clone())); - #[cfg(feature = "hydrate")] - let on_prop_picked_up = Callback::new(move |prop_id: Uuid| { - set_loose_props.update(|props| { - props.retain(|p| p.id != prop_id); - }); - }); - - // Callback when a member starts fading (timeout disconnect) - #[cfg(feature = "hydrate")] - let on_member_fading = Callback::new(move |fading: FadingMember| { - set_fading_members.update(|members| { - // Remove any existing entry for this user (shouldn't happen, but be safe) - members.retain(|m| { - m.member.member.user_id != fading.member.member.user_id - }); - members.push(fading); - }); - }); - - // Callback to capture current user identity from Welcome message - #[cfg(feature = "hydrate")] - let on_welcome = Callback::new(move |info: ChannelMemberInfo| { - set_current_user_id.set(Some(info.user_id)); - set_current_display_name.set(info.display_name.clone()); - set_is_guest.set(info.is_guest); - }); - - // Callback for WebSocket errors (whisper failures, etc.) - #[cfg(feature = "hydrate")] - let on_ws_error = Callback::new(move |error: WsError| { - // Display user-friendly error message - let msg = match error.code.as_str() { - "WHISPER_TARGET_NOT_FOUND" => error.message, - "TELEPORT_DISABLED" => error.message, - "SCENE_NOT_FOUND" => error.message, - _ => format!("Error: {}", error.message), - }; - set_error_message.set(Some(msg)); - // Auto-dismiss after 5 seconds - use gloo_timers::callback::Timeout; - Timeout::new(5000, move || { - set_error_message.set(None); - }) - .forget(); - }); - - // Callback for teleport approval - navigate to new scene - #[cfg(feature = "hydrate")] - let on_teleport_approved = Callback::new(move |info: TeleportInfo| { - // Log teleport to message log - let teleport_msg = ChatMessage { - message_id: Uuid::new_v4(), - user_id: Uuid::nil(), // System message - display_name: "[SYSTEM]".to_string(), - content: format!("Teleported to scene: {}", info.scene_slug), - emotion: "neutral".to_string(), - x: 0.0, - y: 0.0, - timestamp: js_sys::Date::now() as i64, - is_whisper: false, - is_same_scene: true, - is_system: true, - }; - message_log.update_value(|log| log.push(teleport_msg)); - - let scene_id = info.scene_id; - let scene_slug = info.scene_slug.clone(); - let realm_slug = slug.get_untracked(); - - // Fetch the new scene data to update the canvas background - let scene_slug_for_url = scene_slug.clone(); - let realm_slug_for_url = realm_slug.clone(); - spawn_local(async move { - use gloo_net::http::Request; - let response = Request::get(&format!( - "/api/realms/{}/scenes/{}", - realm_slug, scene_slug - )) - .send() - .await; - - if let Ok(resp) = response { - if resp.ok() { - if let Ok(scene) = resp.json::().await { - // Update scene dimensions from the new scene - if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) { - set_scene_dimensions.set((w as f64, h as f64)); + // Handle whispers + if msg.is_whisper { + // Track whisper for conversation view + whisper_messages.update_value(|msgs| { + msgs.push(msg.clone()); + // Keep last 100 whisper messages + if msgs.len() > 100 { + msgs.remove(0); } + }); - // Update URL to reflect new scene - if let Some(window) = web_sys::window() { - if let Ok(history) = window.history() { - let new_url = if scene.is_entry_point { - format!("/realms/{}", realm_slug_for_url) - } else { - format!( - "/realms/{}/scenes/{}", - realm_slug_for_url, scene_slug_for_url - ) - }; - let _ = history.replace_state_with_url( - &wasm_bindgen::JsValue::NULL, - "", - Some(&new_url), - ); - } - } + // Add to persistent whisper history in LocalStorage + add_whisper_to_history(msg.clone()); - // Update the current scene for the viewer - set_current_scene.set(Some(scene)); + if msg.is_same_scene { + // Same scene whisper: show as italic bubble (handled by bubble rendering) + let key = msg.user_id; + let expires_at = msg.timestamp + DEFAULT_BUBBLE_TIMEOUT_MS; + set_active_bubbles.update(|bubbles| { + bubbles.insert( + key, + ActiveBubble { + message: msg, + expires_at, + }, + ); + }); + } else { + // Cross-scene whisper: show as notification toast + set_current_notification.set(Some(NotificationMessage::from_chat_message(msg))); } + } else { + // Regular broadcast: show as bubble + let key = msg.user_id; + let expires_at = msg.timestamp + DEFAULT_BUBBLE_TIMEOUT_MS; + set_active_bubbles.update(|bubbles| { + bubbles.insert( + key, + ActiveBubble { + message: msg, + expires_at, + }, + ); + }); } } + WsEvent::LoosePropsSync(props) => { + set_loose_props.set(props); + } + WsEvent::PropDropped(prop) => { + set_loose_props.update(|props| { + props.push(prop); + }); + } + WsEvent::PropPickedUp(prop_id) => { + set_loose_props.update(|props| { + props.retain(|p| p.id != prop_id); + }); + } + WsEvent::MemberFading(fading) => { + set_fading_members.update(|members| { + // Remove any existing entry for this user (shouldn't happen, but be safe) + members.retain(|m| { + m.member.member.user_id != fading.member.member.user_id + }); + members.push(fading); + }); + } + WsEvent::Welcome(info) => { + set_current_user_id.set(Some(info.user_id)); + set_current_display_name.set(info.display_name.clone()); + set_is_guest.set(info.is_guest); + } + WsEvent::Error(error) => { + // Display user-friendly error message + let msg = match error.code.as_str() { + "WHISPER_TARGET_NOT_FOUND" => error.message, + "TELEPORT_DISABLED" => error.message, + "SCENE_NOT_FOUND" => error.message, + _ => format!("Error: {}", error.message), + }; + set_error_message.set(Some(msg)); + // Auto-dismiss after 5 seconds + gloo_timers::callback::Timeout::new(5000, move || { + set_error_message.set(None); + }) + .forget(); + } + WsEvent::TeleportApproved(info) => { + let teleport_msg = ChatMessage { + message_id: Uuid::new_v4(), + user_id: Uuid::nil(), + display_name: "[SYSTEM]".to_string(), + content: format!("Teleported to scene: {}", info.scene_slug), + emotion: "neutral".to_string(), + x: 0.0, + y: 0.0, + timestamp: js_sys::Date::now() as i64, + is_whisper: false, + is_same_scene: true, + is_system: true, + }; + message_log.update_value(|log| log.push(teleport_msg)); + navigate_to_scene_for_event(info.scene_id, info.scene_slug); + } + WsEvent::Summoned(info) => { + let summon_msg = ChatMessage { + message_id: Uuid::new_v4(), + user_id: Uuid::nil(), + display_name: "[MOD]".to_string(), + content: format!("Summoned by {} to scene: {}", info.summoned_by, info.scene_slug), + emotion: "neutral".to_string(), + x: 0.0, + y: 0.0, + timestamp: js_sys::Date::now() as i64, + is_whisper: false, + is_same_scene: true, + is_system: true, + }; + message_log.update_value(|log| log.push(summon_msg)); - // Update channel_id to trigger WebSocket reconnection - set_channel_id.set(Some(scene_id)); + set_mod_notification.set(Some((true, format!("Summoned by {}", info.summoned_by)))); + gloo_timers::callback::Timeout::new(3000, move || { + set_mod_notification.set(None); + }) + .forget(); - // Clear members since we're switching scenes - set_members.set(Vec::new()); + navigate_to_scene_for_event(info.scene_id, info.scene_slug); + } + WsEvent::ModCommandResult(info) => { + // Log mod command result to message log + let status = if info.success { "OK" } else { "FAILED" }; + let mod_msg = ChatMessage { + message_id: Uuid::new_v4(), + user_id: Uuid::nil(), // System/mod message + display_name: "[MOD]".to_string(), + content: format!("[{}] {}", status, info.message), + emotion: "neutral".to_string(), + x: 0.0, + y: 0.0, + timestamp: js_sys::Date::now() as i64, + is_whisper: false, + is_same_scene: true, + is_system: true, + }; + message_log.update_value(|log| log.push(mod_msg)); - // Trigger a reconnect to ensure fresh connection - reconnect_trigger.update(|t| *t += 1); - }); - }); + set_mod_notification.set(Some((info.success, info.message))); - // Callback for being summoned by a moderator - show notification and teleport - #[cfg(feature = "hydrate")] - let on_summoned = Callback::new(move |info: SummonInfo| { - // Log summon to message log - let summon_msg = ChatMessage { - message_id: Uuid::new_v4(), - user_id: Uuid::nil(), // System/mod message - display_name: "[MOD]".to_string(), - content: format!("Summoned by {} to scene: {}", info.summoned_by, info.scene_slug), - emotion: "neutral".to_string(), - x: 0.0, - y: 0.0, - timestamp: js_sys::Date::now() as i64, - is_whisper: false, - is_same_scene: true, - is_system: true, - }; - message_log.update_value(|log| log.push(summon_msg)); - - // Show notification - set_mod_notification.set(Some((true, format!("Summoned by {}", info.summoned_by)))); - - // Auto-dismiss notification after 3 seconds - let timeout = gloo_timers::callback::Timeout::new(3000, move || { - set_mod_notification.set(None); - }); - timeout.forget(); - - let scene_id = info.scene_id; - let scene_slug = info.scene_slug.clone(); - let realm_slug = slug.get_untracked(); - - // Fetch the new scene data (same as teleport approval) - let scene_slug_for_url = scene_slug.clone(); - let realm_slug_for_url = realm_slug.clone(); - spawn_local(async move { - use gloo_net::http::Request; - let response = Request::get(&format!( - "/api/realms/{}/scenes/{}", - realm_slug, scene_slug - )) - .send() - .await; - - if let Ok(resp) = response { - if resp.ok() { - if let Ok(scene) = resp.json::().await { - // Update scene dimensions from the new scene - if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) { - set_scene_dimensions.set((w as f64, h as f64)); - } - - // Update URL to reflect new scene - if let Some(window) = web_sys::window() { - if let Ok(history) = window.history() { - let new_url = if scene.is_entry_point { - format!("/realms/{}", realm_slug_for_url) - } else { - format!( - "/realms/{}/scenes/{}", - realm_slug_for_url, scene_slug_for_url - ) - }; - let _ = history.replace_state_with_url( - &wasm_bindgen::JsValue::NULL, - "", - Some(&new_url), - ); - } - } - - // Update the current scene for the viewer - set_current_scene.set(Some(scene)); + // Auto-dismiss notification after 3 seconds + gloo_timers::callback::Timeout::new(3000, move || { + set_mod_notification.set(None); + }) + .forget(); + } + WsEvent::MemberIdentityUpdated(info) => { + // Update the member's display name in the members list + set_members.update(|members| { + if let Some(member) = members + .iter_mut() + .find(|m| m.member.user_id == info.user_id) + { + member.member.display_name = info.display_name.clone(); } - } + }); } - - // Update channel_id to trigger WebSocket reconnection - set_channel_id.set(Some(scene_id)); - - // Clear members since we're switching scenes - set_members.set(Vec::new()); - - // Trigger a reconnect to ensure fresh connection - reconnect_trigger.update(|t| *t += 1); - }); - }); - - // Callback for mod command result - show notification - #[cfg(feature = "hydrate")] - let on_mod_command_result = Callback::new(move |info: ModCommandResultInfo| { - // Log mod command result to message log - let status = if info.success { "OK" } else { "FAILED" }; - let mod_msg = ChatMessage { - message_id: Uuid::new_v4(), - user_id: Uuid::nil(), // System/mod message - display_name: "[MOD]".to_string(), - content: format!("[{}] {}", status, info.message), - emotion: "neutral".to_string(), - x: 0.0, - y: 0.0, - timestamp: js_sys::Date::now() as i64, - is_whisper: false, - is_same_scene: true, - is_system: true, - }; - message_log.update_value(|log| log.push(mod_msg)); - - set_mod_notification.set(Some((info.success, info.message))); - - // Auto-dismiss notification after 3 seconds - let timeout = gloo_timers::callback::Timeout::new(3000, move || { - set_mod_notification.set(None); - }); - timeout.forget(); - }); - - // Callback for member identity updates (e.g., guest registered as user) - #[cfg(feature = "hydrate")] - let on_member_identity_updated = Callback::new(move |info: MemberIdentityInfo| { - // Update the member's display name in the members list - set_members.update(|members| { - if let Some(member) = members - .iter_mut() - .find(|m| m.member.user_id == info.user_id) - { - member.member.display_name = info.display_name.clone(); - } - }); + } }); #[cfg(feature = "hydrate")] @@ -579,18 +484,7 @@ pub fn RealmPage() -> impl IntoView { slug, Signal::derive(move || channel_id.get()), reconnect_trigger, - on_members_update, - on_chat_message, - on_loose_props_sync, - on_prop_dropped, - on_prop_picked_up, - on_member_fading, - Some(on_welcome), - Some(on_ws_error), - Some(on_teleport_approved), - Some(on_summoned), - Some(on_mod_command_result), - Some(on_member_identity_updated), + on_ws_event, ); // Set channel ID, current scene, and scene dimensions when entry scene loads @@ -825,6 +719,10 @@ pub fn RealmPage() -> impl IntoView { Rc::new(RefCell::new(None)); let keyup_closure_holder_clone = keyup_closure_holder.clone(); + // StoredValue to hold js_sys::Function references for cleanup (Send+Sync compatible) + let keydown_fn: StoredValue, LocalStorage> = StoredValue::new_local(None); + let keyup_fn: StoredValue, LocalStorage> = StoredValue::new_local(None); + Effect::new(move |_| { // Cleanup previous keydown closure if any if let Some(old_closure) = closure_holder_clone.borrow_mut().take() { @@ -1082,29 +980,20 @@ pub fn RealmPage() -> impl IntoView { // Store the keyup closure for cleanup *keyup_closure_holder_clone.borrow_mut() = Some(keyup_closure); - }); - // Cleanup event listeners when component unmounts - // We need to store JS function references for cleanup since Rc isn't Send+Sync - let keydown_fn: StoredValue, LocalStorage> = StoredValue::new_local(None); - let keyup_fn: StoredValue, LocalStorage> = StoredValue::new_local(None); - - // Store references to the JS functions for cleanup - Effect::new({ - let closure_holder = closure_holder.clone(); - let keyup_closure_holder = keyup_closure_holder.clone(); - move |_| { - if let Some(ref closure) = *closure_holder.borrow() { - let func: &js_sys::Function = closure.as_ref().unchecked_ref(); - keydown_fn.set_value(Some(func.clone())); - } - if let Some(ref closure) = *keyup_closure_holder.borrow() { - let func: &js_sys::Function = closure.as_ref().unchecked_ref(); - keyup_fn.set_value(Some(func.clone())); - } + // Extract and store js_sys::Function references for cleanup + // (must be done inside Effect since Rc> isn't Send+Sync for on_cleanup) + if let Some(ref closure) = *closure_holder_clone.borrow() { + let func: &js_sys::Function = closure.as_ref().unchecked_ref(); + keydown_fn.set_value(Some(func.clone())); + } + if let Some(ref closure) = *keyup_closure_holder_clone.borrow() { + let func: &js_sys::Function = closure.as_ref().unchecked_ref(); + keyup_fn.set_value(Some(func.clone())); } }); + // Cleanup event listeners when component unmounts on_cleanup(move || { if let Some(window) = web_sys::window() { keydown_fn.with_value(|func| { @@ -1155,32 +1044,26 @@ pub fn RealmPage() -> impl IntoView { }); // Create logout callback - explicitly close WebSocket before calling logout API + // Create logout callback - close WebSocket and call logout API let on_logout = Callback::new(move |_: ()| { #[cfg(feature = "hydrate")] { use gloo_net::http::Request; - use gloo_timers::callback::Timeout; let navigate = navigate.clone(); - // 1. Close WebSocket explicitly with LOGOUT code + // Close WebSocket explicitly with LOGOUT code (non-blocking, browser handles close handshake) ws_close.with_value(|closer| { if let Some(close_fn) = closer { close_fn(close_codes::LOGOUT, "logout".to_string()); } }); - // 2. Small delay to ensure close message is sent, then call logout API - Timeout::new(100, move || { - spawn_local(async move { - // 3. Call logout API - let _: Result = - Request::post("/api/auth/logout").send().await; - - // 4. Navigate to home - navigate("/", Default::default()); - }); - }) - .forget(); + // Call logout API immediately - session invalidation doesn't depend on WS close completing + spawn_local(async move { + let _: Result = + Request::post("/api/auth/logout").send().await; + navigate("/", Default::default()); + }); } });