From 44b322371c7f72df981f991b7f24c89f9ba49ab87527d2bc225ed48bb9ecc0a4 Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Sun, 18 Jan 2026 18:06:21 -0600 Subject: [PATCH 01/10] fix: finally got prop scaling, text scaling, canvas scaling, and bubble drawing to work --- .../src/components/avatar_canvas.rs | 542 ++++++++++-------- 1 file changed, 298 insertions(+), 244 deletions(-) diff --git a/crates/chattyness-user-ui/src/components/avatar_canvas.rs b/crates/chattyness-user-ui/src/components/avatar_canvas.rs index 3943066..d2ae953 100644 --- a/crates/chattyness-user-ui/src/components/avatar_canvas.rs +++ b/crates/chattyness-user-ui/src/components/avatar_canvas.rs @@ -226,6 +226,235 @@ fn determine_bubble_position( } } +/// Unified layout context for avatar canvas rendering. +/// +/// This struct computes all derived layout values once from the inputs, +/// providing a single source of truth for: +/// - Canvas dimensions and position +/// - Avatar positioning within the canvas +/// - Coordinate transformations between canvas-local and screen space +/// - Bubble positioning and clamping +/// +/// By centralizing these calculations, we avoid scattered, duplicated logic +/// and ensure the style closure, Effect, and draw_bubble all use consistent values. +#[derive(Clone, Copy)] +#[allow(dead_code)] // Some fields kept for potential future use +struct CanvasLayout { + // Core dimensions + prop_size: f64, + avatar_size: f64, + + // Content offset from grid center + content_x_offset: f64, + content_y_offset: f64, + + // Text scaling + text_scale: f64, + bubble_max_width: f64, + + // Canvas dimensions + canvas_width: f64, + canvas_height: f64, + + // Canvas position in screen space + canvas_screen_x: f64, + canvas_screen_y: f64, + + // Avatar center within canvas (canvas-local coordinates) + avatar_cx: f64, + avatar_cy: f64, + + // Scene boundaries for clamping + boundaries: ScreenBoundaries, + + // Bubble state + bubble_position: BubblePosition, + bubble_height_reserved: f64, + + // Content row info for positioning + empty_top_rows: usize, + empty_bottom_rows: usize, +} + +impl CanvasLayout { + /// Create a new layout from all input parameters. + fn new( + content_bounds: &ContentBounds, + prop_size: f64, + text_em_size: f64, + avatar_screen_x: f64, + avatar_screen_y: f64, + boundaries: ScreenBoundaries, + has_bubble: bool, + bubble_text: Option<&str>, + ) -> Self { + let avatar_size = prop_size * 3.0; + let text_scale = text_em_size * BASE_TEXT_SCALE; + let bubble_max_width = 200.0 * text_scale; + + // Content offsets from grid center + let content_x_offset = content_bounds.x_offset(prop_size); + let content_y_offset = content_bounds.y_offset(prop_size); + + // Empty rows for positioning elements relative to content + let empty_top_rows = content_bounds.empty_top_rows(); + let empty_bottom_rows = content_bounds.empty_bottom_rows(); + + // Content dimensions for clamping + let content_half_width = content_bounds.content_width(prop_size) / 2.0; + let content_half_height = content_bounds.content_height(prop_size) / 2.0; + + // Clamp avatar so content stays within scene + let (clamped_x, clamped_y) = boundaries.clamp_avatar_center( + avatar_screen_x, + avatar_screen_y, + content_half_width, + content_half_height, + ); + + // Calculate bubble height and position + let bubble_height_reserved = if has_bubble { + (4.0 * 16.0 + 16.0 + 8.0 + 5.0) * text_scale + } else { + 0.0 + }; + let name_height = 20.0 * text_scale; + + // Determine bubble position (above or below) + // Use actual content height, not full 3x3 grid size + let bubble_position = if has_bubble { + let estimated_height = bubble_text + .map(|t| estimate_bubble_height(t, text_scale)) + .unwrap_or(0.0); + // clamped_y is the content center, so use content_half_height + // to find the actual top of the visible avatar content + determine_bubble_position( + clamped_y, + content_half_height, + estimated_height, + 0.0, + 0.0, + boundaries.min_y, + ) + } else { + BubblePosition::Above + }; + + // Canvas dimensions - wide enough to fit shifted bubble + let extra_margin = if has_bubble { bubble_max_width } else { 0.0 }; + let canvas_width = avatar_size.max(bubble_max_width) + extra_margin; + let canvas_height = avatar_size + bubble_height_reserved + name_height; + + // Canvas position in screen space + // The avatar grid center maps to canvas_width/2, but we need to account + // for the content offset so the visible content aligns with clamped_x/y + let canvas_x = clamped_x - avatar_size / 2.0 - content_x_offset; + let canvas_screen_x = canvas_x - (canvas_width - avatar_size) / 2.0; + + let canvas_y = clamped_y - avatar_size / 2.0 - content_y_offset; + let canvas_screen_y = match bubble_position { + BubblePosition::Above => canvas_y - bubble_height_reserved, + BubblePosition::Below => canvas_y, + }; + + // Avatar center within canvas + let avatar_cx = canvas_width / 2.0; + let avatar_cy = match bubble_position { + BubblePosition::Above => bubble_height_reserved + avatar_size / 2.0, + BubblePosition::Below => avatar_size / 2.0, + }; + + Self { + prop_size, + avatar_size, + content_x_offset, + content_y_offset, + text_scale, + bubble_max_width, + canvas_width, + canvas_height, + canvas_screen_x, + canvas_screen_y, + avatar_cx, + avatar_cy, + boundaries, + bubble_position, + bubble_height_reserved, + empty_top_rows, + empty_bottom_rows, + } + } + + /// CSS style string for positioning the canvas element. + fn css_style(&self, z_index: i32, pointer_events: &str, opacity: f64) -> String { + format!( + "position: absolute; \ + left: 0; top: 0; \ + transform: translate({}px, {}px); \ + z-index: {}; \ + pointer-events: {}; \ + width: {}px; \ + height: {}px; \ + opacity: {};", + self.canvas_screen_x, + self.canvas_screen_y, + z_index, + pointer_events, + self.canvas_width, + self.canvas_height, + opacity + ) + } + + /// Content center X in canvas-local coordinates. + fn content_center_x(&self) -> f64 { + self.avatar_cx + self.content_x_offset + } + + /// Top of avatar in canvas-local coordinates. + fn avatar_top_y(&self) -> f64 { + self.avatar_cy - self.avatar_size / 2.0 + } + + /// Bottom of avatar in canvas-local coordinates. + fn avatar_bottom_y(&self) -> f64 { + self.avatar_cy + self.avatar_size / 2.0 + } + + /// Convert canvas-local X to screen X. + fn canvas_to_screen_x(&self, x: f64) -> f64 { + self.canvas_screen_x + x + } + + /// Clamp a bubble's X position to stay within scene boundaries. + /// Takes and returns canvas-local coordinates. + fn clamp_bubble_x(&self, bubble_x: f64, bubble_width: f64) -> f64 { + // Convert to screen space + let screen_left = self.canvas_to_screen_x(bubble_x); + let screen_right = screen_left + bubble_width; + + // Calculate shifts needed to stay within bounds + let shift_right = (self.boundaries.min_x - screen_left).max(0.0); + let shift_left = (screen_right - self.boundaries.max_x).max(0.0); + + // Apply shift and clamp to canvas bounds + let shifted = bubble_x + shift_right - shift_left; + shifted.max(0.0).min(self.canvas_width - bubble_width) + } + + /// Adjustment for bubble Y position based on empty rows at top. + /// Returns the distance in pixels from grid top to content top. + fn content_top_adjustment(&self) -> f64 { + self.empty_top_rows as f64 * self.prop_size + } + + /// Adjustment for name Y position based on empty rows at bottom. + /// Returns the distance in pixels from grid bottom to content bottom. + fn content_bottom_adjustment(&self) -> f64 { + self.empty_bottom_rows as f64 * self.prop_size + } +} + /// Get a unique key for a member (for Leptos For keying). pub fn member_key(m: &ChannelMemberWithAvatar) -> (Option, Option) { (m.member.user_id, m.member.guest_session_id) @@ -293,102 +522,29 @@ pub fn AvatarCanvas( &m.avatar.emotion_layer, ); - // Get offsets from grid center to content center - let x_content_offset = content_bounds.x_offset(ps); - let y_content_offset = content_bounds.y_offset(ps); - - // Avatar is a 3x3 grid of props, each prop is prop_size - let avatar_size = ps * 3.0; - // Get scene dimensions (use large defaults if not provided) let sw = scene_width.map(|s| s.get()).unwrap_or(10000.0); let sh = scene_height.map(|s| s.get()).unwrap_or(10000.0); - // Compute screen boundaries for avatar clamping + // Compute screen boundaries and avatar screen position let boundaries = ScreenBoundaries::from_transform(sw, sh, sx, sy, ox, oy); - - // Calculate raw avatar screen position let avatar_screen_x = m.member.position_x * sx + ox; let avatar_screen_y = m.member.position_y * sy + oy; - // Clamp avatar center so visual bounds stay within screen boundaries - // Use actual content extent rather than full 3x3 grid - let content_half_width = content_bounds.content_width(ps) / 2.0; - let content_half_height = content_bounds.content_height(ps) / 2.0; - let (clamped_x, clamped_y) = boundaries.clamp_avatar_center( + // Create unified layout - all calculations happen in one place + let layout = CanvasLayout::new( + &content_bounds, + ps, + te, avatar_screen_x, avatar_screen_y, - content_half_width, - content_half_height, + boundaries, + bubble.is_some(), + bubble.as_ref().map(|b| b.message.content.as_str()), ); - // Calculate canvas position from clamped screen coordinates, adjusted for content bounds - let canvas_x = clamped_x - avatar_size / 2.0 - x_content_offset; - let canvas_y = clamped_y - avatar_size / 2.0 - y_content_offset; - - // Fixed text dimensions (independent of prop_size/zoom) - let text_scale = te * BASE_TEXT_SCALE; - let fixed_bubble_height = if bubble.is_some() { - (4.0 * 16.0 + 16.0 + 8.0 + 5.0) * text_scale - } else { - 0.0 - }; - let fixed_name_height = 20.0 * text_scale; - let fixed_text_width = 200.0 * text_scale; - - // Determine bubble position based on available space above avatar - // This must match the logic in the Effect that draws the bubble - let bubble_position = if bubble.is_some() { - // Use clamped avatar screen position for bubble calculation - let avatar_half_height = avatar_size / 2.0 + y_content_offset; - - // Calculate bubble height using actual content (includes tail + gap) - let estimated_bubble_height = bubble - .as_ref() - .map(|b| estimate_bubble_height(&b.message.content, text_scale)) - .unwrap_or(0.0); - - determine_bubble_position( - clamped_y, - avatar_half_height, - estimated_bubble_height, - 0.0, // Already included in estimate_bubble_height - 0.0, // Already included in estimate_bubble_height - boundaries.min_y, - ) - } else { - BubblePosition::Above // Default when no bubble - }; - - // Canvas must fit avatar, text, AND bubble (positioned based on bubble location) - let canvas_width = avatar_size.max(fixed_text_width); - let canvas_height = avatar_size + fixed_bubble_height + fixed_name_height; - - // Adjust position based on bubble position - // When bubble is above: offset canvas upward to make room at top - // When bubble is below: no upward offset, bubble goes below avatar - let adjusted_y = match bubble_position { - BubblePosition::Above => canvas_y - fixed_bubble_height, - BubblePosition::Below => canvas_y, - }; - - format!( - "position: absolute; \ - left: 0; top: 0; \ - transform: translate({}px, {}px); \ - z-index: {}; \ - pointer-events: {}; \ - width: {}px; \ - height: {}px; \ - opacity: {};", - canvas_x - (canvas_width - avatar_size) / 2.0, - adjusted_y, - z_index, - pointer_events, - canvas_width, - canvas_height, - opacity - ) + // Generate CSS style from layout + layout.css_style(z_index, pointer_events, opacity) }; // Store references for the effect @@ -422,7 +578,7 @@ pub fn AvatarCanvas( return; }; - // Calculate dimensions (same as in style closure) + // Calculate content bounds for the avatar let content_bounds = ContentBounds::from_layers( &m.avatar.skin_layer, &m.avatar.clothes_layer, @@ -430,61 +586,35 @@ pub fn AvatarCanvas( &m.avatar.emotion_layer, ); - let avatar_size = ps * 3.0; - let text_scale = te * BASE_TEXT_SCALE; - let fixed_bubble_height = if bubble.is_some() { - (4.0 * 16.0 + 16.0 + 8.0 + 5.0) * text_scale - } else { - 0.0 - }; - let fixed_name_height = 20.0 * text_scale; - let fixed_text_width = 200.0 * text_scale; + // Get scene dimensions and transform parameters + let sx = scale_x.get(); + let sy = scale_y.get(); + let ox = offset_x.get(); + let oy = offset_y.get(); + let sw = scene_width.map(|s| s.get()).unwrap_or(10000.0); + let sh = scene_height.map(|s| s.get()).unwrap_or(10000.0); - // Determine bubble position early so we can position the avatar correctly - let y_content_offset = content_bounds.y_offset(ps); - let bubble_position = if bubble.is_some() { - let sx = scale_x.get(); - let sy = scale_y.get(); - let ox = offset_x.get(); - let oy = offset_y.get(); + // Create unified layout - same calculation as style closure + let boundaries = ScreenBoundaries::from_transform(sw, sh, sx, sy, ox, oy); + let avatar_screen_x = m.member.position_x * sx + ox; + let avatar_screen_y = m.member.position_y * sy + oy; - // Get scene dimensions (use large defaults if not provided) - let sw = scene_width.map(|s| s.get()).unwrap_or(10000.0); - let sh = scene_height.map(|s| s.get()).unwrap_or(10000.0); - - // Compute screen boundaries - let boundaries = ScreenBoundaries::from_transform(sw, sh, sx, sy, ox, oy); - - // Calculate avatar's screen position - let avatar_screen_y = m.member.position_y * sy + oy; - let avatar_half_height = avatar_size / 2.0 + y_content_offset; - - // Calculate bubble height using actual content (includes tail + gap) - let estimated_bubble_height = bubble - .as_ref() - .map(|b| estimate_bubble_height(&b.message.content, text_scale)) - .unwrap_or(0.0); - - determine_bubble_position( - avatar_screen_y, - avatar_half_height, - estimated_bubble_height, - 0.0, // Already included in estimate_bubble_height - 0.0, // Already included in estimate_bubble_height - boundaries.min_y, - ) - } else { - BubblePosition::Above - }; - - let canvas_width = avatar_size.max(fixed_text_width); - let canvas_height = avatar_size + fixed_bubble_height + fixed_name_height; + let layout = CanvasLayout::new( + &content_bounds, + ps, + te, + avatar_screen_x, + avatar_screen_y, + boundaries, + bubble.is_some(), + bubble.as_ref().map(|b| b.message.content.as_str()), + ); let canvas_el: &web_sys::HtmlCanvasElement = &canvas; - // Set canvas resolution - canvas_el.set_width(canvas_width as u32); - canvas_el.set_height(canvas_height as u32); + // Set canvas resolution from layout + canvas_el.set_width(layout.canvas_width as u32); + canvas_el.set_height(layout.canvas_height as u32); let Ok(Some(ctx)) = canvas_el.get_context("2d") else { return; @@ -492,16 +622,7 @@ pub fn AvatarCanvas( let ctx: web_sys::CanvasRenderingContext2d = ctx.dyn_into().unwrap(); // Clear canvas - ctx.clear_rect(0.0, 0.0, canvas_width, canvas_height); - - // Avatar center position within the canvas - // When bubble is above: avatar is below the bubble space - // When bubble is below: avatar is at the top, bubble space is below - let avatar_cx = canvas_width / 2.0; - let avatar_cy = match bubble_position { - BubblePosition::Above => fixed_bubble_height + avatar_size / 2.0, - BubblePosition::Below => avatar_size / 2.0, - }; + ctx.clear_rect(0.0, 0.0, layout.canvas_width, layout.canvas_height); // Helper to load and draw an image // Images are cached; when loaded, triggers a redraw via signal @@ -544,9 +665,9 @@ pub fn AvatarCanvas( }; // Draw all 9 positions of the avatar grid (3x3 layout) - let cell_size = ps; - let grid_origin_x = avatar_cx - avatar_size / 2.0; - let grid_origin_y = avatar_cy - avatar_size / 2.0; + let cell_size = layout.prop_size; + let grid_origin_x = layout.avatar_cx - layout.avatar_size / 2.0; + let grid_origin_y = layout.avatar_cy - layout.avatar_size / 2.0; // Draw skin layer for all 9 positions for pos in 0..9 { @@ -616,9 +737,9 @@ pub fn AvatarCanvas( // Draw emotion badge if non-neutral let current_emotion = m.member.current_emotion; if current_emotion > 0 { - let badge_size = 16.0 * text_scale; - let badge_x = avatar_cx + avatar_size / 2.0 - badge_size / 2.0; - let badge_y = avatar_cy - avatar_size / 2.0 - badge_size / 2.0; + let badge_size = 16.0 * layout.text_scale; + let badge_x = layout.avatar_cx + layout.avatar_size / 2.0 - badge_size / 2.0; + let badge_y = layout.avatar_cy - layout.avatar_size / 2.0 - badge_size / 2.0; ctx.begin_path(); let _ = ctx.arc( @@ -632,23 +753,21 @@ pub fn AvatarCanvas( ctx.fill(); ctx.set_fill_style_str("#000"); - ctx.set_font(&format!("bold {}px sans-serif", 10.0 * text_scale)); + ctx.set_font(&format!("bold {}px sans-serif", 10.0 * layout.text_scale)); ctx.set_text_align("center"); ctx.set_text_baseline("middle"); let _ = ctx.fill_text(&format!("{}", current_emotion), badge_x, badge_y); } - // Calculate content bounds for name positioning - let name_x = avatar_cx + content_bounds.x_offset(cell_size); - let empty_bottom_rows = content_bounds.empty_bottom_rows(); - // Draw display name below avatar (with black outline for readability) + let name_x = layout.content_center_x(); + let name_y = layout.avatar_bottom_y() - layout.content_bottom_adjustment() + + 15.0 * layout.text_scale; + let display_name = &m.member.display_name; - ctx.set_font(&format!("{}px sans-serif", 12.0 * text_scale)); + ctx.set_font(&format!("{}px sans-serif", 12.0 * layout.text_scale)); ctx.set_text_align("center"); ctx.set_text_baseline("alphabetic"); - let name_y = avatar_cy + avatar_size / 2.0 - (empty_bottom_rows as f64 * cell_size) - + 15.0 * text_scale; // Black outline ctx.set_stroke_style_str("#000"); ctx.set_line_width(3.0); @@ -661,35 +780,7 @@ pub fn AvatarCanvas( if let Some(ref b) = bubble { let current_time = js_sys::Date::now() as i64; if b.expires_at >= current_time { - let content_x_offset = content_bounds.x_offset(cell_size); - let content_top_adjustment = content_bounds.empty_top_rows() as f64 * cell_size; - - // Get screen boundaries for horizontal clamping - let sx = scale_x.get(); - let sy = scale_y.get(); - let ox = offset_x.get(); - let oy = offset_y.get(); - let sw = scene_width.map(|s| s.get()).unwrap_or(10000.0); - let sh = scene_height.map(|s| s.get()).unwrap_or(10000.0); - let boundaries = ScreenBoundaries::from_transform(sw, sh, sx, sy, ox, oy); - - // Avatar top and bottom Y within the canvas - let avatar_top_y = avatar_cy - avatar_size / 2.0; - let avatar_bottom_y = avatar_cy + avatar_size / 2.0; - - // Use the pre-calculated bubble_position from earlier - draw_bubble( - &ctx, - b, - avatar_cx, - avatar_top_y, - avatar_bottom_y, - content_x_offset, - content_top_adjustment, - te, - bubble_position, - Some(&boundaries), - ); + draw_bubble_with_layout(&ctx, b, &layout, te); } } }); @@ -724,33 +815,18 @@ fn normalize_asset_path(path: &str) -> String { } } -/// Draw a speech bubble relative to the avatar with boundary awareness. +/// Draw a speech bubble using the unified CanvasLayout. /// -/// # Arguments -/// * `ctx` - Canvas rendering context -/// * `bubble` - The active bubble data -/// * `center_x` - Avatar center X in canvas coordinates -/// * `top_y` - Avatar top edge Y in canvas coordinates -/// * `bottom_y` - Avatar bottom edge Y in canvas coordinates -/// * `content_x_offset` - X offset to center on content -/// * `content_top_adjustment` - Y adjustment for empty top rows -/// * `text_em_size` - Text size multiplier -/// * `position` - Whether to render above or below the avatar -/// * `boundaries` - Screen boundaries for horizontal clamping (optional) +/// This is the preferred method for drawing bubbles - it uses the layout's +/// coordinate transformation and clamping methods, ensuring consistency +/// with the canvas positioning. #[cfg(feature = "hydrate")] -fn draw_bubble( +fn draw_bubble_with_layout( ctx: &web_sys::CanvasRenderingContext2d, bubble: &ActiveBubble, - center_x: f64, - top_y: f64, - bottom_y: f64, - content_x_offset: f64, - content_top_adjustment: f64, + layout: &CanvasLayout, text_em_size: f64, - position: BubblePosition, - boundaries: Option<&ScreenBoundaries>, ) { - // Text scale independent of zoom - only affected by user's text_em_size setting let text_scale = text_em_size * BASE_TEXT_SCALE; let max_bubble_width = 200.0 * text_scale; let padding = 8.0 * text_scale; @@ -771,11 +847,7 @@ fn draw_bubble( // Measure and wrap text ctx.set_font(&format!("{}{}px sans-serif", font_style, font_size)); - let lines = wrap_text( - ctx, - &bubble.message.content, - max_bubble_width - padding * 2.0, - ); + let lines = wrap_text(ctx, &bubble.message.content, max_bubble_width - padding * 2.0); // Calculate bubble dimensions let bubble_width = lines @@ -786,25 +858,14 @@ fn draw_bubble( let bubble_width = bubble_width.max(60.0 * text_scale); let bubble_height = (lines.len() as f64) * line_height + padding * 2.0; - // Center bubble horizontally on content (not grid center) - let content_center_x = center_x + content_x_offset; + // Get content center from layout + let content_center_x = layout.content_center_x(); - // Calculate initial bubble X position (centered on content) - let mut bubble_x = content_center_x - bubble_width / 2.0; + // Calculate initial bubble X (centered on content) + let initial_bubble_x = content_center_x - bubble_width / 2.0; - // Clamp bubble horizontally to stay within drawable area - if let Some(bounds) = boundaries { - let bubble_left = bubble_x; - let bubble_right = bubble_x + bubble_width; - - if bubble_left < bounds.min_x { - // Shift right to stay within left edge - bubble_x = bounds.min_x; - } else if bubble_right > bounds.max_x { - // Shift left to stay within right edge - bubble_x = bounds.max_x - bubble_width; - } - } + // Use layout's clamping method - this handles coordinate transformation correctly + let bubble_x = layout.clamp_bubble_x(initial_bubble_x, bubble_width); // Calculate tail center - point toward content center but stay within bubble bounds let tail_center_x = content_center_x @@ -812,27 +873,20 @@ fn draw_bubble( .min(bubble_x + bubble_width - tail_size - border_radius); // Calculate vertical position based on bubble position - let bubble_y = match position { + let bubble_y = match layout.bubble_position { BubblePosition::Above => { // Position vertically closer to content when top rows are empty - let adjusted_top_y = top_y + content_top_adjustment; + let adjusted_top_y = layout.avatar_top_y() + layout.content_top_adjustment(); adjusted_top_y - bubble_height - tail_size - gap } BubblePosition::Below => { // Position below avatar with gap for tail - bottom_y + tail_size + gap + layout.avatar_bottom_y() + tail_size + gap } }; // Draw bubble background - draw_rounded_rect( - ctx, - bubble_x, - bubble_y, - bubble_width, - bubble_height, - border_radius, - ); + draw_rounded_rect(ctx, bubble_x, bubble_y, bubble_width, bubble_height, border_radius); ctx.set_fill_style_str(bg_color); ctx.fill(); ctx.set_stroke_style_str(border_color); @@ -841,7 +895,7 @@ fn draw_bubble( // Draw tail pointing to content center ctx.begin_path(); - match position { + match layout.bubble_position { BubblePosition::Above => { // Tail points DOWN toward avatar ctx.move_to(tail_center_x - tail_size, bubble_y + bubble_height); @@ -861,7 +915,7 @@ fn draw_bubble( ctx.set_stroke_style_str(border_color); ctx.stroke(); - // Draw text (re-set font in case canvas state changed) + // Draw text ctx.set_font(&format!("{}{}px sans-serif", font_style, font_size)); ctx.set_fill_style_str(text_color); ctx.set_text_align("left"); From 15cc1f708f7f5667d03e41fe5ae0c0f28bfedeac9759f08b7be3119e6bdade29 Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Sun, 18 Jan 2026 19:31:30 -0600 Subject: [PATCH 02/10] fix: statistics at the top of page --- .../chattyness-db/src/queries/owner/realms.rs | 28 ++- .../chattyness-db/src/queries/owner/users.rs | 27 +-- crates/chattyness-db/src/queries/realms.rs | 181 ++++++++++-------- .../src/components/layout.rs | 4 +- crates/chattyness-user-ui/src/pages/realm.rs | 3 +- 5 files changed, 147 insertions(+), 96 deletions(-) diff --git a/crates/chattyness-db/src/queries/owner/realms.rs b/crates/chattyness-db/src/queries/owner/realms.rs index e1e2d95..156d6f0 100644 --- a/crates/chattyness-db/src/queries/owner/realms.rs +++ b/crates/chattyness-db/src/queries/owner/realms.rs @@ -30,7 +30,12 @@ pub async fn list_realms_with_owner( r.owner_id, u.username as owner_username, r.member_count, - r.current_user_count, + COALESCE(( + SELECT COUNT(*)::INTEGER + FROM scene.instance_members im + JOIN realm.scenes s ON im.instance_id = s.id + WHERE s.realm_id = r.id + ), 0) AS current_user_count, r.created_at FROM realm.realms r JOIN auth.users u ON r.owner_id = u.id @@ -65,7 +70,12 @@ pub async fn search_realms( r.owner_id, u.username as owner_username, r.member_count, - r.current_user_count, + COALESCE(( + SELECT COUNT(*)::INTEGER + FROM scene.instance_members im + JOIN realm.scenes s ON im.instance_id = s.id + WHERE s.realm_id = r.id + ), 0) AS current_user_count, r.created_at FROM realm.realms r JOIN auth.users u ON r.owner_id = u.id @@ -245,7 +255,12 @@ pub async fn get_realm_by_slug(pool: &PgPool, slug: &str) -> Result Result, AppErro let realms = sqlx::query_as::<_, RealmSummary>( r#" SELECT - id, - name, - slug, - tagline, - privacy, - is_nsfw, - thumbnail_path, - member_count, - current_user_count - FROM realm.realms - ORDER BY name + r.id, + r.name, + r.slug, + r.tagline, + r.privacy, + r.is_nsfw, + r.thumbnail_path, + r.member_count, + COALESCE(( + SELECT COUNT(*)::INTEGER + FROM scene.instance_members im + JOIN realm.scenes s ON im.instance_id = s.id + WHERE s.realm_id = r.id + ), 0) AS current_user_count + FROM realm.realms r + ORDER BY r.name "#, ) .fetch_all(pool) diff --git a/crates/chattyness-db/src/queries/realms.rs b/crates/chattyness-db/src/queries/realms.rs index 2bc7482..61541d4 100644 --- a/crates/chattyness-db/src/queries/realms.rs +++ b/crates/chattyness-db/src/queries/realms.rs @@ -77,27 +77,32 @@ pub async fn get_realm_by_slug<'e>( let realm = sqlx::query_as::<_, Realm>( r#" SELECT - id, - name, - slug, - description, - tagline, - owner_id, - privacy, - is_nsfw, - min_reputation_tier, - theme_color, - banner_image_path, - thumbnail_path, - max_users, - allow_guest_access, - default_scene_id, - member_count, - current_user_count, - created_at, - updated_at - FROM realm.realms - WHERE slug = $1 + r.id, + r.name, + r.slug, + r.description, + r.tagline, + r.owner_id, + r.privacy, + r.is_nsfw, + r.min_reputation_tier, + r.theme_color, + r.banner_image_path, + r.thumbnail_path, + r.max_users, + r.allow_guest_access, + r.default_scene_id, + r.member_count, + COALESCE(( + SELECT COUNT(*)::INTEGER + FROM scene.instance_members im + JOIN realm.scenes s ON im.instance_id = s.id + WHERE s.realm_id = r.id + ), 0) AS current_user_count, + r.created_at, + r.updated_at + FROM realm.realms r + WHERE r.slug = $1 "#, ) .bind(slug) @@ -112,27 +117,32 @@ pub async fn get_realm_by_id(pool: &PgPool, id: Uuid) -> Result, A let realm = sqlx::query_as::<_, Realm>( r#" SELECT - id, - name, - slug, - description, - tagline, - owner_id, - privacy, - is_nsfw, - min_reputation_tier, - theme_color, - banner_image_path, - thumbnail_path, - max_users, - allow_guest_access, - default_scene_id, - member_count, - current_user_count, - created_at, - updated_at - FROM realm.realms - WHERE id = $1 + r.id, + r.name, + r.slug, + r.description, + r.tagline, + r.owner_id, + r.privacy, + r.is_nsfw, + r.min_reputation_tier, + r.theme_color, + r.banner_image_path, + r.thumbnail_path, + r.max_users, + r.allow_guest_access, + r.default_scene_id, + r.member_count, + COALESCE(( + SELECT COUNT(*)::INTEGER + FROM scene.instance_members im + JOIN realm.scenes s ON im.instance_id = s.id + WHERE s.realm_id = r.id + ), 0) AS current_user_count, + r.created_at, + r.updated_at + FROM realm.realms r + WHERE r.id = $1 "#, ) .bind(id) @@ -153,18 +163,23 @@ pub async fn list_public_realms( sqlx::query_as::<_, RealmSummary>( r#" SELECT - id, - name, - slug, - tagline, - privacy, - is_nsfw, - thumbnail_path, - member_count, - current_user_count - FROM realm.realms - WHERE privacy = 'public' - ORDER BY current_user_count DESC, member_count DESC + r.id, + r.name, + r.slug, + r.tagline, + r.privacy, + r.is_nsfw, + r.thumbnail_path, + r.member_count, + COALESCE(( + SELECT COUNT(*)::INTEGER + FROM scene.instance_members im + JOIN realm.scenes s ON im.instance_id = s.id + WHERE s.realm_id = r.id + ), 0) AS current_user_count + FROM realm.realms r + WHERE r.privacy = 'public' + ORDER BY current_user_count DESC, r.member_count DESC LIMIT $1 OFFSET $2 "#, ) @@ -176,18 +191,23 @@ pub async fn list_public_realms( sqlx::query_as::<_, RealmSummary>( r#" SELECT - id, - name, - slug, - tagline, - privacy, - is_nsfw, - thumbnail_path, - member_count, - current_user_count - FROM realm.realms - WHERE privacy = 'public' AND is_nsfw = false - ORDER BY current_user_count DESC, member_count DESC + r.id, + r.name, + r.slug, + r.tagline, + r.privacy, + r.is_nsfw, + r.thumbnail_path, + r.member_count, + COALESCE(( + SELECT COUNT(*)::INTEGER + FROM scene.instance_members im + JOIN realm.scenes s ON im.instance_id = s.id + WHERE s.realm_id = r.id + ), 0) AS current_user_count + FROM realm.realms r + WHERE r.privacy = 'public' AND r.is_nsfw = false + ORDER BY current_user_count DESC, r.member_count DESC LIMIT $1 OFFSET $2 "#, ) @@ -205,18 +225,23 @@ pub async fn get_user_realms(pool: &PgPool, user_id: Uuid) -> Result( r#" SELECT - id, - name, - slug, - tagline, - privacy, - is_nsfw, - thumbnail_path, - member_count, - current_user_count - FROM realm.realms - WHERE owner_id = $1 - ORDER BY created_at DESC + r.id, + r.name, + r.slug, + r.tagline, + r.privacy, + r.is_nsfw, + r.thumbnail_path, + r.member_count, + COALESCE(( + SELECT COUNT(*)::INTEGER + FROM scene.instance_members im + JOIN realm.scenes s ON im.instance_id = s.id + WHERE s.realm_id = r.id + ), 0) AS current_user_count + FROM realm.realms r + WHERE r.owner_id = $1 + ORDER BY r.created_at DESC "#, ) .bind(user_id) diff --git a/crates/chattyness-user-ui/src/components/layout.rs b/crates/chattyness-user-ui/src/components/layout.rs index 9f12aac..6892f82 100644 --- a/crates/chattyness-user-ui/src/components/layout.rs +++ b/crates/chattyness-user-ui/src/components/layout.rs @@ -42,14 +42,14 @@ pub fn RealmHeader( realm_description: Option, scene_name: String, scene_description: Option, - online_count: i32, + online_count: Signal, total_members: i32, max_capacity: i32, can_admin: bool, on_logout: Callback<()>, ) -> impl IntoView { let stats_tooltip = format!("Members: {} / Max: {}", total_members, max_capacity); - let online_text = format!("{} ONLINE", online_count); + let online_text = move || format!("{} ONLINE", online_count.get()); let admin_url = format!("/admin/realms/{}", realm_slug); view! { diff --git a/crates/chattyness-user-ui/src/pages/realm.rs b/crates/chattyness-user-ui/src/pages/realm.rs index 38f9609..78cd484 100644 --- a/crates/chattyness-user-ui/src/pages/realm.rs +++ b/crates/chattyness-user-ui/src/pages/realm.rs @@ -686,7 +686,8 @@ pub fn RealmPage() -> impl IntoView { let realm_name = realm.name.clone(); let realm_slug_val = realm.slug.clone(); let realm_description = realm.tagline.clone(); - let online_count = realm.current_user_count; + // Derive online count reactively from members signal + let online_count = Signal::derive(move || members.get().len() as i32); let total_members = realm.member_count; let max_capacity = realm.max_users; let scene_name = scene_info.0; From 84cb4e5e784fa98eba062a39ea3a471016734a14c93ddc41b9390d51930e0c88 Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Sun, 18 Jan 2026 22:43:30 -0600 Subject: [PATCH 03/10] fix: message bug and context menu bugs --- .../chattyness-user-ui/src/components/chat.rs | 41 +++++++++----- .../src/components/context_menu.rs | 53 ++++++++++++++++--- .../src/components/scene_viewer.rs | 1 + 3 files changed, 76 insertions(+), 19 deletions(-) diff --git a/crates/chattyness-user-ui/src/components/chat.rs b/crates/chattyness-user-ui/src/components/chat.rs index 1ff1f6e..18fdabd 100644 --- a/crates/chattyness-user-ui/src/components/chat.rs +++ b/crates/chattyness-user-ui/src/components/chat.rs @@ -162,25 +162,24 @@ pub fn ChatInput( return; }; - // Pre-fill with /whisper command - let placeholder = "your message here"; - let whisper_text = format!("/whisper {} {}", target_name, placeholder); + // Pre-fill with /whisper command prefix only (no placeholder text) + // User types their message after the space + // parse_whisper_command already rejects empty messages + let whisper_prefix = format!("/whisper {} ", target_name); if let Some(input) = input_ref.get() { // Set the message - set_message.set(whisper_text.clone()); + set_message.set(whisper_prefix.clone()); + // Don't show hint - user already knows they're whispering set_command_mode.set(CommandMode::None); // Update input value - input.set_value(&whisper_text); + input.set_value(&whisper_prefix); - // Focus the input + // Focus the input and position cursor at end let _ = input.focus(); - - // Select the placeholder text so it gets replaced when typing - let prefix_len = format!("/whisper {} ", target_name).len() as u32; - let total_len = whisper_text.len() as u32; - let _ = input.set_selection_range(prefix_len, total_len); + let len = whisper_prefix.len() as u32; + let _ = input.set_selection_range(len, len); } }); } @@ -230,8 +229,24 @@ pub fn ChatInput( let cmd = value[1..].to_lowercase(); // Show hint for slash commands (don't execute until Enter) - // Match: /s[etting], /i[nventory], /w[hisper], or their full forms with args - if cmd.is_empty() + // Match: /s[etting], /i[nventory], /w[hisper] + // But NOT when whisper command is complete (has name + space for message) + let is_complete_whisper = { + // Check if it's "/w name " or "/whisper name " (name followed by space) + let rest = cmd.strip_prefix("whisper ").or_else(|| cmd.strip_prefix("w ")); + if let Some(after_cmd) = rest { + // If there's content after the command and it contains a space, + // user has typed "name " and is now typing the message + after_cmd.contains(' ') + } else { + false + } + }; + + if is_complete_whisper { + // User is typing the message part, no hint needed + set_command_mode.set(CommandMode::None); + } else if cmd.is_empty() || "setting".starts_with(&cmd) || "inventory".starts_with(&cmd) || "whisper".starts_with(&cmd) diff --git a/crates/chattyness-user-ui/src/components/context_menu.rs b/crates/chattyness-user-ui/src/components/context_menu.rs index 236b16a..c766d95 100644 --- a/crates/chattyness-user-ui/src/components/context_menu.rs +++ b/crates/chattyness-user-ui/src/components/context_menu.rs @@ -24,6 +24,7 @@ pub struct ContextMenuItem { /// Props: /// - `open`: Whether the menu is currently visible /// - `position`: The (x, y) position in client coordinates where the menu should appear +/// - `header`: Optional header text displayed at the top (e.g., username) /// - `items`: The menu items to display /// - `on_select`: Callback when a menu item is selected, receives the action string /// - `on_close`: Callback when the menu should close (click outside, escape, etc.) @@ -35,6 +36,9 @@ pub fn ContextMenu( /// Position (x, y) in client coordinates. #[prop(into)] position: Signal<(f64, f64)>, + /// Optional header text (e.g., username) displayed above the menu items. + #[prop(optional, into)] + header: Option>>, /// Menu items to display. #[prop(into)] items: Signal>, @@ -82,12 +86,40 @@ pub fn ContextMenu( ) }; - // Click outside handler + // Click outside handler - use Effect with cleanup to properly remove handlers #[cfg(feature = "hydrate")] { + use std::cell::RefCell; + use std::rc::Rc; use wasm_bindgen::{JsCast, closure::Closure}; + // Store closures so we can remove them on cleanup + let mousedown_closure: Rc>>> = + Rc::new(RefCell::new(None)); + let keydown_closure: Rc>>> = + Rc::new(RefCell::new(None)); + + let mousedown_closure_clone = mousedown_closure.clone(); + let keydown_closure_clone = keydown_closure.clone(); + Effect::new(move |_| { + let window = web_sys::window().unwrap(); + + // Clean up previous handlers first + if let Some(old_handler) = mousedown_closure_clone.borrow_mut().take() { + let _ = window.remove_event_listener_with_callback( + "mousedown", + old_handler.as_ref().unchecked_ref(), + ); + } + if let Some(old_handler) = keydown_closure_clone.borrow_mut().take() { + let _ = window.remove_event_listener_with_callback( + "keydown", + old_handler.as_ref().unchecked_ref(), + ); + } + + // Only add handlers when menu is open if !open.get() { return; } @@ -100,6 +132,7 @@ pub fn ContextMenu( let menu_el: web_sys::HtmlElement = menu_el.into(); let menu_el_clone = menu_el.clone(); + // Mousedown handler for click-outside detection let handler = Closure::::new(move |ev: web_sys::MouseEvent| { if let Some(target) = ev.target() { @@ -111,9 +144,9 @@ pub fn ContextMenu( } }); - let window = web_sys::window().unwrap(); let _ = window .add_event_listener_with_callback("mousedown", handler.as_ref().unchecked_ref()); + *mousedown_closure_clone.borrow_mut() = Some(handler); // Escape key handler let on_close_esc = on_close.clone(); @@ -129,10 +162,7 @@ pub fn ContextMenu( "keydown", keydown_handler.as_ref().unchecked_ref(), ); - - // Store handlers to clean up (they get cleaned up when Effect reruns) - handler.forget(); - keydown_handler.forget(); + *keydown_closure_clone.borrow_mut() = Some(keydown_handler); }); } @@ -145,6 +175,17 @@ pub fn ContextMenu( role="menu" aria-label="Context menu" > + // Header with username and divider + {move || { + header.and_then(|h| h.get()).map(|header_text| { + view! { +
+
{header_text}
+
+
+ } + }) + }} Date: Sun, 18 Jan 2026 23:12:24 -0600 Subject: [PATCH 04/10] fix: reconnect on ws failure --- apps/chattyness-app/style/tailwind.css | 10 + crates/chattyness-user-ui/src/components.rs | 2 + .../src/components/reconnection_overlay.rs | 391 ++++++++++++++++++ .../src/components/ws_client.rs | 4 + crates/chattyness-user-ui/src/pages/realm.rs | 26 +- 5 files changed, 430 insertions(+), 3 deletions(-) create mode 100644 crates/chattyness-user-ui/src/components/reconnection_overlay.rs diff --git a/apps/chattyness-app/style/tailwind.css b/apps/chattyness-app/style/tailwind.css index a3d3d22..8f689c9 100644 --- a/apps/chattyness-app/style/tailwind.css +++ b/apps/chattyness-app/style/tailwind.css @@ -75,4 +75,14 @@ .error-message { @apply p-4 bg-red-900/50 border border-red-500 rounded-lg text-red-200; } + + /* Reconnection overlay spinner animation */ + .reconnect-spinner { + animation: reconnect-pulse 1.5s ease-in-out infinite; + } +} + +@keyframes reconnect-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } } diff --git a/crates/chattyness-user-ui/src/components.rs b/crates/chattyness-user-ui/src/components.rs index 5c365db..ce93976 100644 --- a/crates/chattyness-user-ui/src/components.rs +++ b/crates/chattyness-user-ui/src/components.rs @@ -20,6 +20,7 @@ pub mod scene_viewer; pub mod settings; pub mod settings_popup; pub mod tabs; +pub mod reconnection_overlay; pub mod ws_client; pub use avatar_canvas::*; @@ -38,6 +39,7 @@ pub use layout::*; pub use modals::*; pub use notification_history::*; pub use notifications::*; +pub use reconnection_overlay::*; pub use scene_viewer::*; pub use settings::*; pub use settings_popup::*; diff --git a/crates/chattyness-user-ui/src/components/reconnection_overlay.rs b/crates/chattyness-user-ui/src/components/reconnection_overlay.rs new file mode 100644 index 0000000..c74c0cf --- /dev/null +++ b/crates/chattyness-user-ui/src/components/reconnection_overlay.rs @@ -0,0 +1,391 @@ +//! Reconnection overlay component. +//! +//! Displays a full-screen overlay when WebSocket connection is lost, +//! with countdown timer and automatic retry logic. + +use leptos::prelude::*; + +use super::ws_client::WsState; + +/// Reconnection attempt phase. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReconnectionPhase { + /// Initial phase: 5 attempts with 5-second countdown each. + Initial { attempt: u8 }, + /// Extended phase: 10 attempts with 10-second countdown each. + Extended { attempt: u8 }, + /// All attempts exhausted. + Failed, +} + +impl ReconnectionPhase { + /// Get the countdown duration in seconds for the current phase. + pub fn countdown_duration(&self) -> u32 { + match self { + Self::Initial { .. } => 5, + Self::Extended { .. } => 10, + Self::Failed => 0, + } + } + + /// Get the maximum attempts for the current phase. + pub fn max_attempts(&self) -> u8 { + match self { + Self::Initial { .. } => 5, + Self::Extended { .. } => 10, + Self::Failed => 0, + } + } + + /// Advance to the next attempt or phase. + pub fn next(self) -> Self { + match self { + Self::Initial { attempt } if attempt < 5 => Self::Initial { attempt: attempt + 1 }, + Self::Initial { .. } => Self::Extended { attempt: 1 }, + Self::Extended { attempt } if attempt < 10 => Self::Extended { attempt: attempt + 1 }, + Self::Extended { .. } | Self::Failed => Self::Failed, + } + } + + /// Get the current attempt number. + pub fn attempt(&self) -> u8 { + match self { + Self::Initial { attempt } | Self::Extended { attempt } => *attempt, + Self::Failed => 0, + } + } + + /// Check if this is the initial phase. + pub fn is_initial(&self) -> bool { + matches!(self, Self::Initial { .. }) + } +} + +/// Internal state for the reconnection overlay. +#[derive(Clone, Copy, Debug)] +enum OverlayState { + /// Hidden (connected). + Hidden, + /// Showing countdown. + Countdown { + phase: ReconnectionPhase, + remaining: u32, + }, + /// Currently attempting to reconnect. + Reconnecting { phase: ReconnectionPhase }, + /// All attempts failed. + Failed, +} + +/// Reconnection overlay component. +/// +/// Shows a full-screen overlay when WebSocket connection is lost, +/// with countdown timer and automatic retry logic. +#[component] +pub fn ReconnectionOverlay( + /// WebSocket connection state to monitor. + ws_state: Signal, + /// Callback to trigger a reconnection attempt. + on_reconnect: Callback<()>, +) -> impl IntoView { + // Internal overlay state + let (overlay_state, set_overlay_state) = signal(OverlayState::Hidden); + + // Timer handle stored for cleanup + #[cfg(feature = "hydrate")] + let timer_handle: std::rc::Rc>> = + std::rc::Rc::new(std::cell::RefCell::new(None)); + + // Watch for WebSocket state changes + #[cfg(feature = "hydrate")] + { + let timer_handle = timer_handle.clone(); + + Effect::new(move |_| { + let state = ws_state.get(); + + match state { + WsState::Connected => { + // Connection restored - hide overlay and stop timer + if let Some(timer) = timer_handle.borrow_mut().take() { + drop(timer); + } + set_overlay_state.set(OverlayState::Hidden); + } + WsState::Disconnected | WsState::Error => { + // Check current state - only start countdown if we're hidden + let current = overlay_state.get_untracked(); + if matches!(current, OverlayState::Hidden) { + // Start initial countdown + let phase = ReconnectionPhase::Initial { attempt: 1 }; + let duration = phase.countdown_duration(); + set_overlay_state.set(OverlayState::Countdown { + phase, + remaining: duration, + }); + + // Start timer + start_countdown_timer( + timer_handle.clone(), + set_overlay_state, + on_reconnect.clone(), + ); + } else if matches!(current, OverlayState::Reconnecting { .. }) { + // Reconnection attempt failed - advance to next attempt + if let OverlayState::Reconnecting { phase } = current { + let next_phase = phase.next(); + if matches!(next_phase, ReconnectionPhase::Failed) { + set_overlay_state.set(OverlayState::Failed); + } else { + let duration = next_phase.countdown_duration(); + set_overlay_state.set(OverlayState::Countdown { + phase: next_phase, + remaining: duration, + }); + // Restart timer for next countdown + start_countdown_timer( + timer_handle.clone(), + set_overlay_state, + on_reconnect.clone(), + ); + } + } + } + } + WsState::Connecting => { + // Currently attempting to connect - keep current state + // The reconnecting state should already be set + } + } + }); + } + + // Render based on state + move || { + let state = overlay_state.get(); + + match state { + OverlayState::Hidden => None, + OverlayState::Countdown { phase, remaining } => { + let (phase_text, attempt_text) = match phase { + ReconnectionPhase::Initial { attempt } => { + ("Attempt", format!("{} of 5", attempt)) + } + ReconnectionPhase::Extended { attempt } => { + ("Extended attempt", format!("{} of 10", attempt)) + } + ReconnectionPhase::Failed => ("", String::new()), + }; + + Some( + view! { +
+ // Darkened backdrop + + + // Dialog box +
+ // Countdown circle +
+ + {remaining} + +
+ +

+ "Lost connection..." +

+ +

+ {format!("attempting to reconnect in {} seconds", remaining)} +

+ +

+ {format!("{} {}", phase_text, attempt_text)} +

+
+
+ } + .into_any(), + ) + } + OverlayState::Reconnecting { phase } => { + let (phase_text, attempt_text) = match phase { + ReconnectionPhase::Initial { attempt } => { + ("Attempt", format!("{} of 5", attempt)) + } + ReconnectionPhase::Extended { attempt } => { + ("Extended attempt", format!("{} of 10", attempt)) + } + ReconnectionPhase::Failed => ("", String::new()), + }; + + Some( + view! { +
+ // Darkened backdrop + + + // Dialog box +
+ // Spinner +
+ + + + +
+ +

+ "Reconnecting..." +

+ +

"Attempting to restore connection"

+ +

+ {format!("{} {}", phase_text, attempt_text)} +

+
+
+ } + .into_any(), + ) + } + OverlayState::Failed => Some( + view! { +
+ // Darkened backdrop + + + // Dialog box +
+ // Error icon +
+ + + +
+ +

+ "Connection Failed" +

+ +

+ "Unable to reconnect after multiple attempts. Please check your network connection and try again." +

+ + +
+
+ } + .into_any(), + ), + } + } +} + +/// Start the countdown timer. +#[cfg(feature = "hydrate")] +fn start_countdown_timer( + timer_handle: std::rc::Rc>>, + set_overlay_state: WriteSignal, + on_reconnect: Callback<()>, +) { + use gloo_timers::callback::Interval; + + // Stop any existing timer + if let Some(old_timer) = timer_handle.borrow_mut().take() { + drop(old_timer); + } + + // Create new timer that ticks every second + let timer = Interval::new(1000, move || { + set_overlay_state.update(|state| { + if let OverlayState::Countdown { phase, remaining } = state { + if *remaining > 1 { + // Decrement countdown + *remaining -= 1; + } else { + // Countdown reached zero - trigger reconnection + *state = OverlayState::Reconnecting { phase: *phase }; + on_reconnect.run(()); + } + } + }); + }); + + *timer_handle.borrow_mut() = Some(timer); +} diff --git a/crates/chattyness-user-ui/src/components/ws_client.rs b/crates/chattyness-user-ui/src/components/ws_client.rs index a162edc..a4e6952 100644 --- a/crates/chattyness-user-ui/src/components/ws_client.rs +++ b/crates/chattyness-user-ui/src/components/ws_client.rs @@ -80,6 +80,7 @@ pub struct WsError { pub fn use_channel_websocket( realm_slug: Signal, channel_id: Signal>, + reconnect_trigger: Signal, on_members_update: Callback>, on_chat_message: Callback, on_loose_props_sync: Callback>, @@ -120,6 +121,8 @@ pub fn use_channel_websocket( 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() { @@ -458,6 +461,7 @@ fn handle_server_message( pub fn use_channel_websocket( _realm_slug: Signal, _channel_id: Signal>, + _reconnect_trigger: Signal, _on_members_update: Callback>, _on_chat_message: Callback, _on_loose_props_sync: Callback>, diff --git a/crates/chattyness-user-ui/src/pages/realm.rs b/crates/chattyness-user-ui/src/pages/realm.rs index 78cd484..9a28abe 100644 --- a/crates/chattyness-user-ui/src/pages/realm.rs +++ b/crates/chattyness-user-ui/src/pages/realm.rs @@ -14,8 +14,8 @@ use uuid::Uuid; use crate::components::{ ActiveBubble, AvatarEditorPopup, Card, ChatInput, ConversationModal, EmotionKeybindings, FadingMember, InventoryPopup, KeybindingsPopup, MessageLog, NotificationHistoryModal, - NotificationMessage, NotificationToast, RealmHeader, RealmSceneViewer, SettingsPopup, - ViewerSettings, + NotificationMessage, NotificationToast, RealmHeader, RealmSceneViewer, ReconnectionOverlay, + SettingsPopup, ViewerSettings, }; #[cfg(feature = "hydrate")] use crate::components::{ @@ -118,6 +118,9 @@ pub fn RealmPage() -> impl IntoView { // Error notification state (for whisper failures, etc.) let (error_message, set_error_message) = signal(Option::::None); + // Reconnection trigger - increment to force WebSocket reconnection + let (reconnect_trigger, set_reconnect_trigger) = signal(0u32); + let realm_data = LocalResource::new(move || { let slug = slug.get(); async move { @@ -330,9 +333,10 @@ pub fn RealmPage() -> impl IntoView { }); #[cfg(feature = "hydrate")] - let (_ws_state, ws_sender) = use_channel_websocket( + let (ws_state, ws_sender) = use_channel_websocket( slug, Signal::derive(move || channel_id.get()), + Signal::derive(move || reconnect_trigger.get()), on_members_update, on_chat_message, on_loose_props_sync, @@ -954,6 +958,22 @@ pub fn RealmPage() -> impl IntoView { /> } } + + // Reconnection overlay - shown when WebSocket disconnects + { + #[cfg(feature = "hydrate")] + let ws_state_for_overlay = ws_state; + #[cfg(not(feature = "hydrate"))] + let ws_state_for_overlay = Signal::derive(|| crate::components::ws_client::WsState::Disconnected); + view! { + + } + } } .into_any() } From 1f922f8221ff131751c16928320716a0eb640a76d8cb7a1184ee81e3d54a3b12 Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Sun, 18 Jan 2026 23:57:47 -0600 Subject: [PATCH 05/10] fix: make the default emotion happy --- crates/chattyness-db/src/queries/channel_members.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/chattyness-db/src/queries/channel_members.rs b/crates/chattyness-db/src/queries/channel_members.rs index 9e19d89..74252d6 100644 --- a/crates/chattyness-db/src/queries/channel_members.rs +++ b/crates/chattyness-db/src/queries/channel_members.rs @@ -67,7 +67,7 @@ pub async fn ensure_active_avatar<'e>( sqlx::query( r#" INSERT INTO auth.active_avatars (user_id, realm_id, avatar_id, current_emotion) - SELECT $1, $2, id, 0 + SELECT $1, $2, id, 1 FROM auth.avatars WHERE user_id = $1 AND slot_number = 0 ON CONFLICT (user_id, realm_id) DO NOTHING From 39750c1d8218c95b22dbb6cb68fc3ce72cd707df7510c79988ce87d38f2a1842 Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Mon, 19 Jan 2026 00:38:37 -0600 Subject: [PATCH 06/10] ui: inform guests of restricted interfaces --- crates/chattyness-db/src/models.rs | 3 + .../src/queries/channel_members.rs | 6 +- .../src/components/avatar_editor.rs | 11 ++ .../src/components/inventory.rs | 100 ++++++++++-------- .../src/components/modals.rs | 42 ++++++++ .../src/components/scene_viewer.rs | 15 ++- .../src/components/ws_client.rs | 3 + crates/chattyness-user-ui/src/pages/realm.rs | 6 ++ 8 files changed, 137 insertions(+), 49 deletions(-) diff --git a/crates/chattyness-db/src/models.rs b/crates/chattyness-db/src/models.rs index 9c4ac98..5d40fea 100644 --- a/crates/chattyness-db/src/models.rs +++ b/crates/chattyness-db/src/models.rs @@ -1829,6 +1829,9 @@ pub struct ChannelMemberInfo { /// Current emotion slot (0-9) pub current_emotion: i16, pub joined_at: DateTime, + /// Whether this user is a guest (has the 'guest' tag) + #[serde(default)] + pub is_guest: bool, } /// Request to update position in a channel. diff --git a/crates/chattyness-db/src/queries/channel_members.rs b/crates/chattyness-db/src/queries/channel_members.rs index 74252d6..230a81e 100644 --- a/crates/chattyness-db/src/queries/channel_members.rs +++ b/crates/chattyness-db/src/queries/channel_members.rs @@ -176,7 +176,8 @@ pub async fn get_channel_members<'e>( cm.is_moving, cm.is_afk, COALESCE(aa.current_emotion, 0::smallint) as current_emotion, - cm.joined_at + cm.joined_at, + COALESCE('guest' = ANY(u.tags), false) as is_guest FROM scene.instance_members cm LEFT JOIN auth.users u ON cm.user_id = u.id LEFT JOIN auth.guest_sessions gs ON cm.guest_session_id = gs.id @@ -214,7 +215,8 @@ pub async fn get_channel_member<'e>( cm.is_moving, cm.is_afk, COALESCE(aa.current_emotion, 0::smallint) as current_emotion, - cm.joined_at + cm.joined_at, + COALESCE('guest' = ANY(u.tags), false) as is_guest FROM scene.instance_members cm LEFT JOIN auth.users u ON cm.user_id = u.id LEFT JOIN auth.active_avatars aa ON cm.user_id = aa.user_id AND aa.realm_id = $3 diff --git a/crates/chattyness-user-ui/src/components/avatar_editor.rs b/crates/chattyness-user-ui/src/components/avatar_editor.rs index b4bb3b6..b1d7a00 100644 --- a/crates/chattyness-user-ui/src/components/avatar_editor.rs +++ b/crates/chattyness-user-ui/src/components/avatar_editor.rs @@ -12,6 +12,7 @@ use chattyness_db::models::{AvatarWithPaths, InventoryItem}; #[cfg(feature = "hydrate")] use chattyness_db::ws_messages::ClientMessage; +use super::modals::GuestLockedOverlay; use super::ws_client::WsSenderStorage; #[cfg(feature = "hydrate")] use crate::utils::normalize_asset_path; @@ -216,6 +217,7 @@ fn RenderedPreview(#[prop(into)] avatar: Signal>) -> imp /// - `realm_slug`: Current realm slug for API calls /// - `on_avatar_update`: Callback when avatar is updated /// - `ws_sender`: WebSocket sender for broadcasting avatar changes +/// - `is_guest`: Whether the current user is a guest (shows locked overlay) #[component] pub fn AvatarEditorPopup( #[prop(into)] open: Signal, @@ -224,7 +226,11 @@ pub fn AvatarEditorPopup( #[prop(into)] realm_slug: Signal, on_avatar_update: Callback, ws_sender: WsSenderStorage, + /// Whether the current user is a guest. Guests see a locked overlay. + #[prop(optional, into)] + is_guest: Option>, ) -> impl IntoView { + let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false)); // Tab state let (active_tab, set_active_tab) = signal(EditorTab::BaseLayers); @@ -798,6 +804,11 @@ pub fn AvatarEditorPopup( + + // Guest locked overlay + + + // Context menu diff --git a/crates/chattyness-user-ui/src/components/inventory.rs b/crates/chattyness-user-ui/src/components/inventory.rs index c9eaf67..add9e96 100644 --- a/crates/chattyness-user-ui/src/components/inventory.rs +++ b/crates/chattyness-user-ui/src/components/inventory.rs @@ -8,7 +8,7 @@ use chattyness_db::models::{InventoryItem, PublicProp}; #[cfg(feature = "hydrate")] use chattyness_db::ws_messages::ClientMessage; -use super::modals::Modal; +use super::modals::{GuestLockedOverlay, Modal}; use super::tabs::{Tab, TabBar}; use super::ws_client::WsSender; @@ -24,13 +24,18 @@ use super::ws_client::WsSender; /// - `on_close`: Callback when popup should close /// - `ws_sender`: WebSocket sender for dropping props /// - `realm_slug`: Current realm slug for fetching realm props +/// - `is_guest`: Whether the current user is a guest (shows locked overlay) #[component] pub fn InventoryPopup( #[prop(into)] open: Signal, on_close: Callback<()>, ws_sender: StoredValue, LocalStorage>, #[prop(into)] realm_slug: Signal, + /// Whether the current user is a guest. Guests see a locked overlay. + #[prop(optional, into)] + is_guest: Option>, ) -> impl IntoView { + let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false)); // Tab state let (active_tab, set_active_tab) = signal("my_inventory"); @@ -238,52 +243,59 @@ pub fn InventoryPopup( max_width="max-w-2xl" class="max-h-[80vh] flex flex-col" > - // Tab bar - +
+ // Tab bar + - // Tab content -
- // My Inventory tab - - - + // Tab content +
+ // My Inventory tab + + + - // Server tab - - - + // Server tab + + + - // Realm tab - - + // Realm tab + + + +
+ + // Guest locked overlay + +
diff --git a/crates/chattyness-user-ui/src/components/modals.rs b/crates/chattyness-user-ui/src/components/modals.rs index 5baa588..3bcde67 100644 --- a/crates/chattyness-user-ui/src/components/modals.rs +++ b/crates/chattyness-user-ui/src/components/modals.rs @@ -314,3 +314,45 @@ pub fn ConfirmModal( } } + +// ============================================================================ +// Guest Locked Overlay +// ============================================================================ + +/// Overlay displayed when a feature is restricted to registered users. +/// +/// Shows a semi-transparent backdrop with a padlock icon and diagonal +/// "Registered Users" text. Designed to be placed inside a modal container +/// with `position: relative`. +/// +/// # Example +/// +/// ```ignore +///
+/// // Modal content here +/// +///
+/// ``` +#[component] +pub fn GuestLockedOverlay() -> impl IntoView { + view! { + + } +} diff --git a/crates/chattyness-user-ui/src/components/scene_viewer.rs b/crates/chattyness-user-ui/src/components/scene_viewer.rs index 664cac7..23dff40 100644 --- a/crates/chattyness-user-ui/src/components/scene_viewer.rs +++ b/crates/chattyness-user-ui/src/components/scene_viewer.rs @@ -56,10 +56,14 @@ pub fn RealmSceneViewer( /// Current user's guest_session_id (for context menu filtering). #[prop(optional, into)] current_guest_session_id: Option>>, + /// Whether the current user is a guest (guests cannot use context menu). + #[prop(optional, into)] + is_guest: Option>, /// Callback when whisper is requested on a member. #[prop(optional, into)] on_whisper_request: Option>, ) -> impl IntoView { + let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false)); // Use default settings if none provided let settings = settings.unwrap_or_else(|| Signal::derive(ViewerSettings::default)); let dimensions = parse_bounds_dimensions(&scene.bounds_wkt); @@ -183,14 +187,19 @@ pub fn RealmSceneViewer( move |ev: web_sys::MouseEvent| { use wasm_bindgen::JsCast; - // Get click position - let client_x = ev.client_x() as f64; - let client_y = ev.client_y() as f64; + // Guests cannot message other users - don't show context menu + if is_guest.get() { + return; + } // Get current user identity for filtering let my_user_id = current_user_id.map(|s| s.get()).flatten(); let my_guest_session_id = current_guest_session_id.map(|s| s.get()).flatten(); + // Get click position + let client_x = ev.client_x() as f64; + let client_y = ev.client_y() as f64; + // Query all avatar canvases and check for hit let document = web_sys::window().unwrap().document().unwrap(); diff --git a/crates/chattyness-user-ui/src/components/ws_client.rs b/crates/chattyness-user-ui/src/components/ws_client.rs index a4e6952..ef026fa 100644 --- a/crates/chattyness-user-ui/src/components/ws_client.rs +++ b/crates/chattyness-user-ui/src/components/ws_client.rs @@ -60,6 +60,8 @@ pub struct ChannelMemberInfo { pub guest_session_id: Option, /// 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. @@ -240,6 +242,7 @@ pub fn use_channel_websocket( 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); } diff --git a/crates/chattyness-user-ui/src/pages/realm.rs b/crates/chattyness-user-ui/src/pages/realm.rs index 9a28abe..882847f 100644 --- a/crates/chattyness-user-ui/src/pages/realm.rs +++ b/crates/chattyness-user-ui/src/pages/realm.rs @@ -98,6 +98,8 @@ pub fn RealmPage() -> impl IntoView { // Current user identity (received from WebSocket Welcome message) let (current_user_id, set_current_user_id) = signal(Option::::None); let (current_guest_session_id, set_current_guest_session_id) = signal(Option::::None); + // Whether the current user is a guest (has the 'guest' tag) + let (is_guest, set_is_guest) = signal(false); // Whisper target - when set, triggers pre-fill in ChatInput let (whisper_target, set_whisper_target) = signal(Option::::None); @@ -313,6 +315,7 @@ pub fn RealmPage() -> impl IntoView { set_current_user_id.set(info.user_id); set_current_guest_session_id.set(info.guest_session_id); set_current_display_name.set(info.display_name.clone()); + set_is_guest.set(info.is_guest); }); // Callback for WebSocket errors (whisper failures, etc.) @@ -772,6 +775,7 @@ pub fn RealmPage() -> impl IntoView { fading_members=Signal::derive(move || fading_members.get()) current_user_id=Signal::derive(move || current_user_id.get()) current_guest_session_id=Signal::derive(move || current_guest_session_id.get()) + is_guest=Signal::derive(move || is_guest.get()) on_whisper_request=on_whisper_request_cb />
@@ -823,6 +827,7 @@ pub fn RealmPage() -> impl IntoView { }) ws_sender=ws_sender_for_inv realm_slug=Signal::derive(move || slug.get()) + is_guest=Signal::derive(move || is_guest.get()) /> } } @@ -867,6 +872,7 @@ pub fn RealmPage() -> impl IntoView { set_skin_preview_path.set(updated.skin_layer[4].clone()); }) ws_sender=ws_sender_for_avatar + is_guest=Signal::derive(move || is_guest.get()) /> } } From 226c2e02b57b479d5f5b894ceccdd0667ffb7d2b59fecf9daf95ca9bc600ac50 Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Mon, 19 Jan 2026 01:03:46 -0600 Subject: [PATCH 07/10] fix: feature gate some guest features in the backend --- crates/chattyness-db/src/models.rs | 7 +++++++ crates/chattyness-user-ui/src/api/avatars.rs | 14 ++++++++++++++ crates/chattyness-user-ui/src/api/websocket.rs | 12 ++++++++++++ 3 files changed, 33 insertions(+) diff --git a/crates/chattyness-db/src/models.rs b/crates/chattyness-db/src/models.rs index 5d40fea..1378e00 100644 --- a/crates/chattyness-db/src/models.rs +++ b/crates/chattyness-db/src/models.rs @@ -451,6 +451,13 @@ pub struct User { pub updated_at: DateTime, } +impl User { + /// Check if this user is a guest (has the Guest tag). + pub fn is_guest(&self) -> bool { + self.tags.contains(&UserTag::Guest) + } +} + /// Minimal user info for display purposes. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UserSummary { diff --git a/crates/chattyness-user-ui/src/api/avatars.rs b/crates/chattyness-user-ui/src/api/avatars.rs index c3c344d..c1b99ba 100644 --- a/crates/chattyness-user-ui/src/api/avatars.rs +++ b/crates/chattyness-user-ui/src/api/avatars.rs @@ -53,6 +53,13 @@ pub async fn assign_slot( Path(slug): Path, Json(req): Json, ) -> Result, AppError> { + // Guests cannot customize their avatar + if user.is_guest() { + return Err(AppError::Forbidden( + "Avatar customization is disabled for guests, please register first.".to_string(), + )); + } + req.validate()?; let mut conn = rls_conn.acquire().await; @@ -93,6 +100,13 @@ pub async fn clear_slot( Path(slug): Path, Json(req): Json, ) -> Result, AppError> { + // Guests cannot customize their avatar + if user.is_guest() { + return Err(AppError::Forbidden( + "Avatar customization is disabled for guests, please register first.".to_string(), + )); + } + req.validate()?; let mut conn = rls_conn.acquire().await; diff --git a/crates/chattyness-user-ui/src/api/websocket.rs b/crates/chattyness-user-ui/src/api/websocket.rs index fa958dc..32a5274 100644 --- a/crates/chattyness-user-ui/src/api/websocket.rs +++ b/crates/chattyness-user-ui/src/api/websocket.rs @@ -353,6 +353,7 @@ async fn handle_socket( let _ = channel_state.tx.send(join_msg); let user_id = user.id; + let is_guest = user.is_guest(); let tx = channel_state.tx.clone(); // Acquire a second dedicated connection for the receive task @@ -509,6 +510,17 @@ async fn handle_socket( // Handle whisper (direct message) vs broadcast if let Some(target_name) = target_display_name { + // Check if guest is trying to whisper + if is_guest { + let _ = direct_tx + .send(ServerMessage::Error { + code: "GUEST_FEATURE_DISABLED".to_string(), + message: "Private messaging is disabled for guests, please register first.".to_string(), + }) + .await; + continue; + } + // Whisper: send directly to target user if let Some((_target_user_id, target_conn)) = ws_state .find_user_by_display_name(realm_id, &target_name) From 32e5e424621c9c2d7ab25d06ca61c6b887d073c67265b97d68bae81a0e7296e9 Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Mon, 19 Jan 2026 11:48:12 -0600 Subject: [PATCH 08/10] feat: add teleport --- crates/chattyness-db/src/models.rs | 4 + .../chattyness-db/src/queries/owner/realms.rs | 8 +- crates/chattyness-db/src/queries/realms.rs | 8 +- crates/chattyness-db/src/ws_messages.rs | 14 ++ .../chattyness-user-ui/src/api/websocket.rs | 88 +++++++ crates/chattyness-user-ui/src/components.rs | 2 + .../chattyness-user-ui/src/components/chat.rs | 220 +++++++++++++++++- .../src/components/scene_list_popup.rs | 127 ++++++++++ .../src/components/ws_client.rs | 25 ++ crates/chattyness-user-ui/src/pages/realm.rs | 122 +++++++++- db/schema/tables/030_realm.sql | 1 + 11 files changed, 603 insertions(+), 16 deletions(-) create mode 100644 crates/chattyness-user-ui/src/components/scene_list_popup.rs diff --git a/crates/chattyness-db/src/models.rs b/crates/chattyness-db/src/models.rs index 1378e00..c943e18 100644 --- a/crates/chattyness-db/src/models.rs +++ b/crates/chattyness-db/src/models.rs @@ -489,6 +489,7 @@ pub struct Realm { pub thumbnail_path: Option, pub max_users: i32, pub allow_guest_access: bool, + pub allow_user_teleport: bool, pub default_scene_id: Option, pub member_count: i32, pub current_user_count: i32, @@ -516,6 +517,7 @@ pub struct CreateRealmRequest { pub is_nsfw: bool, pub max_users: i32, pub allow_guest_access: bool, + pub allow_user_teleport: bool, pub theme_color: Option, } @@ -1361,6 +1363,7 @@ pub struct RealmDetail { pub thumbnail_path: Option, pub max_users: i32, pub allow_guest_access: bool, + pub allow_user_teleport: bool, pub member_count: i32, pub current_user_count: i32, pub created_at: DateTime, @@ -1377,6 +1380,7 @@ pub struct UpdateRealmRequest { pub is_nsfw: bool, pub max_users: i32, pub allow_guest_access: bool, + pub allow_user_teleport: bool, pub theme_color: Option, } diff --git a/crates/chattyness-db/src/queries/owner/realms.rs b/crates/chattyness-db/src/queries/owner/realms.rs index 156d6f0..17ca002 100644 --- a/crates/chattyness-db/src/queries/owner/realms.rs +++ b/crates/chattyness-db/src/queries/owner/realms.rs @@ -254,6 +254,7 @@ pub async fn get_realm_by_slug(pool: &PgPool, slug: &str) -> Result( r.thumbnail_path, r.max_users, r.allow_guest_access, + r.allow_user_teleport, r.default_scene_id, r.member_count, COALESCE(( @@ -131,6 +134,7 @@ pub async fn get_realm_by_id(pool: &PgPool, id: Uuid) -> Result, A r.thumbnail_path, r.max_users, r.allow_guest_access, + r.allow_user_teleport, r.default_scene_id, r.member_count, COALESCE(( diff --git a/crates/chattyness-db/src/ws_messages.rs b/crates/chattyness-db/src/ws_messages.rs index 80ad935..0068dc7 100644 --- a/crates/chattyness-db/src/ws_messages.rs +++ b/crates/chattyness-db/src/ws_messages.rs @@ -76,6 +76,12 @@ pub enum ClientMessage { /// Request to broadcast avatar appearance to other users. SyncAvatar, + + /// Request to teleport to a different scene. + Teleport { + /// Scene ID to teleport to. + scene_id: Uuid, + }, } /// Server-to-client WebSocket messages. @@ -212,4 +218,12 @@ pub enum ServerMessage { /// Updated avatar render data. avatar: AvatarRenderData, }, + + /// Teleport approved - client should disconnect and reconnect to new scene. + TeleportApproved { + /// Scene ID to navigate to. + scene_id: Uuid, + /// Scene slug for URL. + scene_slug: String, + }, } diff --git a/crates/chattyness-user-ui/src/api/websocket.rs b/crates/chattyness-user-ui/src/api/websocket.rs index 32a5274..934825c 100644 --- a/crates/chattyness-user-ui/src/api/websocket.rs +++ b/crates/chattyness-user-ui/src/api/websocket.rs @@ -391,11 +391,15 @@ async fn handle_socket( // Clone ws_state for use in recv_task let ws_state_for_recv = ws_state.clone(); + // Clone pool for use in recv_task (for teleport queries) + let pool_for_recv = pool.clone(); + // Create recv timeout from config let recv_timeout = Duration::from_secs(ws_config.recv_timeout_secs); // Spawn task to handle incoming messages from client let recv_task = tokio::spawn(async move { + let pool = pool_for_recv; let ws_state = ws_state_for_recv; let mut disconnect_reason = DisconnectReason::Graceful; @@ -724,6 +728,90 @@ async fn handle_socket( } } } + ClientMessage::Teleport { scene_id } => { + // Validate teleport permission and scene + // 1. Check realm allows user teleport + let realm = match realms::get_realm_by_id( + &pool, + realm_id, + ) + .await + { + Ok(Some(r)) => r, + Ok(None) => { + let _ = direct_tx.send(ServerMessage::Error { + code: "REALM_NOT_FOUND".to_string(), + message: "Realm not found".to_string(), + }).await; + continue; + } + Err(e) => { + tracing::error!("[WS] Teleport realm lookup failed: {:?}", e); + let _ = direct_tx.send(ServerMessage::Error { + code: "TELEPORT_FAILED".to_string(), + message: "Failed to verify teleport permission".to_string(), + }).await; + continue; + } + }; + + if !realm.allow_user_teleport { + let _ = direct_tx.send(ServerMessage::Error { + code: "TELEPORT_DISABLED".to_string(), + message: "Teleporting is not enabled for this realm".to_string(), + }).await; + continue; + } + + // 2. Validate scene exists, belongs to realm, and is not hidden + let scene = match scenes::get_scene_by_id(&pool, scene_id).await { + Ok(Some(s)) => s, + Ok(None) => { + let _ = direct_tx.send(ServerMessage::Error { + code: "SCENE_NOT_FOUND".to_string(), + message: "Scene not found".to_string(), + }).await; + continue; + } + Err(e) => { + tracing::error!("[WS] Teleport scene lookup failed: {:?}", e); + let _ = direct_tx.send(ServerMessage::Error { + code: "TELEPORT_FAILED".to_string(), + message: "Failed to verify scene".to_string(), + }).await; + continue; + } + }; + + if scene.realm_id != realm_id { + let _ = direct_tx.send(ServerMessage::Error { + code: "SCENE_NOT_IN_REALM".to_string(), + message: "Scene does not belong to this realm".to_string(), + }).await; + continue; + } + + if scene.is_hidden { + let _ = direct_tx.send(ServerMessage::Error { + code: "SCENE_HIDDEN".to_string(), + message: "Cannot teleport to a hidden scene".to_string(), + }).await; + continue; + } + + // 3. Send approval - client will disconnect and reconnect + #[cfg(debug_assertions)] + tracing::debug!( + "[WS] User {} teleporting to scene {} ({})", + user_id, + scene.name, + scene.slug + ); + let _ = direct_tx.send(ServerMessage::TeleportApproved { + scene_id: scene.id, + scene_slug: scene.slug, + }).await; + } } } Message::Close(close_frame) => { diff --git a/crates/chattyness-user-ui/src/components.rs b/crates/chattyness-user-ui/src/components.rs index ce93976..5779f00 100644 --- a/crates/chattyness-user-ui/src/components.rs +++ b/crates/chattyness-user-ui/src/components.rs @@ -16,6 +16,7 @@ pub mod layout; pub mod modals; pub mod notification_history; pub mod notifications; +pub mod scene_list_popup; pub mod scene_viewer; pub mod settings; pub mod settings_popup; @@ -40,6 +41,7 @@ pub use modals::*; pub use notification_history::*; pub use notifications::*; pub use reconnection_overlay::*; +pub use scene_list_popup::*; pub use scene_viewer::*; pub use settings::*; pub use settings_popup::*; diff --git a/crates/chattyness-user-ui/src/components/chat.rs b/crates/chattyness-user-ui/src/components/chat.rs index 18fdabd..0be1a35 100644 --- a/crates/chattyness-user-ui/src/components/chat.rs +++ b/crates/chattyness-user-ui/src/components/chat.rs @@ -1,11 +1,13 @@ //! Chat components for realm chat interface. use leptos::prelude::*; +use uuid::Uuid; -use chattyness_db::models::EmotionAvailability; +use chattyness_db::models::{EmotionAvailability, SceneSummary}; use chattyness_db::ws_messages::ClientMessage; use super::emotion_picker::{EMOTIONS, EmoteListPopup, LabelStyle}; +use super::scene_list_popup::SceneListPopup; use super::ws_client::WsSenderStorage; /// Command mode state for the chat input. @@ -19,6 +21,8 @@ enum CommandMode { ShowingSlashHint, /// Showing emotion list popup. ShowingList, + /// Showing scene list popup for teleport. + ShowingSceneList, } /// Parse an emote command and return the emotion name if valid. @@ -44,6 +48,28 @@ fn parse_emote_command(cmd: &str) -> Option { }) } +/// Parse a teleport command and return the scene slug if valid. +/// +/// Supports `/t slug` and `/teleport slug`. +fn parse_teleport_command(cmd: &str) -> Option { + let cmd = cmd.trim(); + + // Strip the leading slash if present + let cmd = cmd.strip_prefix('/').unwrap_or(cmd); + + // Check for `t ` or `teleport ` + let slug = cmd + .strip_prefix("teleport ") + .or_else(|| cmd.strip_prefix("t ")) + .map(str::trim)?; + + if slug.is_empty() { + return None; + } + + Some(slug.to_string()) +} + /// Parse a whisper command and return (target_name, message) if valid. /// /// Supports `/w name message` and `/whisper name message`. @@ -84,6 +110,9 @@ fn parse_whisper_command(cmd: &str) -> Option<(String, String)> { /// - `on_open_settings`: Callback to open settings popup /// - `on_open_inventory`: Callback to open inventory popup /// - `whisper_target`: Signal containing the display name to whisper to (triggers pre-fill) +/// - `scenes`: List of available scenes for teleport command +/// - `allow_user_teleport`: Whether teleporting is enabled for this realm +/// - `on_teleport`: Callback when a teleport is requested (receives scene ID) #[component] pub fn ChatInput( ws_sender: WsSenderStorage, @@ -97,11 +126,23 @@ pub fn ChatInput( /// Signal containing the display name to whisper to. When set, pre-fills the input. #[prop(optional, into)] whisper_target: Option>>, + /// List of available scenes for teleport command. + #[prop(optional, into)] + scenes: Option>>, + /// Whether teleporting is enabled for this realm. + #[prop(default = Signal::derive(|| false))] + allow_user_teleport: Signal, + /// Callback when a teleport is requested. + #[prop(optional)] + on_teleport: Option>, ) -> impl IntoView { let (message, set_message) = signal(String::new()); let (command_mode, set_command_mode) = signal(CommandMode::None); let (list_filter, set_list_filter) = signal(String::new()); let (selected_index, set_selected_index) = signal(0usize); + // Separate filter/index for scene list + let (scene_filter, set_scene_filter) = signal(String::new()); + let (scene_selected_index, set_scene_selected_index) = signal(0usize); let input_ref = NodeRef::::new(); // Compute filtered emotions for keyboard navigation @@ -121,6 +162,21 @@ pub fn ChatInput( .unwrap_or_default() }; + // Compute filtered scenes for teleport navigation + let filtered_scenes = move || { + let filter_text = scene_filter.get().to_lowercase(); + scenes + .map(|s| s.get()) + .unwrap_or_default() + .into_iter() + .filter(|s| { + filter_text.is_empty() + || s.name.to_lowercase().contains(&filter_text) + || s.slug.to_lowercase().contains(&filter_text) + }) + .collect::>() + }; + // Handle focus trigger from parent (when space, ':' or '/' is pressed globally) #[cfg(feature = "hydrate")] { @@ -204,13 +260,20 @@ pub fn ChatInput( let value = event_target_value(&ev); set_message.set(value.clone()); - // If list is showing, update filter (input is the filter text) + // If emotion list is showing, update filter (input is the filter text) if command_mode.get_untracked() == CommandMode::ShowingList { set_list_filter.set(value.clone()); set_selected_index.set(0); // Reset selection when filter changes return; } + // If scene list is showing, update filter (input is the filter text) + if command_mode.get_untracked() == CommandMode::ShowingSceneList { + set_scene_filter.set(value.clone()); + set_scene_selected_index.set(0); // Reset selection when filter changes + return; + } + if value.starts_with(':') { let cmd = value[1..].to_lowercase(); @@ -229,7 +292,7 @@ pub fn ChatInput( let cmd = value[1..].to_lowercase(); // Show hint for slash commands (don't execute until Enter) - // Match: /s[etting], /i[nventory], /w[hisper] + // Match: /s[etting], /i[nventory], /w[hisper], /t[eleport] // But NOT when whisper command is complete (has name + space for message) let is_complete_whisper = { // Check if it's "/w name " or "/whisper name " (name followed by space) @@ -243,18 +306,31 @@ pub fn ChatInput( } }; - if is_complete_whisper { - // User is typing the message part, no hint needed + // Check if teleport command is complete (has slug) + let is_complete_teleport = { + let rest = cmd.strip_prefix("teleport ").or_else(|| cmd.strip_prefix("t ")); + if let Some(after_cmd) = rest { + !after_cmd.is_empty() + } else { + false + } + }; + + if is_complete_whisper || is_complete_teleport { + // User is typing the argument part, no hint needed set_command_mode.set(CommandMode::None); } else if cmd.is_empty() || "setting".starts_with(&cmd) || "inventory".starts_with(&cmd) || "whisper".starts_with(&cmd) + || "teleport".starts_with(&cmd) || cmd == "setting" || cmd == "settings" || cmd == "inventory" || cmd.starts_with("w ") || cmd.starts_with("whisper ") + || cmd.starts_with("t ") + || cmd.starts_with("teleport ") { set_command_mode.set(CommandMode::ShowingSlashHint); } else { @@ -280,6 +356,8 @@ pub fn ChatInput( set_command_mode.set(CommandMode::None); set_list_filter.set(String::new()); set_selected_index.set(0); + set_scene_filter.set(String::new()); + set_scene_selected_index.set(0); set_message.set(String::new()); // Blur the input to unfocus chat if let Some(input) = input_ref.get() { @@ -329,6 +407,51 @@ pub fn ChatInput( } } + // Arrow key navigation when scene list is showing + if current_mode == CommandMode::ShowingSceneList { + let scene_list = filtered_scenes(); + let count = scene_list.len(); + + if key == "ArrowDown" && count > 0 { + set_scene_selected_index.update(|idx| { + *idx = (*idx + 1) % count; + }); + ev.prevent_default(); + return; + } + + if key == "ArrowUp" && count > 0 { + set_scene_selected_index.update(|idx| { + *idx = if *idx == 0 { count - 1 } else { *idx - 1 }; + }); + ev.prevent_default(); + return; + } + + if key == "Enter" && count > 0 { + // Select the currently highlighted scene - fill in command + let idx = scene_selected_index.get_untracked(); + if let Some(scene) = scene_list.get(idx) { + let cmd = format!("/teleport {}", scene.slug); + set_scene_filter.set(String::new()); + set_scene_selected_index.set(0); + set_command_mode.set(CommandMode::None); + set_message.set(cmd.clone()); + if let Some(input) = input_ref.get() { + input.set_value(&cmd); + } + } + ev.prevent_default(); + return; + } + + // Any other key in scene list mode is handled by on_input + if key == "Enter" { + ev.prevent_default(); + return; + } + } + // Tab for autocomplete if key == "Tab" { let msg = message.get(); @@ -352,6 +475,15 @@ pub fn ChatInput( ev.prevent_default(); return; } + // Autocomplete to /teleport if /t, /te, /tel, etc. + if !cmd.is_empty() && "teleport".starts_with(&cmd) && cmd != "teleport" { + set_message.set("/teleport".to_string()); + if let Some(input) = input_ref.get() { + input.set_value("/teleport"); + } + ev.prevent_default(); + return; + } } // Always prevent Tab from moving focus when in input ev.prevent_default(); @@ -416,6 +548,43 @@ pub fn ChatInput( return; } + // /t or /teleport (no slug yet) - show scene list if enabled + if allow_user_teleport.get_untracked() + && !cmd.is_empty() + && ("teleport".starts_with(&cmd) || cmd == "teleport") + { + set_command_mode.set(CommandMode::ShowingSceneList); + set_scene_filter.set(String::new()); + set_scene_selected_index.set(0); + set_message.set(String::new()); + if let Some(input) = input_ref.get() { + input.set_value(""); + } + ev.prevent_default(); + return; + } + + // /teleport {slug} - execute teleport + if let Some(slug) = parse_teleport_command(&msg) { + if allow_user_teleport.get_untracked() { + // Find the scene by slug + let scene_list = scenes.map(|s| s.get()).unwrap_or_default(); + if let Some(scene) = scene_list.iter().find(|s| s.slug == slug) { + if let Some(ref callback) = on_teleport { + callback.run(scene.id); + } + set_message.set(String::new()); + set_command_mode.set(CommandMode::None); + if let Some(input) = input_ref.get() { + input.set_value(""); + let _ = input.blur(); + } + } + } + ev.prevent_default(); + return; + } + // Invalid slash command - just ignore, don't send ev.prevent_default(); return; @@ -485,7 +654,7 @@ pub fn ChatInput( } }; - // Popup select handler + // Popup select handler for emotions let on_popup_select = Callback::new(move |emotion: String| { set_list_filter.set(String::new()); apply_emotion(emotion); @@ -496,7 +665,27 @@ pub fn ChatInput( set_command_mode.set(CommandMode::None); }); + // Scene popup select handler - fills in the command + let on_scene_select = Callback::new(move |scene: SceneSummary| { + let cmd = format!("/teleport {}", scene.slug); + set_scene_filter.set(String::new()); + set_scene_selected_index.set(0); + set_command_mode.set(CommandMode::None); + set_message.set(cmd.clone()); + if let Some(input) = input_ref.get() { + input.set_value(&cmd); + } + }); + + let on_scene_popup_close = Callback::new(move |_: ()| { + set_scene_filter.set(String::new()); + set_scene_selected_index.set(0); + set_command_mode.set(CommandMode::None); + }); + let filter_signal = Signal::derive(move || list_filter.get()); + let scene_filter_signal = Signal::derive(move || scene_filter.get()); + let scenes_signal = Signal::derive(move || scenes.map(|s| s.get()).unwrap_or_default()); view! {
@@ -513,7 +702,7 @@ pub fn ChatInput(
- // Slash command hint bar (/s[etting], /i[nventory], /w[hisper]) + // Slash command hint bar (/s[etting], /i[nventory], /w[hisper], /t[eleport])
"/" @@ -527,6 +716,12 @@ pub fn ChatInput( "/" "w" "[hisper] name" + + "|" + "/" + "t" + "[eleport]" +
@@ -543,6 +738,17 @@ pub fn ChatInput( /> + // Scene list popup for teleport + + + +
>, + on_select: Callback, + #[prop(into)] on_close: Callback<()>, + #[prop(into)] scene_filter: Signal, + #[prop(into)] selected_idx: Signal, +) -> impl IntoView { + let _ = on_close; // Suppress unused warning + + // Get list of scenes, filtered by search text + let filtered_scenes = move || { + let filter_text = scene_filter.get().to_lowercase(); + scenes + .get() + .into_iter() + .filter(|s| { + filter_text.is_empty() + || s.name.to_lowercase().contains(&filter_text) + || s.slug.to_lowercase().contains(&filter_text) + }) + .collect::>() + }; + + let filter_display = move || { + let f = scene_filter.get(); + if f.is_empty() { + "Type to filter...".to_string() + } else { + format!("Filter: {}", f) + } + }; + + // Indexed scenes for selection tracking + let indexed_scenes = move || { + filtered_scenes() + .into_iter() + .enumerate() + .collect::>() + }; + + view! { +
+
+ "Select a scene to teleport to:" + {filter_display} +
+
+ {move || { + indexed_scenes() + .into_iter() + .map(|(idx, scene)| { + let on_select = on_select.clone(); + let scene_for_click = scene.clone(); + let scene_name = scene.name.clone(); + let scene_slug = scene.slug.clone(); + let is_selected = move || selected_idx.get() == idx; + view! { + + } + }) + .collect_view() + }} +
+ +
+ {move || { + if scene_filter.get().is_empty() { + "No scenes available" + } else { + "No matching scenes" + } + }} +
+
+
+ "^_" + " navigate " + "Enter" + " select " + "Esc" + " cancel" +
+
+ } +} diff --git a/crates/chattyness-user-ui/src/components/ws_client.rs b/crates/chattyness-user-ui/src/components/ws_client.rs index ef026fa..8ec1877 100644 --- a/crates/chattyness-user-ui/src/components/ws_client.rs +++ b/crates/chattyness-user-ui/src/components/ws_client.rs @@ -73,6 +73,15 @@ pub struct WsError { 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: @@ -91,6 +100,7 @@ pub fn use_channel_websocket( on_member_fading: Callback, on_welcome: Option>, on_error: Option>, + on_teleport_approved: Option>, ) -> (Signal, WsSenderStorage) { use std::cell::RefCell; use std::rc::Rc; @@ -190,6 +200,7 @@ pub fn use_channel_websocket( 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> = Rc::new(RefCell::new(false)); @@ -257,6 +268,7 @@ pub fn use_channel_websocket( &on_prop_picked_up_clone, &on_member_fading_clone, &on_error_clone, + &on_teleport_approved_clone, ); } } @@ -304,6 +316,7 @@ fn handle_server_message( on_prop_picked_up: &Callback, on_member_fading: &Callback, on_error: &Option>, + on_teleport_approved: &Option>, ) { let mut members_vec = members.borrow_mut(); @@ -456,6 +469,17 @@ fn handle_server_message( } 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, + }); + } + } } } @@ -473,6 +497,7 @@ pub fn use_channel_websocket( _on_member_fading: Callback, _on_welcome: Option>, _on_error: Option>, + _on_teleport_approved: Option>, ) -> (Signal, WsSenderStorage) { let (ws_state, _) = signal(WsState::Disconnected); let sender: WsSenderStorage = StoredValue::new_local(None); diff --git a/crates/chattyness-user-ui/src/pages/realm.rs b/crates/chattyness-user-ui/src/pages/realm.rs index 882847f..980f71f 100644 --- a/crates/chattyness-user-ui/src/pages/realm.rs +++ b/crates/chattyness-user-ui/src/pages/realm.rs @@ -20,14 +20,14 @@ use crate::components::{ #[cfg(feature = "hydrate")] use crate::components::{ ChannelMemberInfo, ChatMessage, DEFAULT_BUBBLE_TIMEOUT_MS, FADE_DURATION_MS, HistoryEntry, - WsError, add_to_history, use_channel_websocket, + TeleportInfo, WsError, add_to_history, use_channel_websocket, }; use crate::utils::LocalStoragePersist; #[cfg(feature = "hydrate")] use crate::utils::parse_bounds_dimensions; use chattyness_db::models::{ AvatarWithPaths, ChannelMemberWithAvatar, EmotionAvailability, LooseProp, RealmRole, - RealmWithUserRole, Scene, + RealmWithUserRole, Scene, SceneSummary, }; #[cfg(feature = "hydrate")] use chattyness_db::ws_messages::ClientMessage; @@ -123,6 +123,15 @@ pub fn RealmPage() -> impl IntoView { // Reconnection trigger - increment to force WebSocket reconnection let (reconnect_trigger, set_reconnect_trigger) = signal(0u32); + // Current scene (changes when teleporting) + let (current_scene, set_current_scene) = signal(Option::::None); + + // Available scenes for teleportation (cached on load) + let (available_scenes, set_available_scenes) = signal(Vec::::new()); + + // Whether teleportation is allowed in this realm + let (allow_user_teleport, set_allow_user_teleport) = signal(false); + let realm_data = LocalResource::new(move || { let slug = slug.get(); async move { @@ -324,6 +333,8 @@ pub fn RealmPage() -> impl IntoView { // Display user-friendly error message let msg = match error.code.as_str() { "WHISPER_TARGET_NOT_FOUND" => error.message, + "TELEPORT_DISABLED" => error.message, + "SCENE_NOT_FOUND" => error.message, _ => format!("Error: {}", error.message), }; set_error_message.set(Some(msg)); @@ -335,6 +346,47 @@ pub fn RealmPage() -> impl IntoView { .forget(); }); + // Callback for teleport approval - navigate to new scene + #[cfg(feature = "hydrate")] + let on_teleport_approved = Callback::new(move |info: TeleportInfo| { + let scene_id = info.scene_id; + let scene_slug = info.scene_slug; + let realm_slug = slug.get_untracked(); + + // Fetch the new scene data to update the canvas background + 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::().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)); + } + // 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 + set_reconnect_trigger.update(|t| *t += 1); + }); + }); + #[cfg(feature = "hydrate")] let (ws_state, ws_sender) = use_channel_websocket( slug, @@ -348,9 +400,10 @@ pub fn RealmPage() -> impl IntoView { on_member_fading, Some(on_welcome), Some(on_ws_error), + Some(on_teleport_approved), ); - // Set channel ID and scene dimensions when scene loads + // Set channel ID, current scene, and scene dimensions when entry scene loads // Note: Currently using scene.id as the channel_id since channel_members // uses scenes directly. Proper channel infrastructure can be added later. #[cfg(feature = "hydrate")] @@ -360,6 +413,7 @@ pub fn RealmPage() -> impl IntoView { return; }; set_channel_id.set(Some(scene.id)); + set_current_scene.set(Some(scene.clone())); // Extract scene dimensions from bounds_wkt if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) { @@ -368,6 +422,44 @@ pub fn RealmPage() -> impl IntoView { }); } + // Fetch available scenes and realm settings when realm loads + #[cfg(feature = "hydrate")] + { + Effect::new(move |_| { + let Some(realm_with_role) = realm_data.get().flatten() else { + return; + }; + + // Set allow_user_teleport from realm settings + set_allow_user_teleport.set(realm_with_role.realm.allow_user_teleport); + + // Fetch scenes list for teleport command + let current_slug = slug.get(); + if current_slug.is_empty() { + return; + } + + spawn_local(async move { + use gloo_net::http::Request; + let response = Request::get(&format!("/api/realms/{}/scenes", current_slug)) + .send() + .await; + if let Ok(resp) = response { + if resp.ok() { + if let Ok(scenes) = resp.json::>().await { + // Filter out hidden scenes + let visible_scenes: Vec = scenes + .into_iter() + .filter(|s| !s.is_hidden) + .collect(); + set_available_scenes.set(visible_scenes); + } + } + } + }); + }); + } + // Cleanup expired speech bubbles and fading members every second #[cfg(feature = "hydrate")] { @@ -729,11 +821,16 @@ pub fn RealmPage() -> impl IntoView { let realm_slug_for_viewer = realm_slug_val.clone(); #[cfg(feature = "hydrate")] let ws_sender_clone = ws_sender.clone(); + // Read current_scene in reactive context (before .map()) + // so changes trigger re-render + let current_scene_val = current_scene.get(); entry_scene .get() .map(|maybe_scene| { match maybe_scene { - Some(scene) => { + Some(entry_scene_data) => { + // Use current_scene if set (after teleport), otherwise use entry scene + let display_scene = current_scene_val.clone().unwrap_or_else(|| entry_scene_data.clone()); let members_signal = Signal::derive(move || members.get()); let emotion_avail_signal = Signal::derive(move || emotion_availability.get()); let skin_path_signal = Signal::derive(move || skin_preview_path.get()); @@ -755,10 +852,22 @@ pub fn RealmPage() -> impl IntoView { let on_whisper_request_cb = Callback::new(move |target: String| { set_whisper_target.set(Some(target)); }); + let scenes_signal = Signal::derive(move || available_scenes.get()); + let teleport_enabled_signal = Signal::derive(move || allow_user_teleport.get()); + #[cfg(feature = "hydrate")] + let ws_for_teleport = ws_sender_clone.clone(); + let on_teleport_cb = Callback::new(move |scene_id: Uuid| { + #[cfg(feature = "hydrate")] + ws_for_teleport.with_value(|sender| { + if let Some(send_fn) = sender { + send_fn(ClientMessage::Teleport { scene_id }); + } + }); + }); view! {
impl IntoView { on_open_settings=on_open_settings_cb on_open_inventory=on_open_inventory_cb whisper_target=whisper_target_signal + scenes=scenes_signal + allow_user_teleport=teleport_enabled_signal + on_teleport=on_teleport_cb />
diff --git a/db/schema/tables/030_realm.sql b/db/schema/tables/030_realm.sql index 41dd830..99fa82d 100644 --- a/db/schema/tables/030_realm.sql +++ b/db/schema/tables/030_realm.sql @@ -31,6 +31,7 @@ CREATE TABLE realm.realms ( max_users INTEGER NOT NULL DEFAULT 100 CHECK (max_users > 0 AND max_users <= 10000), allow_guest_access BOOLEAN NOT NULL DEFAULT true, + allow_user_teleport BOOLEAN NOT NULL DEFAULT false, default_scene_id UUID, From bf3bd3dff5212dee8bacbdb8677c51ee9bd2521aadf233d28a7802a33dd3b95e Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Tue, 20 Jan 2026 11:29:22 -0600 Subject: [PATCH 09/10] fix: teleport should never show reconnect dialog --- crates/chattyness-db/src/ws_messages.rs | 8 ++ .../chattyness-user-ui/src/api/websocket.rs | 31 ++++- .../src/components/reconnection_overlay.rs | 8 ++ .../src/components/ws_client.rs | 118 ++++++++++++++++-- crates/chattyness-user-ui/src/pages/realm.rs | 8 +- 5 files changed, 153 insertions(+), 20 deletions(-) diff --git a/crates/chattyness-db/src/ws_messages.rs b/crates/chattyness-db/src/ws_messages.rs index 0068dc7..3dd17fc 100644 --- a/crates/chattyness-db/src/ws_messages.rs +++ b/crates/chattyness-db/src/ws_messages.rs @@ -20,6 +20,14 @@ pub struct WsConfig { pub ping_interval_secs: u64, } +/// WebSocket close codes (custom range: 4000-4999). +pub mod close_codes { + /// Scene change (user navigating to different scene). + pub const SCENE_CHANGE: u16 = 4000; + /// Server timeout (no message received within timeout period). + pub const SERVER_TIMEOUT: u16 = 4001; +} + /// Reason for member disconnect. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] diff --git a/crates/chattyness-user-ui/src/api/websocket.rs b/crates/chattyness-user-ui/src/api/websocket.rs index 934825c..56f8e08 100644 --- a/crates/chattyness-user-ui/src/api/websocket.rs +++ b/crates/chattyness-user-ui/src/api/websocket.rs @@ -20,14 +20,11 @@ use uuid::Uuid; use chattyness_db::{ models::{AvatarRenderData, ChannelMemberWithAvatar, EmotionState, User}, queries::{avatars, channel_members, loose_props, realms, scenes}, - ws_messages::{ClientMessage, DisconnectReason, ServerMessage, WsConfig}, + ws_messages::{close_codes, ClientMessage, DisconnectReason, ServerMessage, WsConfig}, }; use chattyness_error::AppError; use chattyness_shared::WebSocketConfig; -/// Close code for scene change (custom code). -const SCENE_CHANGE_CLOSE_CODE: u16 = 4000; - use crate::auth::AuthUser; /// Channel state for broadcasting updates. @@ -397,8 +394,13 @@ async fn handle_socket( // Create recv timeout from config let recv_timeout = Duration::from_secs(ws_config.recv_timeout_secs); + // Channel for sending close frame requests from recv_task to send_task + let (close_tx, mut close_rx) = mpsc::channel::<(u16, String)>(1); + // Spawn task to handle incoming messages from client + let close_tx_for_recv = close_tx.clone(); let recv_task = tokio::spawn(async move { + let close_tx = close_tx_for_recv; let pool = pool_for_recv; let ws_state = ws_state_for_recv; let mut disconnect_reason = DisconnectReason::Graceful; @@ -817,7 +819,7 @@ async fn handle_socket( Message::Close(close_frame) => { // Check close code for scene change if let Some(CloseFrame { code, .. }) = close_frame { - if code == SCENE_CHANGE_CLOSE_CODE { + if code == close_codes::SCENE_CHANGE { disconnect_reason = DisconnectReason::SceneChange; } else { disconnect_reason = DisconnectReason::Graceful; @@ -843,6 +845,12 @@ async fn handle_socket( Err(_) => { // Timeout elapsed - connection likely lost tracing::info!("[WS] Connection timeout for user {}", user_id); + // Send close frame with timeout code so client can attempt silent reconnection + let _ = close_tx + .send((close_codes::SERVER_TIMEOUT, "timeout".to_string())) + .await; + // Brief delay to allow close frame to be sent + tokio::time::sleep(Duration::from_millis(100)).await; disconnect_reason = DisconnectReason::Timeout; break; } @@ -852,10 +860,21 @@ async fn handle_socket( (recv_conn, disconnect_reason) }); - // Spawn task to forward broadcasts and direct messages to this client + // Spawn task to forward broadcasts, direct messages, and close frames to this client let send_task = tokio::spawn(async move { loop { tokio::select! { + // Handle close frame requests (from timeout) + Some((code, reason)) = close_rx.recv() => { + #[cfg(debug_assertions)] + tracing::debug!("[WS->Client] Sending close frame: code={}, reason={}", code, reason); + let close_frame = CloseFrame { + code, + reason: reason.into(), + }; + let _ = sender.send(Message::Close(Some(close_frame))).await; + break; + } // Handle broadcast messages Ok(msg) = rx.recv() => { if let Ok(json) = serde_json::to_string(&msg) { diff --git a/crates/chattyness-user-ui/src/components/reconnection_overlay.rs b/crates/chattyness-user-ui/src/components/reconnection_overlay.rs index c74c0cf..43c3f44 100644 --- a/crates/chattyness-user-ui/src/components/reconnection_overlay.rs +++ b/crates/chattyness-user-ui/src/components/reconnection_overlay.rs @@ -112,6 +112,14 @@ pub fn ReconnectionOverlay( } set_overlay_state.set(OverlayState::Hidden); } + WsState::SilentReconnecting(_) => { + // Silent reconnection in progress - keep overlay hidden + // The ws_client handles the reconnection attempts internally + if let Some(timer) = timer_handle.borrow_mut().take() { + drop(timer); + } + set_overlay_state.set(OverlayState::Hidden); + } WsState::Disconnected | WsState::Error => { // Check current state - only start countdown if we're hidden let current = overlay_state.get_untracked(); diff --git a/crates/chattyness-user-ui/src/components/ws_client.rs b/crates/chattyness-user-ui/src/components/ws_client.rs index 8ec1877..a5845ef 100644 --- a/crates/chattyness-user-ui/src/components/ws_client.rs +++ b/crates/chattyness-user-ui/src/components/ws_client.rs @@ -9,18 +9,21 @@ use leptos::reactive::owner::LocalStorage; #[cfg(feature = "hydrate")] use chattyness_db::models::EmotionState; use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp}; -use chattyness_db::ws_messages::ClientMessage; +use chattyness_db::ws_messages::{close_codes, ClientMessage}; #[cfg(feature = "hydrate")] use chattyness_db::ws_messages::{DisconnectReason, ServerMessage}; 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; +/// 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 { @@ -43,6 +46,9 @@ pub enum WsState { 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. @@ -91,7 +97,7 @@ pub struct TeleportInfo { pub fn use_channel_websocket( realm_slug: Signal, channel_id: Signal>, - reconnect_trigger: Signal, + reconnect_trigger: RwSignal, on_members_update: Callback>, on_chat_message: Callback, on_loose_props_sync: Callback>, @@ -110,6 +116,11 @@ pub fn use_channel_websocket( let (ws_state, set_ws_state) = signal(WsState::Disconnected); let ws_ref: Rc>> = Rc::new(RefCell::new(None)); let members: Rc>> = Rc::new(RefCell::new(Vec::new())); + // Track current user's ID to ignore self MemberLeft during reconnection + let current_user_id: Rc>> = 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> = 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(); @@ -129,6 +140,7 @@ 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(); Effect::new(move |_| { let slug = realm_slug.get(); @@ -138,7 +150,14 @@ pub fn use_channel_websocket( // Cleanup previous connection if let Some(old_ws) = ws_ref_clone.borrow_mut().take() { - let _ = old_ws.close(); + #[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 { @@ -205,6 +224,8 @@ pub fn use_channel_websocket( let ws_ref_for_heartbeat = ws_ref.clone(); let heartbeat_started: Rc> = 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::() { let text: String = text.into(); @@ -219,6 +240,9 @@ pub fn use_channel_websocket( .. } = 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; @@ -269,6 +293,7 @@ pub fn use_channel_websocket( &on_member_fading_clone, &on_error_clone, &on_teleport_approved_clone, + ¤t_user_id_for_msg, ); } } @@ -278,22 +303,82 @@ pub fn use_channel_websocket( // 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()); - set_ws_state_err.set(WsState::Error); + + // 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); 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={}", e.code(), e.reason()).into(), + &format!("[WS] Closed: code={}, reason={}", code, e.reason()).into(), ); - set_ws_state_close.set(WsState::Disconnected); + + // 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); ws.set_onclose(Some(onclose.as_ref().unchecked_ref())); onclose.forget(); @@ -317,6 +402,7 @@ fn handle_server_message( on_member_fading: &Callback, on_error: &Option>, on_teleport_approved: &Option>, + current_user_id: &std::rc::Rc>>, ) { let mut members_vec = members.borrow_mut(); @@ -343,6 +429,18 @@ fn handle_server_message( 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() @@ -488,7 +586,7 @@ fn handle_server_message( pub fn use_channel_websocket( _realm_slug: Signal, _channel_id: Signal>, - _reconnect_trigger: Signal, + _reconnect_trigger: RwSignal, _on_members_update: Callback>, _on_chat_message: Callback, _on_loose_props_sync: Callback>, diff --git a/crates/chattyness-user-ui/src/pages/realm.rs b/crates/chattyness-user-ui/src/pages/realm.rs index 980f71f..b2034c4 100644 --- a/crates/chattyness-user-ui/src/pages/realm.rs +++ b/crates/chattyness-user-ui/src/pages/realm.rs @@ -121,7 +121,7 @@ pub fn RealmPage() -> impl IntoView { let (error_message, set_error_message) = signal(Option::::None); // Reconnection trigger - increment to force WebSocket reconnection - let (reconnect_trigger, set_reconnect_trigger) = signal(0u32); + let reconnect_trigger = RwSignal::new(0u32); // Current scene (changes when teleporting) let (current_scene, set_current_scene) = signal(Option::::None); @@ -383,7 +383,7 @@ pub fn RealmPage() -> impl IntoView { set_members.set(Vec::new()); // Trigger a reconnect to ensure fresh connection - set_reconnect_trigger.update(|t| *t += 1); + reconnect_trigger.update(|t| *t += 1); }); }); @@ -391,7 +391,7 @@ pub fn RealmPage() -> impl IntoView { let (ws_state, ws_sender) = use_channel_websocket( slug, Signal::derive(move || channel_id.get()), - Signal::derive(move || reconnect_trigger.get()), + reconnect_trigger, on_members_update, on_chat_message, on_loose_props_sync, @@ -1087,7 +1087,7 @@ pub fn RealmPage() -> impl IntoView { } From 29f29358fd14d316426174551a6b27389a8312c620f526d27d0a3353e24100fd Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Tue, 20 Jan 2026 12:39:24 -0600 Subject: [PATCH 10/10] feat: slug in url if /t is enabled --- apps/chattyness-app/src/app.rs | 9 ++ crates/chattyness-user-ui/src/pages/realm.rs | 154 +++++++++++++++++-- crates/chattyness-user-ui/src/routes.rs | 9 ++ 3 files changed, 162 insertions(+), 10 deletions(-) diff --git a/apps/chattyness-app/src/app.rs b/apps/chattyness-app/src/app.rs index bf7a632..73b8f73 100644 --- a/apps/chattyness-app/src/app.rs +++ b/apps/chattyness-app/src/app.rs @@ -259,6 +259,15 @@ pub fn CombinedApp() -> impl IntoView { + // ========================================== // Admin routes (lazy loading) diff --git a/crates/chattyness-user-ui/src/pages/realm.rs b/crates/chattyness-user-ui/src/pages/realm.rs index b2034c4..ef81a32 100644 --- a/crates/chattyness-user-ui/src/pages/realm.rs +++ b/crates/chattyness-user-ui/src/pages/realm.rs @@ -44,6 +44,9 @@ pub fn RealmPage() -> impl IntoView { let slug = Signal::derive(move || params.read().get("slug").unwrap_or_default()); + // Scene slug from URL (for direct scene navigation) + let scene_slug_param = Signal::derive(move || params.read().get("scene_slug")); + // Channel member state let (members, set_members) = signal(Vec::::new()); let (channel_id, set_channel_id) = signal(Option::::None); @@ -350,10 +353,12 @@ pub fn RealmPage() -> impl IntoView { #[cfg(feature = "hydrate")] let on_teleport_approved = Callback::new(move |info: TeleportInfo| { let scene_id = info.scene_id; - let scene_slug = info.scene_slug; + 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!( @@ -370,6 +375,26 @@ pub fn RealmPage() -> impl IntoView { if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) { set_scene_dimensions.set((w as f64, h as f64)); } + + // 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)); } @@ -408,16 +433,125 @@ pub fn RealmPage() -> impl IntoView { // uses scenes directly. Proper channel infrastructure can be added later. #[cfg(feature = "hydrate")] { - Effect::new(move |_| { - let Some(scene) = entry_scene.get().flatten() else { - return; - }; - set_channel_id.set(Some(scene.id)); - set_current_scene.set(Some(scene.clone())); + // Track whether we've handled initial scene load to prevent double-loading + let initial_scene_handled = StoredValue::new_local(false); - // Extract scene dimensions from bounds_wkt - if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) { - set_scene_dimensions.set((w as f64, h as f64)); + Effect::new(move |_| { + // Skip if already handled + if initial_scene_handled.get_value() { + return; + } + + let url_scene_slug = scene_slug_param.get(); + let has_url_scene = url_scene_slug + .as_ref() + .is_some_and(|s| !s.is_empty()); + + if has_url_scene { + // URL has a scene slug - wait for realm data to check if teleport is allowed + let Some(realm_with_role) = realm_data.get().flatten() else { + return; + }; + + let realm_slug_val = slug.get(); + let scene_slug_val = url_scene_slug.unwrap(); + + if !realm_with_role.realm.allow_user_teleport { + // Teleport disabled - redirect to base realm URL and show error + initial_scene_handled.set_value(true); + set_error_message.set(Some( + "Direct scene access is disabled for this realm".to_string(), + )); + + // Redirect to base realm URL + let navigate = use_navigate(); + navigate( + &format!("/realms/{}", realm_slug_val), + leptos_router::NavigateOptions { + replace: true, + ..Default::default() + }, + ); + + // Auto-dismiss error after 5 seconds + use gloo_timers::callback::Timeout; + Timeout::new(5000, move || { + set_error_message.set(None); + }) + .forget(); + + // Fall back to entry scene + if let Some(scene) = entry_scene.get().flatten() { + set_channel_id.set(Some(scene.id)); + set_current_scene.set(Some(scene.clone())); + if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) { + set_scene_dimensions.set((w as f64, h as f64)); + } + } + return; + } + + // Teleport allowed - fetch the specific scene + initial_scene_handled.set_value(true); + spawn_local(async move { + use gloo_net::http::Request; + let response = Request::get(&format!( + "/api/realms/{}/scenes/{}", + realm_slug_val, scene_slug_val + )) + .send() + .await; + + if let Ok(resp) = response { + if resp.ok() { + if let Ok(scene) = resp.json::().await { + set_channel_id.set(Some(scene.id)); + if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) { + set_scene_dimensions.set((w as f64, h as f64)); + } + set_current_scene.set(Some(scene)); + return; + } + } + } + + // Scene not found - show error and fall back to entry scene + set_error_message.set(Some(format!( + "Scene '{}' not found", + scene_slug_val + ))); + + // Update URL to base realm URL + if let Some(window) = web_sys::window() { + if let Ok(history) = window.history() { + let _ = history.replace_state_with_url( + &wasm_bindgen::JsValue::NULL, + "", + Some(&format!("/realms/{}", realm_slug_val)), + ); + } + } + + // Auto-dismiss error after 5 seconds + use gloo_timers::callback::Timeout; + Timeout::new(5000, move || { + set_error_message.set(None); + }) + .forget(); + }); + } else { + // No URL scene slug - use entry scene + let Some(scene) = entry_scene.get().flatten() else { + return; + }; + initial_scene_handled.set_value(true); + set_channel_id.set(Some(scene.id)); + set_current_scene.set(Some(scene.clone())); + + // Extract scene dimensions from bounds_wkt + if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) { + set_scene_dimensions.set((w as f64, h as f64)); + } } }); } diff --git a/crates/chattyness-user-ui/src/routes.rs b/crates/chattyness-user-ui/src/routes.rs index b6febbe..f523062 100644 --- a/crates/chattyness-user-ui/src/routes.rs +++ b/crates/chattyness-user-ui/src/routes.rs @@ -31,6 +31,15 @@ pub fn UserRoutes() -> impl IntoView { + } }