chattyness/crates/chattyness-user-ui/src/components/ws_client.rs

603 lines
24 KiB
Rust

//! WebSocket client for channel presence.
//!
//! Provides a Leptos hook to manage WebSocket connections for real-time
//! position updates, emotion changes, and member synchronization.
use leptos::prelude::*;
use leptos::reactive::owner::LocalStorage;
#[cfg(feature = "hydrate")]
use chattyness_db::models::EmotionState;
use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp};
use chattyness_db::ws_messages::{close_codes, ClientMessage};
#[cfg(feature = "hydrate")]
use chattyness_db::ws_messages::{DisconnectReason, ServerMessage};
use super::chat_types::ChatMessage;
/// Duration for fade-out animation in milliseconds.
pub const FADE_DURATION_MS: i64 = 5000;
/// Maximum number of silent reconnection attempts before showing overlay.
pub const MAX_SILENT_RECONNECT_ATTEMPTS: u8 = 3;
/// Delay between silent reconnection attempts in milliseconds.
pub const SILENT_RECONNECT_DELAY_MS: u32 = 1000;
/// A member that is currently fading out after a timeout disconnect.
#[derive(Clone, Debug)]
pub struct FadingMember {
/// The member data.
pub member: ChannelMemberWithAvatar,
/// Timestamp when the fade started (milliseconds since epoch).
pub fade_start: i64,
/// Duration of the fade in milliseconds.
pub fade_duration: i64,
}
/// WebSocket connection state.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum WsState {
/// Attempting to connect.
Connecting,
/// Connected and ready.
Connected,
/// Disconnected (not connected).
Disconnected,
/// Connection error occurred.
Error,
/// Silently attempting to reconnect after server timeout.
/// The u8 is the current attempt number (1-based).
SilentReconnecting(u8),
}
/// Sender function type for WebSocket messages.
pub type WsSender = Box<dyn Fn(ClientMessage)>;
/// Local stored value type for the sender (non-Send, WASM-compatible).
pub type WsSenderStorage = StoredValue<Option<WsSender>, LocalStorage>;
/// Information about the current channel member (received on Welcome).
#[derive(Clone, Debug)]
pub struct ChannelMemberInfo {
/// The user's user_id (if authenticated user).
pub user_id: Option<uuid::Uuid>,
/// The user's guest_session_id (if guest).
pub guest_session_id: Option<uuid::Uuid>,
/// The user's display name.
pub display_name: String,
/// Whether this user is a guest (has the 'guest' tag).
pub is_guest: bool,
}
/// WebSocket error info for UI display.
#[derive(Clone, Debug)]
pub struct WsError {
/// Error code from server.
pub code: String,
/// Human-readable error message.
pub message: String,
}
/// Teleport information received from server.
#[derive(Clone, Debug)]
pub struct TeleportInfo {
/// Scene ID to teleport to.
pub scene_id: uuid::Uuid,
/// Scene slug for URL.
pub scene_slug: String,
}
/// Hook to manage WebSocket connection for a channel.
///
/// Returns a tuple of:
/// - `Signal<WsState>` - The current connection state
/// - `WsSenderStorage` - A stored sender function to send messages
#[cfg(feature = "hydrate")]
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>>,
) -> (Signal<WsState>, WsSenderStorage) {
use std::cell::RefCell;
use std::rc::Rc;
use wasm_bindgen::{JsCast, closure::Closure};
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));
// Create a stored sender function (using new_local for WASM single-threaded environment)
let ws_ref_for_send = ws_ref.clone();
let sender: WsSenderStorage =
StoredValue::new_local(Some(Box::new(move |msg: ClientMessage| {
if let Some(ws) = ws_ref_for_send.borrow().as_ref() {
if ws.ready_state() == WebSocket::OPEN {
if let Ok(json) = serde_json::to_string(&msg) {
#[cfg(debug_assertions)]
web_sys::console::log_1(&format!("[WS->Server] {}", json).into());
let _ = ws.send_with_str(&json);
}
}
}
})));
// 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();
Effect::new(move |_| {
let slug = realm_slug.get();
let ch_id = channel_id.get();
// Track reconnect_trigger to force reconnection when it changes
let _trigger = reconnect_trigger.get();
// Cleanup previous connection
if let Some(old_ws) = ws_ref_clone.borrow_mut().take() {
#[cfg(debug_assertions)]
web_sys::console::log_1(
&format!("[WS] Closing old connection, readyState={}", old_ws.ready_state()).into(),
);
// Set flag BEFORE closing - guarantees local state even if close code doesn't arrive
*is_intentional_close_for_cleanup.borrow_mut() = true;
// Close with SCENE_CHANGE code so onclose handler knows this was intentional
let _ = old_ws.close_with_code_and_reason(close_codes::SCENE_CHANGE, "scene change");
}
let Some(ch_id) = ch_id else {
set_ws_state.set(WsState::Disconnected);
return;
};
if slug.is_empty() {
set_ws_state.set(WsState::Disconnected);
return;
}
// Construct WebSocket URL
let window = web_sys::window().unwrap();
let location = window.location();
let protocol = if location.protocol().unwrap_or_default() == "https:" {
"wss:"
} else {
"ws:"
};
let host = location.host().unwrap_or_default();
let url = format!(
"{}//{}/api/realms/{}/channels/{}/ws",
protocol, host, slug, ch_id
);
#[cfg(debug_assertions)]
web_sys::console::log_1(&format!("[WS] Connecting to: {}", url).into());
set_ws_state.set(WsState::Connecting);
let ws = match WebSocket::new(&url) {
Ok(ws) => ws,
Err(e) => {
#[cfg(debug_assertions)]
web_sys::console::error_1(&format!("[WS] Failed to create: {:?}", e).into());
set_ws_state.set(WsState::Error);
return;
}
};
// onopen
let set_ws_state_open = set_ws_state;
let onopen = Closure::wrap(Box::new(move |_: web_sys::Event| {
#[cfg(debug_assertions)]
web_sys::console::log_1(&"[WS] Connected".into());
set_ws_state_open.set(WsState::Connected);
}) as Box<dyn FnMut(web_sys::Event)>);
ws.set_onopen(Some(onopen.as_ref().unchecked_ref()));
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();
// 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();
let onmessage = Closure::wrap(Box::new(move |e: MessageEvent| {
if let Ok(text) = e.data().dyn_into::<js_sys::JsString>() {
let text: String = text.into();
#[cfg(debug_assertions)]
web_sys::console::log_1(&format!("[WS<-Server] {}", text).into());
if let Ok(msg) = serde_json::from_str::<ServerMessage>(&text) {
// Check for Welcome message to start heartbeat with server-provided config
if let ServerMessage::Welcome {
ref config,
ref member,
..
} = msg
{
// Track current user ID for MemberLeft filtering
*current_user_id_for_msg.borrow_mut() = member.user_id;
if !*heartbeat_started_clone.borrow() {
*heartbeat_started_clone.borrow_mut() = true;
let ping_interval_ms = config.ping_interval_secs * 1000;
let ws_ref_ping = ws_ref_for_heartbeat.clone();
#[cfg(debug_assertions)]
web_sys::console::log_1(
&format!(
"[WS] Starting heartbeat with interval {}ms",
ping_interval_ms
)
.into(),
);
let heartbeat = gloo_timers::callback::Interval::new(
ping_interval_ms as u32,
move || {
if let Some(ws) = ws_ref_ping.borrow().as_ref() {
if ws.ready_state() == WebSocket::OPEN {
if let Ok(json) =
serde_json::to_string(&ClientMessage::Ping)
{
let _ = ws.send_with_str(&json);
}
}
}
},
);
std::mem::forget(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,
guest_session_id: member.guest_session_id,
display_name: member.display_name.clone(),
is_guest: member.is_guest,
};
callback.run(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,
&current_user_id_for_msg,
);
}
}
}) as Box<dyn FnMut(MessageEvent)>);
ws.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
onmessage.forget();
// onerror
let set_ws_state_err = set_ws_state;
let ws_state_for_err = ws_state;
let reconnect_trigger_for_error = reconnect_trigger;
let onerror = Closure::wrap(Box::new(move |e: ErrorEvent| {
#[cfg(debug_assertions)]
web_sys::console::error_1(&format!("[WS] Error: {:?}", e.message()).into());
// Check if we're in silent reconnection mode
let current_state = ws_state_for_err.get_untracked();
if let WsState::SilentReconnecting(attempt) = current_state {
if attempt < MAX_SILENT_RECONNECT_ATTEMPTS {
// Try another silent reconnection
let next_attempt = attempt + 1;
#[cfg(debug_assertions)]
web_sys::console::log_1(
&format!(
"[WS] Silent reconnection attempt {} failed, trying attempt {}",
attempt, next_attempt
)
.into(),
);
set_ws_state_err.set(WsState::SilentReconnecting(next_attempt));
// Schedule next reconnection attempt
let reconnect_trigger = reconnect_trigger_for_error;
gloo_timers::callback::Timeout::new(SILENT_RECONNECT_DELAY_MS, move || {
reconnect_trigger.update(|v| *v = v.wrapping_add(1));
})
.forget();
} else {
// Max attempts reached, fall back to showing overlay
#[cfg(debug_assertions)]
web_sys::console::log_1(
&"[WS] Silent reconnection failed, showing reconnection overlay".into(),
);
set_ws_state_err.set(WsState::Error);
}
} else {
set_ws_state_err.set(WsState::Error);
}
}) as Box<dyn FnMut(ErrorEvent)>);
ws.set_onerror(Some(onerror.as_ref().unchecked_ref()));
onerror.forget();
// onclose
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 onclose = Closure::wrap(Box::new(move |e: CloseEvent| {
let code = e.code();
#[cfg(debug_assertions)]
web_sys::console::log_1(
&format!("[WS] Closed: code={}, reason={}", code, e.reason()).into(),
);
// Handle based on close code with defense-in-depth using flag
if code == close_codes::SERVER_TIMEOUT {
// Server timeout - attempt silent reconnection (highest priority)
#[cfg(debug_assertions)]
web_sys::console::log_1(&"[WS] Server timeout, attempting silent reconnection".into());
set_ws_state_close.set(WsState::SilentReconnecting(1));
// Schedule reconnection after delay
let reconnect_trigger = reconnect_trigger_for_close;
gloo_timers::callback::Timeout::new(SILENT_RECONNECT_DELAY_MS, move || {
reconnect_trigger.update(|v| *v = v.wrapping_add(1));
})
.forget();
} else if code == close_codes::SCENE_CHANGE || *is_intentional_close_for_onclose.borrow() {
// Intentional close (scene change/teleport) - 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;
} else {
// Other close codes - treat as disconnection
set_ws_state_close.set(WsState::Disconnected);
}
}) as Box<dyn FnMut(CloseEvent)>);
ws.set_onclose(Some(onclose.as_ref().unchecked_ref()));
onclose.forget();
*ws_ref_clone.borrow_mut() = Some(ws);
});
(Signal::derive(move || ws_state.get()), sender)
}
/// Handle a message received from the server.
#[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>>,
current_user_id: &std::rc::Rc<std::cell::RefCell<Option<uuid::Uuid>>>,
) {
let mut members_vec = members.borrow_mut();
match msg {
ServerMessage::Welcome {
member: _,
members: initial_members,
config: _, // Config is handled in the caller for heartbeat setup
} => {
*members_vec = initial_members;
on_update.run(members_vec.clone());
}
ServerMessage::MemberJoined { member } => {
// Remove if exists (rejoin case), then add
members_vec.retain(|m| {
m.member.user_id != member.member.user_id
|| m.member.guest_session_id != member.member.guest_session_id
});
members_vec.push(member);
on_update.run(members_vec.clone());
}
ServerMessage::MemberLeft {
user_id,
guest_session_id,
reason,
} => {
// Check if this is our own MemberLeft due to timeout - ignore it during reconnection
// so we don't see our own avatar fade out
let own_user_id = *current_user_id.borrow();
let is_self = own_user_id.is_some() && user_id == own_user_id;
if is_self && reason == DisconnectReason::Timeout {
#[cfg(debug_assertions)]
web_sys::console::log_1(
&"[WS] Ignoring self MemberLeft during reconnection".into(),
);
return;
}
// Find the member before removing
let leaving_member = members_vec
.iter()
.find(|m| {
m.member.user_id == user_id && m.member.guest_session_id == guest_session_id
})
.cloned();
// Always remove from active members list
members_vec.retain(|m| {
m.member.user_id != user_id || m.member.guest_session_id != guest_session_id
});
on_update.run(members_vec.clone());
// For timeout disconnects, trigger fading animation
if reason == DisconnectReason::Timeout {
if let Some(member) = leaving_member {
let fading = FadingMember {
member,
fade_start: js_sys::Date::now() as i64,
fade_duration: FADE_DURATION_MS,
};
on_member_fading.run(fading);
}
}
}
ServerMessage::PositionUpdated {
user_id,
guest_session_id,
x,
y,
} => {
if let Some(m) = members_vec.iter_mut().find(|m| {
m.member.user_id == user_id && m.member.guest_session_id == guest_session_id
}) {
m.member.position_x = x;
m.member.position_y = y;
}
on_update.run(members_vec.clone());
}
ServerMessage::EmotionUpdated {
user_id,
guest_session_id,
emotion,
emotion_layer,
} => {
if let Some(m) = members_vec.iter_mut().find(|m| {
m.member.user_id == user_id && m.member.guest_session_id == guest_session_id
}) {
// Convert emotion name to index for internal state
m.member.current_emotion = emotion
.parse::<EmotionState>()
.map(|e| e.to_index() as i16)
.unwrap_or(0);
m.avatar.emotion_layer = emotion_layer;
}
on_update.run(members_vec.clone());
}
ServerMessage::Pong => {
// Heartbeat acknowledged - nothing to do
}
ServerMessage::Error { code, message } => {
// Always log errors to console (not just debug mode)
web_sys::console::error_1(&format!("[WS] Server error: {} - {}", code, message).into());
// Call error callback if provided
if let Some(callback) = on_error {
callback.run(WsError { code, message });
}
}
ServerMessage::ChatMessageReceived {
message_id,
user_id,
guest_session_id,
display_name,
content,
emotion,
x,
y,
timestamp,
is_whisper,
is_same_scene,
} => {
let chat_msg = ChatMessage {
message_id,
user_id,
guest_session_id,
display_name,
content,
emotion,
x,
y,
timestamp,
is_whisper,
is_same_scene,
};
on_chat_message.run(chat_msg);
}
ServerMessage::LoosePropsSync { props } => {
on_loose_props_sync.run(props);
}
ServerMessage::PropDropped { prop } => {
on_prop_dropped.run(prop);
}
ServerMessage::PropPickedUp { prop_id, .. } => {
on_prop_picked_up.run(prop_id);
}
ServerMessage::PropExpired { prop_id } => {
// Treat expired props the same as picked up (remove from display)
on_prop_picked_up.run(prop_id);
}
ServerMessage::AvatarUpdated {
user_id,
guest_session_id,
avatar,
} => {
// Find member and update their avatar layers
if let Some(m) = members_vec.iter_mut().find(|m| {
m.member.user_id == user_id && m.member.guest_session_id == guest_session_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();
}
on_update.run(members_vec.clone());
}
ServerMessage::TeleportApproved {
scene_id,
scene_slug,
} => {
if let Some(callback) = on_teleport_approved {
callback.run(TeleportInfo {
scene_id,
scene_slug,
});
}
}
}
}
/// Stub implementation for SSR (server-side rendering).
#[cfg(not(feature = "hydrate"))]
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>>,
) -> (Signal<WsState>, WsSenderStorage) {
let (ws_state, _) = signal(WsState::Disconnected);
let sender: WsSenderStorage = StoredValue::new_local(None);
(Signal::derive(move || ws_state.get()), sender)
}