diff --git a/crates/chattyness-db/src/queries/channel_members.rs b/crates/chattyness-db/src/queries/channel_members.rs index 0355c67..c9001f8 100644 --- a/crates/chattyness-db/src/queries/channel_members.rs +++ b/crates/chattyness-db/src/queries/channel_members.rs @@ -8,16 +8,31 @@ use chattyness_error::AppError; /// Join a channel as an authenticated user. /// -/// Creates a channel_members entry with default position (400, 300). +/// Restores the user's last position if they were previously in the same scene, +/// otherwise uses the default position (400, 300). +/// Note: channel_id is actually scene_id in this system (scenes are used directly as channels). pub async fn join_channel<'e>( executor: impl PgExecutor<'e>, channel_id: Uuid, user_id: Uuid, ) -> Result { + // Note: channel_id is actually scene_id in this system let member = sqlx::query_as::<_, ChannelMember>( r#" INSERT INTO realm.channel_members (channel_id, user_id, position) - VALUES ($1, $2, ST_SetSRID(ST_MakePoint(400, 300), 0)) + SELECT $1, $2, COALESCE( + -- Try to restore last position if user was in the same scene + -- Note: channel_id = scene_id in this system + (SELECT m.last_position + FROM realm.memberships m + JOIN realm.scenes s ON s.id = $1 + WHERE m.user_id = $2 + AND m.realm_id = s.realm_id + AND m.last_scene_id = $1 + AND m.last_position IS NOT NULL), + -- Default position + ST_SetSRID(ST_MakePoint(400, 300), 0) + ) ON CONFLICT (channel_id, user_id) DO UPDATE SET joined_at = now() RETURNING @@ -67,13 +82,40 @@ pub async fn ensure_active_avatar<'e>( } /// Leave a channel. +/// +/// Saves the user's current position to memberships.last_position before removing them. +/// Note: channel_id is actually scene_id in this system (scenes are used directly as channels). pub async fn leave_channel<'e>( executor: impl PgExecutor<'e>, channel_id: Uuid, user_id: Uuid, ) -> Result<(), AppError> { + // Use data-modifying CTEs with RETURNING to ensure all CTEs execute + // Note: channel_id is actually scene_id in this system sqlx::query( - r#"DELETE FROM realm.channel_members WHERE channel_id = $1 AND user_id = $2"#, + r#" + WITH member_info AS ( + SELECT cm.position, cm.channel_id as scene_id, s.realm_id + FROM realm.channel_members cm + JOIN realm.scenes s ON cm.channel_id = s.id + WHERE cm.channel_id = $1 AND cm.user_id = $2 + ), + save_position AS ( + UPDATE realm.memberships m + SET last_position = mi.position, + last_scene_id = mi.scene_id, + last_visited_at = now() + FROM member_info mi + WHERE m.realm_id = mi.realm_id AND m.user_id = $2 + RETURNING m.user_id + ), + do_delete AS ( + DELETE FROM realm.channel_members + WHERE channel_id = $1 AND user_id = $2 + RETURNING user_id + ) + SELECT COUNT(*) FROM save_position, do_delete + "#, ) .bind(channel_id) .bind(user_id) diff --git a/crates/chattyness-user-ui/src/components/scene_viewer.rs b/crates/chattyness-user-ui/src/components/scene_viewer.rs index 3f5cfd7..abc51a1 100644 --- a/crates/chattyness-user-ui/src/components/scene_viewer.rs +++ b/crates/chattyness-user-ui/src/components/scene_viewer.rs @@ -97,6 +97,9 @@ pub fn RealmSceneViewer( let offset_x = StoredValue::new(0.0_f64); let offset_y = StoredValue::new(0.0_f64); + // Signal to track when scale factors have been properly calculated + let (scales_ready, set_scales_ready) = signal(false); + // Handle canvas click for movement or prop pickup (on avatar canvas - topmost layer) #[cfg(feature = "hydrate")] let on_canvas_click = { @@ -125,9 +128,9 @@ pub fn RealmSceneViewer( let scene_x = scene_x.max(0.0).min(scene_width as f64); let scene_y = scene_y.max(0.0).min(scene_height as f64); - // Check if click is within 32px of any loose prop + // Check if click is within 40px of any loose prop let current_props = loose_props.get(); - let prop_click_radius = 32.0; + let prop_click_radius = 40.0; let mut clicked_prop: Option = None; for prop in ¤t_props { @@ -220,6 +223,9 @@ pub fn RealmSceneViewer( offset_x.set_value(draw_x); offset_y.set_value(draw_y); + // Signal that scale factors are ready + set_scales_ready.set(true); + if let Ok(Some(ctx)) = canvas_el.get_context("2d") { let ctx: web_sys::CanvasRenderingContext2d = ctx.dyn_into::().unwrap(); @@ -271,6 +277,11 @@ pub fn RealmSceneViewer( let current_members = members.get(); let current_bubbles = active_bubbles.get(); + // Skip drawing if scale factors haven't been calculated yet + if !scales_ready.get() { + return; + } + let Some(canvas) = avatar_canvas_ref.get() else { return; }; @@ -335,6 +346,11 @@ pub fn RealmSceneViewer( // Track loose_props signal let current_props = loose_props.get(); + // Skip drawing if scale factors haven't been calculated yet + if !scales_ready.get() { + return; + } + let Some(canvas) = props_canvas_ref.get() else { return; }; @@ -452,7 +468,7 @@ fn draw_avatars( let x = member.member.position_x * scale_x + offset_x; let y = member.member.position_y * scale_y + offset_y; - let avatar_size = 48.0 * scale_x.min(scale_y); + let avatar_size = 60.0 * scale_x.min(scale_y); // Draw avatar placeholder circle ctx.begin_path(); @@ -544,7 +560,7 @@ fn draw_speech_bubbles( current_time_ms: i64, ) { let scale = scale_x.min(scale_y); - let avatar_size = 48.0 * scale; + let avatar_size = 60.0 * scale; let max_bubble_width = 200.0 * scale; let padding = 8.0 * scale; let font_size = 12.0 * scale; @@ -704,7 +720,7 @@ fn draw_loose_props( offset_x: f64, offset_y: f64, ) { - let prop_size = 48.0 * scale_x.min(scale_y); + let prop_size = 60.0 * scale_x.min(scale_y); for prop in props { let x = prop.position_x * scale_x + offset_x; diff --git a/crates/chattyness-user-ui/src/pages/realm.rs b/crates/chattyness-user-ui/src/pages/realm.rs index 4e9bbee..55b1714 100644 --- a/crates/chattyness-user-ui/src/pages/realm.rs +++ b/crates/chattyness-user-ui/src/pages/realm.rs @@ -61,6 +61,9 @@ pub fn RealmPage() -> impl IntoView { // Loose props state let (loose_props, set_loose_props) = signal(Vec::::new()); + // Track user's current position for saving on beforeunload + let (current_position, set_current_position) = signal((400.0_f64, 300.0_f64)); + let realm_data = LocalResource::new(move || { let slug = slug.get(); async move { @@ -233,6 +236,8 @@ pub fn RealmPage() -> impl IntoView { // Handle position update via WebSocket #[cfg(feature = "hydrate")] let on_move = Callback::new(move |(x, y): (f64, f64)| { + // Track position for saving on beforeunload + set_current_position.set((x, y)); ws_sender.with_value(|sender| { if let Some(send_fn) = sender { send_fn(ClientMessage::UpdatePosition { x, y }); @@ -359,6 +364,34 @@ pub fn RealmPage() -> impl IntoView { // Store the closure for cleanup *closure_holder_clone.borrow_mut() = Some(closure); }); + + // Save position on page unload (beforeunload event) + Effect::new({ + let ws_sender = ws_sender.clone(); + move |_| { + let Some(window) = web_sys::window() else { + return; + }; + + let handler = Closure::::new({ + let ws_sender = ws_sender.clone(); + move |_: web_sys::BeforeUnloadEvent| { + let (x, y) = current_position.get_untracked(); + ws_sender.with_value(|sender| { + if let Some(send_fn) = sender { + send_fn(ClientMessage::UpdatePosition { x, y }); + } + }); + } + }); + + let _ = window.add_event_listener_with_callback( + "beforeunload", + handler.as_ref().unchecked_ref(), + ); + handler.forget(); + } + }); } // Callback for chat focus changes