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,
|
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,
|
|
||||||
¤t_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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue