fix: websocket cleanup

This commit is contained in:
Evan Carroll 2026-01-23 11:00:04 -06:00
parent 60a6680eaf
commit 6192875d0d
2 changed files with 396 additions and 528 deletions

View file

@ -124,6 +124,60 @@ pub struct MemberIdentityInfo {
pub is_guest: bool, 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<ChannelMemberWithAvatar>),
/// Chat message received.
ChatMessage(ChatMessage),
/// Loose props synchronized (initial list).
LoosePropsSync(Vec<LooseProp>),
/// 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<RefCell<>> proliferation.
#[cfg(feature = "hydrate")]
struct WsInternalState {
ws: Option<web_sys::WebSocket>,
members: Vec<ChannelMemberWithAvatar>,
current_user_id: Option<uuid::Uuid>,
is_intentional_close: bool,
heartbeat_handle: Option<gloo_timers::callback::Interval>,
}
#[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. /// Hook to manage WebSocket connection for a channel.
/// ///
/// Returns a tuple of: /// Returns a tuple of:
@ -135,18 +189,7 @@ pub fn use_channel_websocket(
realm_slug: Signal<String>, realm_slug: Signal<String>,
channel_id: Signal<Option<uuid::Uuid>>, channel_id: Signal<Option<uuid::Uuid>>,
reconnect_trigger: RwSignal<u32>, reconnect_trigger: RwSignal<u32>,
on_members_update: Callback<Vec<ChannelMemberWithAvatar>>, on_event: Callback<WsEvent>,
on_chat_message: Callback<ChatMessage>,
on_loose_props_sync: Callback<Vec<LooseProp>>,
on_prop_dropped: Callback<LooseProp>,
on_prop_picked_up: Callback<uuid::Uuid>,
on_member_fading: Callback<FadingMember>,
on_welcome: Option<Callback<ChannelMemberInfo>>,
on_error: Option<Callback<WsError>>,
on_teleport_approved: Option<Callback<TeleportInfo>>,
on_summoned: Option<Callback<SummonInfo>>,
on_mod_command_result: Option<Callback<ModCommandResultInfo>>,
on_member_identity_updated: Option<Callback<MemberIdentityInfo>>,
) -> (Signal<WsState>, WsSenderStorage, WsCloserStorage) { ) -> (Signal<WsState>, WsSenderStorage, WsCloserStorage) {
use std::cell::RefCell; use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
@ -156,21 +199,17 @@ pub fn use_channel_websocket(
use web_sys::{CloseEvent, ErrorEvent, MessageEvent, WebSocket}; use web_sys::{CloseEvent, ErrorEvent, MessageEvent, WebSocket};
let (ws_state, set_ws_state) = signal(WsState::Disconnected); let (ws_state, set_ws_state) = signal(WsState::Disconnected);
let ws_ref: Rc<RefCell<Option<WebSocket>>> = Rc::new(RefCell::new(None));
let members: Rc<RefCell<Vec<ChannelMemberWithAvatar>>> = Rc::new(RefCell::new(Vec::new())); let state: Rc<RefCell<WsInternalState>> = Rc::new(RefCell::new(WsInternalState::default()));
// Track current user's ID to ignore self MemberLeft during reconnection
let current_user_id: Rc<RefCell<Option<uuid::Uuid>>> = 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<RefCell<bool>> = Rc::new(RefCell::new(false));
// Flag to prevent accessing disposed reactive values after component unmount // Flag to prevent accessing disposed reactive values after component unmount
let is_disposed: Arc<AtomicBool> = Arc::new(AtomicBool::new(false)); let is_disposed: Arc<AtomicBool> = Arc::new(AtomicBool::new(false));
// Create a stored sender function (using new_local for WASM single-threaded environment) // 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 = let sender: WsSenderStorage =
StoredValue::new_local(Some(Box::new(move |msg: ClientMessage| { 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 ws.ready_state() == WebSocket::OPEN {
if let Ok(json) = serde_json::to_string(&msg) { if let Ok(json) = serde_json::to_string(&msg) {
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
@ -181,15 +220,8 @@ pub fn use_channel_websocket(
} }
}))); })));
// Effect to manage WebSocket lifecycle // Clone for closer callback (must be done before Effect captures state)
let ws_ref_clone = ws_ref.clone(); let state_for_close = state.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();
// Set disposed flag on cleanup to prevent accessing disposed reactive values // Set disposed flag on cleanup to prevent accessing disposed reactive values
let is_disposed_for_cleanup = is_disposed.clone(); 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); 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 |_| { Effect::new(move |_| {
let slug = realm_slug.get(); let slug = realm_slug.get();
let ch_id = channel_id.get(); let ch_id = channel_id.get();
@ -204,16 +240,21 @@ pub fn use_channel_websocket(
let _trigger = reconnect_trigger.get(); let _trigger = reconnect_trigger.get();
// Cleanup previous connection // Cleanup previous connection
if let Some(old_ws) = ws_ref_clone.borrow_mut().take() { {
let mut state = state_for_effect.borrow_mut();
if let Some(old_ws) = state.ws.take() {
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
web_sys::console::log_1( web_sys::console::log_1(
&format!("[WS] Closing old connection, readyState={}", old_ws.ready_state()).into(), &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 // Set flag BEFORE closing - guarantees local state even if close code doesn't arrive
*is_intentional_close_for_cleanup.borrow_mut() = true; state.is_intentional_close = true;
// Cancel existing heartbeat
state.heartbeat_handle = None;
// Close with SCENE_CHANGE code so onclose handler knows this was intentional // 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 _ = old_ws.close_with_code_and_reason(close_codes::SCENE_CHANGE, "scene change");
} }
}
let Some(ch_id) = ch_id else { let Some(ch_id) = ch_id else {
set_ws_state.set(WsState::Disconnected); set_ws_state.set(WsState::Disconnected);
@ -265,26 +306,8 @@ pub fn use_channel_websocket(
onopen.forget(); onopen.forget();
// onmessage // onmessage
let members_for_msg = members_clone.clone(); let state_for_msg = state_for_effect.clone();
let on_members_update_clone = on_members_update.clone(); let on_event_for_msg = on_event.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<RefCell<bool>> = 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 is_disposed_for_msg = is_disposed_for_effect.clone(); let is_disposed_for_msg = is_disposed_for_effect.clone();
let onmessage = Closure::wrap(Box::new(move |e: MessageEvent| { let onmessage = Closure::wrap(Box::new(move |e: MessageEvent| {
// Skip if component has been disposed // Skip if component has been disposed
@ -305,12 +328,13 @@ pub fn use_channel_websocket(
} = msg } = msg
{ {
// Track current user ID for MemberLeft filtering // 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() { // Start heartbeat if not already running
*heartbeat_started_clone.borrow_mut() = true; let needs_heartbeat = state_for_msg.borrow().heartbeat_handle.is_none();
if needs_heartbeat {
let ping_interval_ms = config.ping_interval_secs * 1000; 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)] #[cfg(debug_assertions)]
web_sys::console::log_1( web_sys::console::log_1(
&format!( &format!(
@ -322,7 +346,8 @@ pub fn use_channel_websocket(
let heartbeat = gloo_timers::callback::Interval::new( let heartbeat = gloo_timers::callback::Interval::new(
ping_interval_ms as u32, ping_interval_ms as u32,
move || { 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 ws.ready_state() == WebSocket::OPEN {
if let Ok(json) = if let Ok(json) =
serde_json::to_string(&ClientMessage::Ping) 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 // Call on_welcome callback with current user info
if let Some(ref callback) = on_welcome_clone {
let info = ChannelMemberInfo { let info = ChannelMemberInfo {
user_id: member.user_id, user_id: member.user_id,
display_name: member.display_name.clone(), display_name: member.display_name.clone(),
is_guest: member.is_guest, is_guest: member.is_guest,
}; };
callback.run(info); on_event_for_msg.run(WsEvent::Welcome(info));
} }
} handle_server_message(msg, &state_for_msg, &on_event_for_msg);
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,
&current_user_id_for_msg,
);
} }
} }
}) as Box<dyn FnMut(MessageEvent)>); }) as Box<dyn FnMut(MessageEvent)>);
@ -417,9 +425,9 @@ pub fn use_channel_websocket(
onerror.forget(); onerror.forget();
// onclose // onclose
let state_for_close = state_for_effect.clone();
let set_ws_state_close = set_ws_state; let set_ws_state_close = set_ws_state;
let reconnect_trigger_for_close = reconnect_trigger; 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 is_disposed_for_close = is_disposed_for_effect.clone();
let onclose = Closure::wrap(Box::new(move |e: CloseEvent| { let onclose = Closure::wrap(Box::new(move |e: CloseEvent| {
// Skip if component has been disposed // Skip if component has been disposed
@ -432,6 +440,12 @@ pub fn use_channel_websocket(
&format!("[WS] Closed: code={}, reason={}", code, e.reason()).into(), &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 // Handle based on close code with defense-in-depth using flag
if code == close_codes::SERVER_TIMEOUT { if code == close_codes::SERVER_TIMEOUT {
// Server timeout - attempt silent reconnection (highest priority) // Server timeout - attempt silent reconnection (highest priority)
@ -446,14 +460,14 @@ pub fn use_channel_websocket(
.forget(); .forget();
} else if code == close_codes::SCENE_CHANGE } else if code == close_codes::SCENE_CHANGE
|| code == close_codes::LOGOUT || code == close_codes::LOGOUT
|| *is_intentional_close_for_onclose.borrow() || is_intentional
{ {
// Intentional close (scene change/teleport/logout) - don't show disconnection // Intentional close (scene change/teleport/logout) - don't show disconnection
// Check both code AND flag for defense-in-depth (flag is guaranteed local state) // Check both code AND flag for defense-in-depth (flag is guaranteed local state)
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
web_sys::console::log_1(&"[WS] Intentional close, not setting Disconnected".into()); web_sys::console::log_1(&"[WS] Intentional close, not setting Disconnected".into());
// Reset the flag for future connections // Reset the flag for future connections
*is_intentional_close_for_onclose.borrow_mut() = false; state_for_close.borrow_mut().is_intentional_close = false;
} else { } else {
// Other close codes - treat as disconnection // Other close codes - treat as disconnection
set_ws_state_close.set(WsState::Disconnected); 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())); ws.set_onclose(Some(onclose.as_ref().unchecked_ref()));
onclose.forget(); 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) // 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| { 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() { let mut state = state_for_close.borrow_mut();
// Set intentional close flag BEFORE closing // Set intentional close flag BEFORE closing
*is_intentional_close_for_closer.borrow_mut() = true; 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)] #[cfg(debug_assertions)]
web_sys::console::log_1( web_sys::console::log_1(
&format!("[WS] Closing with code={}, reason={}", code, reason).into(), &format!("[WS] Closing with code={}, reason={}", code, reason).into(),
@ -486,21 +503,10 @@ pub fn use_channel_websocket(
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
fn handle_server_message( fn handle_server_message(
msg: ServerMessage, msg: ServerMessage,
members: &std::rc::Rc<std::cell::RefCell<Vec<ChannelMemberWithAvatar>>>, state: &std::rc::Rc<std::cell::RefCell<WsInternalState>>,
on_update: &Callback<Vec<ChannelMemberWithAvatar>>, on_event: &Callback<WsEvent>,
on_chat_message: &Callback<ChatMessage>,
on_loose_props_sync: &Callback<Vec<LooseProp>>,
on_prop_dropped: &Callback<LooseProp>,
on_prop_picked_up: &Callback<uuid::Uuid>,
on_member_fading: &Callback<FadingMember>,
on_error: &Option<Callback<WsError>>,
on_teleport_approved: &Option<Callback<TeleportInfo>>,
on_summoned: &Option<Callback<SummonInfo>>,
on_mod_command_result: &Option<Callback<ModCommandResultInfo>>,
on_member_identity_updated: &Option<Callback<MemberIdentityInfo>>,
current_user_id: &std::rc::Rc<std::cell::RefCell<Option<uuid::Uuid>>>,
) { ) {
// 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 { enum PostAction {
None, None,
UpdateMembers(Vec<ChannelMemberWithAvatar>), UpdateMembers(Vec<ChannelMemberWithAvatar>),
@ -517,8 +523,8 @@ fn handle_server_message(
} }
let action = { let action = {
let mut members_vec = members.borrow_mut(); let mut state = state.borrow_mut();
let own_user_id = *current_user_id.borrow(); let own_user_id = state.current_user_id;
match msg { match msg {
ServerMessage::Welcome { ServerMessage::Welcome {
@ -526,14 +532,14 @@ fn handle_server_message(
members: initial_members, members: initial_members,
config: _, // Config is handled in the caller for heartbeat setup config: _, // Config is handled in the caller for heartbeat setup
} => { } => {
*members_vec = initial_members; state.members = initial_members;
PostAction::UpdateMembers(members_vec.clone()) PostAction::UpdateMembers(state.members.clone())
} }
ServerMessage::MemberJoined { member } => { ServerMessage::MemberJoined { member } => {
// Remove if exists (rejoin case), then add // Remove if exists (rejoin case), then add
members_vec.retain(|m| m.member.user_id != member.member.user_id); state.members.retain(|m| m.member.user_id != member.member.user_id);
members_vec.push(member); state.members.push(member);
PostAction::UpdateMembers(members_vec.clone()) PostAction::UpdateMembers(state.members.clone())
} }
ServerMessage::MemberLeft { user_id, reason } => { ServerMessage::MemberLeft { user_id, reason } => {
// Check if this is our own MemberLeft due to timeout - ignore it during reconnection // 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 // Find the member before removing
let leaving_member = members_vec let leaving_member = state.members
.iter() .iter()
.find(|m| m.member.user_id == user_id) .find(|m| m.member.user_id == user_id)
.cloned(); .cloned();
// Always remove from active members list // Always remove from active members list
members_vec.retain(|m| m.member.user_id != user_id); state.members.retain(|m| m.member.user_id != user_id);
let updated = members_vec.clone(); let updated = state.members.clone();
// For timeout disconnects, trigger fading animation // For timeout disconnects, trigger fading animation
if reason == DisconnectReason::Timeout { if reason == DisconnectReason::Timeout {
@ -574,21 +580,21 @@ fn handle_server_message(
} }
} }
ServerMessage::PositionUpdated { user_id, x, y } => { ServerMessage::PositionUpdated { user_id, x, y } => {
if let Some(m) = members_vec if let Some(m) = state.members
.iter_mut() .iter_mut()
.find(|m| m.member.user_id == user_id) .find(|m| m.member.user_id == user_id)
{ {
m.member.position_x = x; m.member.position_x = x;
m.member.position_y = y; m.member.position_y = y;
} }
PostAction::UpdateMembers(members_vec.clone()) PostAction::UpdateMembers(state.members.clone())
} }
ServerMessage::EmotionUpdated { ServerMessage::EmotionUpdated {
user_id, user_id,
emotion, emotion,
emotion_layer, emotion_layer,
} => { } => {
if let Some(m) = members_vec if let Some(m) = state.members
.iter_mut() .iter_mut()
.find(|m| m.member.user_id == user_id) .find(|m| m.member.user_id == user_id)
{ {
@ -598,7 +604,7 @@ fn handle_server_message(
.unwrap_or_default(); .unwrap_or_default();
m.avatar.emotion_layer = emotion_layer; m.avatar.emotion_layer = emotion_layer;
} }
PostAction::UpdateMembers(members_vec.clone()) PostAction::UpdateMembers(state.members.clone())
} }
ServerMessage::Pong => { ServerMessage::Pong => {
// Heartbeat acknowledged - nothing to do // Heartbeat acknowledged - nothing to do
@ -650,7 +656,7 @@ fn handle_server_message(
} }
ServerMessage::AvatarUpdated { user_id, avatar } => { ServerMessage::AvatarUpdated { user_id, avatar } => {
// Find member and update their avatar layers // Find member and update their avatar layers
if let Some(m) = members_vec if let Some(m) = state.members
.iter_mut() .iter_mut()
.find(|m| m.member.user_id == user_id) .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.accessories_layer = avatar.accessories_layer.clone();
m.avatar.emotion_layer = avatar.emotion_layer.clone(); m.avatar.emotion_layer = avatar.emotion_layer.clone();
} }
PostAction::UpdateMembers(members_vec.clone()) PostAction::UpdateMembers(state.members.clone())
} }
ServerMessage::TeleportApproved { ServerMessage::TeleportApproved {
scene_id, scene_id,
@ -690,7 +696,7 @@ fn handle_server_message(
is_guest, is_guest,
} => { } => {
// Update the internal members list so subsequent updates don't overwrite // 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() .iter_mut()
.find(|m| m.member.user_id == user_id) .find(|m| m.member.user_id == user_id)
{ {
@ -698,7 +704,7 @@ fn handle_server_message(
member.member.is_guest = is_guest; member.member.is_guest = is_guest;
} }
PostAction::UpdateMembersAndIdentity( PostAction::UpdateMembersAndIdentity(
members_vec.clone(), state.members.clone(),
MemberIdentityInfo { MemberIdentityInfo {
user_id, user_id,
display_name, display_name,
@ -713,13 +719,13 @@ fn handle_server_message(
forced_by: _, forced_by: _,
} => { } => {
// Update the forced user's avatar // 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.skin_layer = avatar.skin_layer.clone();
m.avatar.clothes_layer = avatar.clothes_layer.clone(); m.avatar.clothes_layer = avatar.clothes_layer.clone();
m.avatar.accessories_layer = avatar.accessories_layer.clone(); m.avatar.accessories_layer = avatar.accessories_layer.clone();
m.avatar.emotion_layer = avatar.emotion_layer.clone(); m.avatar.emotion_layer = avatar.emotion_layer.clone();
} }
PostAction::UpdateMembers(members_vec.clone()) PostAction::UpdateMembers(state.members.clone())
} }
ServerMessage::AvatarCleared { ServerMessage::AvatarCleared {
user_id, user_id,
@ -727,64 +733,54 @@ fn handle_server_message(
cleared_by: _, cleared_by: _,
} => { } => {
// Restore the user's original avatar // 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.skin_layer = avatar.skin_layer.clone();
m.avatar.clothes_layer = avatar.clothes_layer.clone(); m.avatar.clothes_layer = avatar.clothes_layer.clone();
m.avatar.accessories_layer = avatar.accessories_layer.clone(); m.avatar.accessories_layer = avatar.accessories_layer.clone();
m.avatar.emotion_layer = avatar.emotion_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 { match action {
PostAction::None => {} PostAction::None => {}
PostAction::UpdateMembers(members) => { PostAction::UpdateMembers(members) => {
on_update.run(members); on_event.run(WsEvent::MembersUpdated(members));
} }
PostAction::UpdateMembersAndFade(members, fading) => { PostAction::UpdateMembersAndFade(members, fading) => {
on_update.run(members); on_event.run(WsEvent::MembersUpdated(members));
on_member_fading.run(fading); on_event.run(WsEvent::MemberFading(fading));
} }
PostAction::UpdateMembersAndIdentity(members, info) => { PostAction::UpdateMembersAndIdentity(members, info) => {
on_update.run(members); on_event.run(WsEvent::MembersUpdated(members));
if let Some(callback) = on_member_identity_updated { on_event.run(WsEvent::MemberIdentityUpdated(info));
callback.run(info);
}
} }
PostAction::ChatMessage(msg) => { PostAction::ChatMessage(msg) => {
on_chat_message.run(msg); on_event.run(WsEvent::ChatMessage(msg));
} }
PostAction::LoosePropsSync(props) => { PostAction::LoosePropsSync(props) => {
on_loose_props_sync.run(props); on_event.run(WsEvent::LoosePropsSync(props));
} }
PostAction::PropDropped(prop) => { PostAction::PropDropped(prop) => {
on_prop_dropped.run(prop); on_event.run(WsEvent::PropDropped(prop));
} }
PostAction::PropPickedUp(prop_id) => { PostAction::PropPickedUp(prop_id) => {
on_prop_picked_up.run(prop_id); on_event.run(WsEvent::PropPickedUp(prop_id));
} }
PostAction::Error(err) => { PostAction::Error(err) => {
if let Some(callback) = on_error { on_event.run(WsEvent::Error(err));
callback.run(err);
}
} }
PostAction::TeleportApproved(info) => { PostAction::TeleportApproved(info) => {
if let Some(callback) = on_teleport_approved { on_event.run(WsEvent::TeleportApproved(info));
callback.run(info);
}
} }
PostAction::Summoned(info) => { PostAction::Summoned(info) => {
if let Some(callback) = on_summoned { on_event.run(WsEvent::Summoned(info));
callback.run(info);
}
} }
PostAction::ModCommandResult(info) => { PostAction::ModCommandResult(info) => {
if let Some(callback) = on_mod_command_result { on_event.run(WsEvent::ModCommandResult(info));
callback.run(info);
}
} }
} }
} }
@ -795,18 +791,7 @@ pub fn use_channel_websocket(
_realm_slug: Signal<String>, _realm_slug: Signal<String>,
_channel_id: Signal<Option<uuid::Uuid>>, _channel_id: Signal<Option<uuid::Uuid>>,
_reconnect_trigger: RwSignal<u32>, _reconnect_trigger: RwSignal<u32>,
_on_members_update: Callback<Vec<ChannelMemberWithAvatar>>, _on_event: Callback<WsEvent>,
_on_chat_message: Callback<ChatMessage>,
_on_loose_props_sync: Callback<Vec<LooseProp>>,
_on_prop_dropped: Callback<LooseProp>,
_on_prop_picked_up: Callback<uuid::Uuid>,
_on_member_fading: Callback<FadingMember>,
_on_welcome: Option<Callback<ChannelMemberInfo>>,
_on_error: Option<Callback<WsError>>,
_on_teleport_approved: Option<Callback<TeleportInfo>>,
_on_summoned: Option<Callback<SummonInfo>>,
_on_mod_command_result: Option<Callback<ModCommandResultInfo>>,
_on_member_identity_updated: Option<Callback<MemberIdentityInfo>>,
) -> (Signal<WsState>, WsSenderStorage, WsCloserStorage) { ) -> (Signal<WsState>, WsSenderStorage, WsCloserStorage) {
let (ws_state, _) = signal(WsState::Disconnected); let (ws_state, _) = signal(WsState::Disconnected);
let sender: WsSenderStorage = StoredValue::new_local(None); let sender: WsSenderStorage = StoredValue::new_local(None);

View file

@ -19,8 +19,7 @@ use crate::components::{
}; };
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
use crate::components::{ use crate::components::{
ChannelMemberInfo, ChatMessage, DEFAULT_BUBBLE_TIMEOUT_MS, FADE_DURATION_MS, ChatMessage, DEFAULT_BUBBLE_TIMEOUT_MS, FADE_DURATION_MS, WsEvent,
MemberIdentityInfo, ModCommandResultInfo, SummonInfo, TeleportInfo, WsError,
add_whisper_to_history, use_channel_websocket, add_whisper_to_history, use_channel_websocket,
}; };
use crate::utils::LocalStoragePersist; use crate::utils::LocalStoragePersist;
@ -238,9 +237,65 @@ 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")] #[cfg(feature = "hydrate")]
let on_members_update = Callback::new(move |new_members: Vec<ChannelMemberWithAvatar>| { 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();
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::<Scene>().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));
}
}
}
set_channel_id.set(Some(scene_id));
set_members.set(Vec::new());
reconnect_trigger.update(|t| *t += 1);
});
}
};
// Consolidated WebSocket event handler
#[cfg(feature = "hydrate")]
let navigate_to_scene_for_event = navigate_to_scene.clone();
#[cfg(feature = "hydrate")]
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 // When members are updated (including rejoins), remove any matching fading members
set_fading_members.update(|fading| { set_fading_members.update(|fading| {
fading.retain(|f| { fading.retain(|f| {
@ -250,11 +305,8 @@ pub fn RealmPage() -> impl IntoView {
}); });
}); });
set_members.set(new_members); set_members.set(new_members);
}); }
WsEvent::ChatMessage(msg) => {
// Chat message callback
#[cfg(feature = "hydrate")]
let on_chat_message = Callback::new(move |msg: ChatMessage| {
// Add to message log // Add to message log
message_log.update_value(|log| log.push(msg.clone())); message_log.update_value(|log| log.push(msg.clone()));
@ -303,31 +355,21 @@ pub fn RealmPage() -> impl IntoView {
); );
}); });
} }
}); }
WsEvent::LoosePropsSync(props) => {
// Loose props callbacks
#[cfg(feature = "hydrate")]
let on_loose_props_sync = Callback::new(move |props: Vec<LooseProp>| {
set_loose_props.set(props); set_loose_props.set(props);
}); }
WsEvent::PropDropped(prop) => {
#[cfg(feature = "hydrate")]
let on_prop_dropped = Callback::new(move |prop: LooseProp| {
set_loose_props.update(|props| { set_loose_props.update(|props| {
props.push(prop); props.push(prop);
}); });
}); }
WsEvent::PropPickedUp(prop_id) => {
#[cfg(feature = "hydrate")]
let on_prop_picked_up = Callback::new(move |prop_id: Uuid| {
set_loose_props.update(|props| { set_loose_props.update(|props| {
props.retain(|p| p.id != prop_id); props.retain(|p| p.id != prop_id);
}); });
}); }
WsEvent::MemberFading(fading) => {
// 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| { set_fading_members.update(|members| {
// Remove any existing entry for this user (shouldn't happen, but be safe) // Remove any existing entry for this user (shouldn't happen, but be safe)
members.retain(|m| { members.retain(|m| {
@ -335,19 +377,13 @@ pub fn RealmPage() -> impl IntoView {
}); });
members.push(fading); members.push(fading);
}); });
}); }
WsEvent::Welcome(info) => {
// 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_user_id.set(Some(info.user_id));
set_current_display_name.set(info.display_name.clone()); set_current_display_name.set(info.display_name.clone());
set_is_guest.set(info.is_guest); set_is_guest.set(info.is_guest);
}); }
WsEvent::Error(error) => {
// Callback for WebSocket errors (whisper failures, etc.)
#[cfg(feature = "hydrate")]
let on_ws_error = Callback::new(move |error: WsError| {
// Display user-friendly error message // Display user-friendly error message
let msg = match error.code.as_str() { let msg = match error.code.as_str() {
"WHISPER_TARGET_NOT_FOUND" => error.message, "WHISPER_TARGET_NOT_FOUND" => error.message,
@ -357,20 +393,15 @@ pub fn RealmPage() -> impl IntoView {
}; };
set_error_message.set(Some(msg)); set_error_message.set(Some(msg));
// Auto-dismiss after 5 seconds // Auto-dismiss after 5 seconds
use gloo_timers::callback::Timeout; gloo_timers::callback::Timeout::new(5000, move || {
Timeout::new(5000, move || {
set_error_message.set(None); set_error_message.set(None);
}) })
.forget(); .forget();
}); }
WsEvent::TeleportApproved(info) => {
// 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 { let teleport_msg = ChatMessage {
message_id: Uuid::new_v4(), message_id: Uuid::new_v4(),
user_id: Uuid::nil(), // System message user_id: Uuid::nil(),
display_name: "[SYSTEM]".to_string(), display_name: "[SYSTEM]".to_string(),
content: format!("Teleported to scene: {}", info.scene_slug), content: format!("Teleported to scene: {}", info.scene_slug),
emotion: "neutral".to_string(), emotion: "neutral".to_string(),
@ -382,74 +413,12 @@ pub fn RealmPage() -> impl IntoView {
is_system: true, is_system: true,
}; };
message_log.update_value(|log| log.push(teleport_msg)); message_log.update_value(|log| log.push(teleport_msg));
navigate_to_scene_for_event(info.scene_id, info.scene_slug);
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::<Scene>().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));
} }
WsEvent::Summoned(info) => {
// 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));
}
}
}
// 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 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 { let summon_msg = ChatMessage {
message_id: Uuid::new_v4(), message_id: Uuid::new_v4(),
user_id: Uuid::nil(), // System/mod message user_id: Uuid::nil(),
display_name: "[MOD]".to_string(), display_name: "[MOD]".to_string(),
content: format!("Summoned by {} to scene: {}", info.summoned_by, info.scene_slug), content: format!("Summoned by {} to scene: {}", info.summoned_by, info.scene_slug),
emotion: "neutral".to_string(), emotion: "neutral".to_string(),
@ -462,78 +431,15 @@ pub fn RealmPage() -> impl IntoView {
}; };
message_log.update_value(|log| log.push(summon_msg)); message_log.update_value(|log| log.push(summon_msg));
// Show notification
set_mod_notification.set(Some((true, format!("Summoned by {}", info.summoned_by)))); set_mod_notification.set(Some((true, format!("Summoned by {}", info.summoned_by))));
gloo_timers::callback::Timeout::new(3000, move || {
// Auto-dismiss notification after 3 seconds
let timeout = gloo_timers::callback::Timeout::new(3000, move || {
set_mod_notification.set(None); set_mod_notification.set(None);
}); })
timeout.forget(); .forget();
let scene_id = info.scene_id; navigate_to_scene_for_event(info.scene_id, info.scene_slug);
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::<Scene>().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));
} }
WsEvent::ModCommandResult(info) => {
// 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));
}
}
}
// 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 // Log mod command result to message log
let status = if info.success { "OK" } else { "FAILED" }; let status = if info.success { "OK" } else { "FAILED" };
let mod_msg = ChatMessage { let mod_msg = ChatMessage {
@ -554,15 +460,12 @@ pub fn RealmPage() -> impl IntoView {
set_mod_notification.set(Some((info.success, info.message))); set_mod_notification.set(Some((info.success, info.message)));
// Auto-dismiss notification after 3 seconds // Auto-dismiss notification after 3 seconds
let timeout = gloo_timers::callback::Timeout::new(3000, move || { gloo_timers::callback::Timeout::new(3000, move || {
set_mod_notification.set(None); set_mod_notification.set(None);
}); })
timeout.forget(); .forget();
}); }
WsEvent::MemberIdentityUpdated(info) => {
// 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 // Update the member's display name in the members list
set_members.update(|members| { set_members.update(|members| {
if let Some(member) = members if let Some(member) = members
@ -572,6 +475,8 @@ pub fn RealmPage() -> impl IntoView {
member.member.display_name = info.display_name.clone(); member.member.display_name = info.display_name.clone();
} }
}); });
}
}
}); });
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
@ -579,18 +484,7 @@ pub fn RealmPage() -> impl IntoView {
slug, slug,
Signal::derive(move || channel_id.get()), Signal::derive(move || channel_id.get()),
reconnect_trigger, reconnect_trigger,
on_members_update, on_ws_event,
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),
); );
// Set channel ID, current scene, and scene dimensions when entry scene loads // 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)); Rc::new(RefCell::new(None));
let keyup_closure_holder_clone = keyup_closure_holder.clone(); 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<Option<js_sys::Function>, LocalStorage> = StoredValue::new_local(None);
let keyup_fn: StoredValue<Option<js_sys::Function>, LocalStorage> = StoredValue::new_local(None);
Effect::new(move |_| { Effect::new(move |_| {
// Cleanup previous keydown closure if any // Cleanup previous keydown closure if any
if let Some(old_closure) = closure_holder_clone.borrow_mut().take() { 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 // Store the keyup closure for cleanup
*keyup_closure_holder_clone.borrow_mut() = Some(keyup_closure); *keyup_closure_holder_clone.borrow_mut() = Some(keyup_closure);
});
// Cleanup event listeners when component unmounts // Extract and store js_sys::Function references for cleanup
// We need to store JS function references for cleanup since Rc isn't Send+Sync // (must be done inside Effect since Rc<RefCell<>> isn't Send+Sync for on_cleanup)
let keydown_fn: StoredValue<Option<js_sys::Function>, LocalStorage> = StoredValue::new_local(None); if let Some(ref closure) = *closure_holder_clone.borrow() {
let keyup_fn: StoredValue<Option<js_sys::Function>, 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(); let func: &js_sys::Function = closure.as_ref().unchecked_ref();
keydown_fn.set_value(Some(func.clone())); keydown_fn.set_value(Some(func.clone()));
} }
if let Some(ref closure) = *keyup_closure_holder.borrow() { if let Some(ref closure) = *keyup_closure_holder_clone.borrow() {
let func: &js_sys::Function = closure.as_ref().unchecked_ref(); let func: &js_sys::Function = closure.as_ref().unchecked_ref();
keyup_fn.set_value(Some(func.clone())); keyup_fn.set_value(Some(func.clone()));
} }
}
}); });
// Cleanup event listeners when component unmounts
on_cleanup(move || { on_cleanup(move || {
if let Some(window) = web_sys::window() { if let Some(window) = web_sys::window() {
keydown_fn.with_value(|func| { 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 - explicitly close WebSocket before calling logout API
// Create logout callback - close WebSocket and call logout API
let on_logout = Callback::new(move |_: ()| { let on_logout = Callback::new(move |_: ()| {
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
{ {
use gloo_net::http::Request; use gloo_net::http::Request;
use gloo_timers::callback::Timeout;
let navigate = navigate.clone(); 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| { ws_close.with_value(|closer| {
if let Some(close_fn) = closer { if let Some(close_fn) = closer {
close_fn(close_codes::LOGOUT, "logout".to_string()); close_fn(close_codes::LOGOUT, "logout".to_string());
} }
}); });
// 2. Small delay to ensure close message is sent, then call logout API // Call logout API immediately - session invalidation doesn't depend on WS close completing
Timeout::new(100, move || {
spawn_local(async move { spawn_local(async move {
// 3. Call logout API
let _: Result<gloo_net::http::Response, gloo_net::Error> = let _: Result<gloo_net::http::Response, gloo_net::Error> =
Request::post("/api/auth/logout").send().await; Request::post("/api/auth/logout").send().await;
// 4. Navigate to home
navigate("/", Default::default()); navigate("/", Default::default());
}); });
})
.forget();
} }
}); });