fix: websocket cleanup
This commit is contained in:
parent
60a6680eaf
commit
6192875d0d
2 changed files with 396 additions and 528 deletions
|
|
@ -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<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.
|
||||
///
|
||||
/// Returns a tuple of:
|
||||
|
|
@ -135,18 +189,7 @@ pub fn use_channel_websocket(
|
|||
realm_slug: Signal<String>,
|
||||
channel_id: Signal<Option<uuid::Uuid>>,
|
||||
reconnect_trigger: RwSignal<u32>,
|
||||
on_members_update: Callback<Vec<ChannelMemberWithAvatar>>,
|
||||
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>>,
|
||||
on_event: Callback<WsEvent>,
|
||||
) -> (Signal<WsState>, 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<RefCell<Option<WebSocket>>> = Rc::new(RefCell::new(None));
|
||||
let members: Rc<RefCell<Vec<ChannelMemberWithAvatar>>> = Rc::new(RefCell::new(Vec::new()));
|
||||
// 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));
|
||||
|
||||
let state: Rc<RefCell<WsInternalState>> = Rc::new(RefCell::new(WsInternalState::default()));
|
||||
// Flag to prevent accessing disposed reactive values after component unmount
|
||||
let is_disposed: Arc<AtomicBool> = 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,16 +240,21 @@ pub fn use_channel_websocket(
|
|||
let _trigger = reconnect_trigger.get();
|
||||
|
||||
// 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)]
|
||||
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;
|
||||
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 {
|
||||
set_ws_state.set(WsState::Disconnected);
|
||||
|
|
@ -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<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 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);
|
||||
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<dyn FnMut(MessageEvent)>);
|
||||
|
|
@ -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() {
|
||||
let mut state = state_for_close.borrow_mut();
|
||||
// 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)]
|
||||
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<std::cell::RefCell<Vec<ChannelMemberWithAvatar>>>,
|
||||
on_update: &Callback<Vec<ChannelMemberWithAvatar>>,
|
||||
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>>>,
|
||||
state: &std::rc::Rc<std::cell::RefCell<WsInternalState>>,
|
||||
on_event: &Callback<WsEvent>,
|
||||
) {
|
||||
// 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<ChannelMemberWithAvatar>),
|
||||
|
|
@ -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<String>,
|
||||
_channel_id: Signal<Option<uuid::Uuid>>,
|
||||
_reconnect_trigger: RwSignal<u32>,
|
||||
_on_members_update: Callback<Vec<ChannelMemberWithAvatar>>,
|
||||
_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>>,
|
||||
_on_event: Callback<WsEvent>,
|
||||
) -> (Signal<WsState>, WsSenderStorage, WsCloserStorage) {
|
||||
let (ws_state, _) = signal(WsState::Disconnected);
|
||||
let sender: WsSenderStorage = StoredValue::new_local(None);
|
||||
|
|
|
|||
|
|
@ -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,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")]
|
||||
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
|
||||
set_fading_members.update(|fading| {
|
||||
fading.retain(|f| {
|
||||
|
|
@ -250,11 +305,8 @@ pub fn RealmPage() -> impl IntoView {
|
|||
});
|
||||
});
|
||||
set_members.set(new_members);
|
||||
});
|
||||
|
||||
// Chat message callback
|
||||
#[cfg(feature = "hydrate")]
|
||||
let on_chat_message = Callback::new(move |msg: ChatMessage| {
|
||||
}
|
||||
WsEvent::ChatMessage(msg) => {
|
||||
// Add to message log
|
||||
message_log.update_value(|log| log.push(msg.clone()));
|
||||
|
||||
|
|
@ -303,31 +355,21 @@ pub fn RealmPage() -> impl IntoView {
|
|||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Loose props callbacks
|
||||
#[cfg(feature = "hydrate")]
|
||||
let on_loose_props_sync = Callback::new(move |props: Vec<LooseProp>| {
|
||||
}
|
||||
WsEvent::LoosePropsSync(props) => {
|
||||
set_loose_props.set(props);
|
||||
});
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
let on_prop_dropped = Callback::new(move |prop: LooseProp| {
|
||||
}
|
||||
WsEvent::PropDropped(prop) => {
|
||||
set_loose_props.update(|props| {
|
||||
props.push(prop);
|
||||
});
|
||||
});
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
let on_prop_picked_up = Callback::new(move |prop_id: Uuid| {
|
||||
}
|
||||
WsEvent::PropPickedUp(prop_id) => {
|
||||
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| {
|
||||
}
|
||||
WsEvent::MemberFading(fading) => {
|
||||
set_fading_members.update(|members| {
|
||||
// Remove any existing entry for this user (shouldn't happen, but be safe)
|
||||
members.retain(|m| {
|
||||
|
|
@ -335,19 +377,13 @@ pub fn RealmPage() -> impl IntoView {
|
|||
});
|
||||
members.push(fading);
|
||||
});
|
||||
});
|
||||
|
||||
// Callback to capture current user identity from Welcome message
|
||||
#[cfg(feature = "hydrate")]
|
||||
let on_welcome = Callback::new(move |info: ChannelMemberInfo| {
|
||||
}
|
||||
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);
|
||||
});
|
||||
|
||||
// Callback for WebSocket errors (whisper failures, etc.)
|
||||
#[cfg(feature = "hydrate")]
|
||||
let on_ws_error = Callback::new(move |error: WsError| {
|
||||
}
|
||||
WsEvent::Error(error) => {
|
||||
// Display user-friendly error message
|
||||
let msg = match error.code.as_str() {
|
||||
"WHISPER_TARGET_NOT_FOUND" => error.message,
|
||||
|
|
@ -357,20 +393,15 @@ pub fn RealmPage() -> impl IntoView {
|
|||
};
|
||||
set_error_message.set(Some(msg));
|
||||
// Auto-dismiss after 5 seconds
|
||||
use gloo_timers::callback::Timeout;
|
||||
Timeout::new(5000, move || {
|
||||
gloo_timers::callback::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
|
||||
}
|
||||
WsEvent::TeleportApproved(info) => {
|
||||
let teleport_msg = ChatMessage {
|
||||
message_id: Uuid::new_v4(),
|
||||
user_id: Uuid::nil(), // System message
|
||||
user_id: Uuid::nil(),
|
||||
display_name: "[SYSTEM]".to_string(),
|
||||
content: format!("Teleported to scene: {}", info.scene_slug),
|
||||
emotion: "neutral".to_string(),
|
||||
|
|
@ -382,74 +413,12 @@ pub fn RealmPage() -> impl IntoView {
|
|||
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::<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));
|
||||
navigate_to_scene_for_event(info.scene_id, info.scene_slug);
|
||||
}
|
||||
|
||||
// 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
|
||||
WsEvent::Summoned(info) => {
|
||||
let summon_msg = ChatMessage {
|
||||
message_id: Uuid::new_v4(),
|
||||
user_id: Uuid::nil(), // System/mod message
|
||||
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(),
|
||||
|
|
@ -462,78 +431,15 @@ pub fn RealmPage() -> impl IntoView {
|
|||
};
|
||||
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 || {
|
||||
gloo_timers::callback::Timeout::new(3000, move || {
|
||||
set_mod_notification.set(None);
|
||||
});
|
||||
timeout.forget();
|
||||
})
|
||||
.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::<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));
|
||||
navigate_to_scene_for_event(info.scene_id, info.scene_slug);
|
||||
}
|
||||
|
||||
// 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| {
|
||||
WsEvent::ModCommandResult(info) => {
|
||||
// Log mod command result to message log
|
||||
let status = if info.success { "OK" } else { "FAILED" };
|
||||
let mod_msg = ChatMessage {
|
||||
|
|
@ -554,15 +460,12 @@ pub fn RealmPage() -> impl IntoView {
|
|||
set_mod_notification.set(Some((info.success, info.message)));
|
||||
|
||||
// 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);
|
||||
});
|
||||
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| {
|
||||
})
|
||||
.forget();
|
||||
}
|
||||
WsEvent::MemberIdentityUpdated(info) => {
|
||||
// Update the member's display name in the members list
|
||||
set_members.update(|members| {
|
||||
if let Some(member) = members
|
||||
|
|
@ -572,6 +475,8 @@ pub fn RealmPage() -> impl IntoView {
|
|||
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<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 |_| {
|
||||
// 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<Option<js_sys::Function>, LocalStorage> = StoredValue::new_local(None);
|
||||
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() {
|
||||
// Extract and store js_sys::Function references for cleanup
|
||||
// (must be done inside Effect since Rc<RefCell<>> 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.borrow() {
|
||||
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 || {
|
||||
// Call logout API immediately - session invalidation doesn't depend on WS close completing
|
||||
spawn_local(async move {
|
||||
// 3. Call logout API
|
||||
let _: Result<gloo_net::http::Response, gloo_net::Error> =
|
||||
Request::post("/api/auth/logout").send().await;
|
||||
|
||||
// 4. Navigate to home
|
||||
navigate("/", Default::default());
|
||||
});
|
||||
})
|
||||
.forget();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue