update to support user expire, timeout, and disconnect

This commit is contained in:
Evan Carroll 2026-01-17 23:47:02 -06:00
parent fe65835f4a
commit 5fcd49e847
16 changed files with 744 additions and 238 deletions

View file

@ -115,6 +115,9 @@ pub fn AvatarCanvas(
/// Text size multiplier for display names, chat bubbles, and badges.
#[prop(default = 1.0)]
text_em_size: f64,
/// Opacity for fade-out animation (0.0 to 1.0, default 1.0).
#[prop(default = 1.0)]
opacity: f64,
) -> impl IntoView {
let canvas_ref = NodeRef::<leptos::html::Canvas>::new();
@ -171,19 +174,24 @@ pub fn AvatarCanvas(
let adjusted_y = canvas_y - bubble_extra;
// CSS positioning via transform (GPU-accelerated)
// Disable pointer-events when fading (opacity < 1.0) so fading avatars aren't clickable
let pointer_events = if opacity < 1.0 { "none" } else { "auto" };
let style = format!(
"position: absolute; \
left: 0; top: 0; \
transform: translate({}px, {}px); \
z-index: {}; \
pointer-events: auto; \
pointer-events: {}; \
width: {}px; \
height: {}px;",
height: {}px; \
opacity: {};",
canvas_x - (canvas_width - avatar_size) / 2.0,
adjusted_y,
z_index,
pointer_events,
canvas_width,
canvas_height
canvas_height,
opacity
);
// Store references for the effect

View file

@ -20,6 +20,7 @@ use super::chat_types::ActiveBubble;
use super::settings::{
calculate_min_zoom, ViewerSettings, BASE_PROP_SIZE, REFERENCE_HEIGHT, REFERENCE_WIDTH,
};
use super::ws_client::FadingMember;
use crate::utils::parse_bounds_dimensions;
/// Scene viewer component for displaying a realm scene with avatars.
@ -49,6 +50,9 @@ pub fn RealmSceneViewer(
/// Callback for zoom changes (from mouse wheel). Receives zoom delta.
#[prop(optional)]
on_zoom_change: Option<Callback<f64>>,
/// Members that are fading out after timeout disconnect.
#[prop(optional, into)]
fading_members: Option<Signal<Vec<FadingMember>>>,
) -> impl IntoView {
// Use default settings if none provided
let settings = settings.unwrap_or_else(|| Signal::derive(ViewerSettings::default));
@ -774,7 +778,8 @@ pub fn RealmSceneViewer(
let ps = prop_size.get();
let te = text_em_size.get();
sorted_members.get()
// Render active members
let mut views: Vec<_> = sorted_members.get()
.into_iter()
.enumerate()
.map(|(idx, member)| {
@ -795,7 +800,39 @@ pub fn RealmSceneViewer(
/>
}
})
.collect_view()
.collect();
// Render fading members with calculated opacity
if let Some(fading_signal) = fading_members {
#[cfg(feature = "hydrate")]
let now = js_sys::Date::now() as i64;
#[cfg(not(feature = "hydrate"))]
let now = 0i64;
for fading in fading_signal.get() {
let elapsed = now - fading.fade_start;
if elapsed < fading.fade_duration {
let opacity = 1.0 - (elapsed as f64 / fading.fade_duration as f64);
let opacity = opacity.max(0.0).min(1.0);
views.push(view! {
<AvatarCanvas
member=fading.member
scale_x=sx
scale_y=sy
offset_x=ox
offset_y=oy
prop_size=ps
z_index=5
active_bubble=None
text_em_size=te
opacity=opacity
/>
});
}
}
}
views.into_iter().collect_view()
}}
</div>
// Click overlay - captures clicks for movement and hit-testing

View file

@ -6,11 +6,32 @@
use leptos::prelude::*;
use leptos::reactive::owner::LocalStorage;
use chattyness_db::models::{ChannelMemberWithAvatar, EmotionState, LooseProp};
use chattyness_db::ws_messages::{ClientMessage, ServerMessage};
use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp};
#[cfg(feature = "hydrate")]
use chattyness_db::models::EmotionState;
use chattyness_db::ws_messages::ClientMessage;
#[cfg(feature = "hydrate")]
use chattyness_db::ws_messages::{DisconnectReason, ServerMessage, WsConfig};
use super::chat_types::ChatMessage;
/// Close code for scene change (must match server constant).
pub const SCENE_CHANGE_CLOSE_CODE: u16 = 4000;
/// Duration for fade-out animation in milliseconds.
pub const FADE_DURATION_MS: i64 = 5000;
/// 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 {
@ -44,6 +65,7 @@ pub fn use_channel_websocket(
on_loose_props_sync: Callback<Vec<LooseProp>>,
on_prop_dropped: Callback<LooseProp>,
on_prop_picked_up: Callback<uuid::Uuid>,
on_member_fading: Callback<FadingMember>,
) -> (Signal<WsState>, WsSenderStorage) {
use std::cell::RefCell;
use std::rc::Rc;
@ -139,6 +161,11 @@ pub fn use_channel_websocket(
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();
// 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();
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();
@ -146,6 +173,28 @@ pub fn use_channel_websocket(
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, .. } = msg {
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);
}
}
handle_server_message(
msg,
&members_for_msg,
@ -154,6 +203,7 @@ pub fn use_channel_websocket(
&on_loose_props_sync_clone,
&on_prop_dropped_clone,
&on_prop_picked_up_clone,
&on_member_fading_clone,
);
}
}
@ -199,6 +249,7 @@ fn handle_server_message(
on_loose_props_sync: &Callback<Vec<LooseProp>>,
on_prop_dropped: &Callback<LooseProp>,
on_prop_picked_up: &Callback<uuid::Uuid>,
on_member_fading: &Callback<FadingMember>,
) {
let mut members_vec = members.borrow_mut();
@ -206,6 +257,7 @@ fn handle_server_message(
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());
@ -222,11 +274,31 @@ fn handle_server_message(
ServerMessage::MemberLeft {
user_id,
guest_session_id,
reason,
} => {
// 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,
@ -333,6 +405,7 @@ pub fn use_channel_websocket(
_on_loose_props_sync: Callback<Vec<LooseProp>>,
_on_prop_dropped: Callback<LooseProp>,
_on_prop_picked_up: Callback<uuid::Uuid>,
_on_member_fading: Callback<FadingMember>,
) -> (Signal<WsState>, WsSenderStorage) {
let (ws_state, _) = signal(WsState::Disconnected);
let sender: WsSenderStorage = StoredValue::new_local(None);