diff --git a/apps/chattyness-app/src/app.rs b/apps/chattyness-app/src/app.rs index 73b8f73..bf7a632 100644 --- a/apps/chattyness-app/src/app.rs +++ b/apps/chattyness-app/src/app.rs @@ -259,15 +259,6 @@ pub fn CombinedApp() -> impl IntoView { - // ========================================== // Admin routes (lazy loading) diff --git a/apps/chattyness-app/style/tailwind.css b/apps/chattyness-app/style/tailwind.css index 8f689c9..a3d3d22 100644 --- a/apps/chattyness-app/style/tailwind.css +++ b/apps/chattyness-app/style/tailwind.css @@ -75,14 +75,4 @@ .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-db/src/models.rs b/crates/chattyness-db/src/models.rs index c943e18..9c4ac98 100644 --- a/crates/chattyness-db/src/models.rs +++ b/crates/chattyness-db/src/models.rs @@ -451,13 +451,6 @@ 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 { @@ -489,7 +482,6 @@ 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, @@ -517,7 +509,6 @@ 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, } @@ -1363,7 +1354,6 @@ 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, @@ -1380,7 +1370,6 @@ 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, } @@ -1840,9 +1829,6 @@ 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 230a81e..9e19d89 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, 1 + SELECT $1, $2, id, 0 FROM auth.avatars WHERE user_id = $1 AND slot_number = 0 ON CONFLICT (user_id, realm_id) DO NOTHING @@ -176,8 +176,7 @@ 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, - COALESCE('guest' = ANY(u.tags), false) as is_guest + cm.joined_at 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 @@ -215,8 +214,7 @@ 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, - COALESCE('guest' = ANY(u.tags), false) as is_guest + cm.joined_at 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-db/src/queries/owner/realms.rs b/crates/chattyness-db/src/queries/owner/realms.rs index 17ca002..e1e2d95 100644 --- a/crates/chattyness-db/src/queries/owner/realms.rs +++ b/crates/chattyness-db/src/queries/owner/realms.rs @@ -30,12 +30,7 @@ pub async fn list_realms_with_owner( r.owner_id, u.username as owner_username, 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.current_user_count, r.created_at FROM realm.realms r JOIN auth.users u ON r.owner_id = u.id @@ -70,12 +65,7 @@ pub async fn search_realms( r.owner_id, u.username as owner_username, 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.current_user_count, r.created_at FROM realm.realms r JOIN auth.users u ON r.owner_id = u.id @@ -254,14 +244,8 @@ pub async fn get_realm_by_slug(pool: &PgPool, slug: &str) -> Result Result, AppErro let realms = sqlx::query_as::<_, RealmSummary>( r#" SELECT - 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 + id, + name, + slug, + tagline, + privacy, + is_nsfw, + thumbnail_path, + member_count, + current_user_count + FROM realm.realms + ORDER BY name "#, ) .fetch_all(pool) diff --git a/crates/chattyness-db/src/queries/realms.rs b/crates/chattyness-db/src/queries/realms.rs index 9491df5..2bc7482 100644 --- a/crates/chattyness-db/src/queries/realms.rs +++ b/crates/chattyness-db/src/queries/realms.rs @@ -17,9 +17,9 @@ pub async fn create_realm( r#" INSERT INTO realm.realms ( name, slug, description, tagline, owner_id, - privacy, is_nsfw, max_users, allow_guest_access, allow_user_teleport, theme_color + privacy, is_nsfw, max_users, allow_guest_access, theme_color ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, name, @@ -35,7 +35,6 @@ pub async fn create_realm( thumbnail_path, max_users, allow_guest_access, - allow_user_teleport, default_scene_id, member_count, current_user_count, @@ -52,7 +51,6 @@ pub async fn create_realm( .bind(req.is_nsfw) .bind(req.max_users) .bind(req.allow_guest_access) - .bind(req.allow_user_teleport) .bind(&req.theme_color) .fetch_one(pool) .await?; @@ -79,33 +77,27 @@ pub async fn get_realm_by_slug<'e>( let realm = sqlx::query_as::<_, Realm>( r#" SELECT - 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.allow_user_teleport, - 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 + 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 "#, ) .bind(slug) @@ -120,33 +112,27 @@ pub async fn get_realm_by_id(pool: &PgPool, id: Uuid) -> Result, A let realm = sqlx::query_as::<_, Realm>( r#" SELECT - 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.allow_user_teleport, - 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 + 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 "#, ) .bind(id) @@ -167,23 +153,18 @@ pub async fn list_public_realms( sqlx::query_as::<_, RealmSummary>( r#" SELECT - 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 + 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 LIMIT $1 OFFSET $2 "#, ) @@ -195,23 +176,18 @@ pub async fn list_public_realms( sqlx::query_as::<_, RealmSummary>( r#" SELECT - 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 + 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 LIMIT $1 OFFSET $2 "#, ) @@ -229,23 +205,18 @@ pub async fn get_user_realms(pool: &PgPool, user_id: Uuid) -> Result( r#" SELECT - 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 + 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 "#, ) .bind(user_id) diff --git a/crates/chattyness-db/src/ws_messages.rs b/crates/chattyness-db/src/ws_messages.rs index 3dd17fc..80ad935 100644 --- a/crates/chattyness-db/src/ws_messages.rs +++ b/crates/chattyness-db/src/ws_messages.rs @@ -20,14 +20,6 @@ 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")] @@ -84,12 +76,6 @@ 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. @@ -226,12 +212,4 @@ 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/avatars.rs b/crates/chattyness-user-ui/src/api/avatars.rs index c1b99ba..c3c344d 100644 --- a/crates/chattyness-user-ui/src/api/avatars.rs +++ b/crates/chattyness-user-ui/src/api/avatars.rs @@ -53,13 +53,6 @@ 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; @@ -100,13 +93,6 @@ 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 56f8e08..fa958dc 100644 --- a/crates/chattyness-user-ui/src/api/websocket.rs +++ b/crates/chattyness-user-ui/src/api/websocket.rs @@ -20,11 +20,14 @@ use uuid::Uuid; use chattyness_db::{ models::{AvatarRenderData, ChannelMemberWithAvatar, EmotionState, User}, queries::{avatars, channel_members, loose_props, realms, scenes}, - ws_messages::{close_codes, ClientMessage, DisconnectReason, ServerMessage, WsConfig}, + ws_messages::{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. @@ -350,7 +353,6 @@ 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 @@ -388,20 +390,11 @@ 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); - // 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; @@ -516,17 +509,6 @@ 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) @@ -730,96 +712,12 @@ 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) => { // Check close code for scene change if let Some(CloseFrame { code, .. }) = close_frame { - if code == close_codes::SCENE_CHANGE { + if code == SCENE_CHANGE_CLOSE_CODE { disconnect_reason = DisconnectReason::SceneChange; } else { disconnect_reason = DisconnectReason::Graceful; @@ -845,12 +743,6 @@ 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; } @@ -860,21 +752,10 @@ async fn handle_socket( (recv_conn, disconnect_reason) }); - // Spawn task to forward broadcasts, direct messages, and close frames to this client + // Spawn task to forward broadcasts and direct messages 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.rs b/crates/chattyness-user-ui/src/components.rs index 5779f00..5c365db 100644 --- a/crates/chattyness-user-ui/src/components.rs +++ b/crates/chattyness-user-ui/src/components.rs @@ -16,12 +16,10 @@ 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; pub mod tabs; -pub mod reconnection_overlay; pub mod ws_client; pub use avatar_canvas::*; @@ -40,8 +38,6 @@ pub use layout::*; 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/avatar_canvas.rs b/crates/chattyness-user-ui/src/components/avatar_canvas.rs index d2ae953..3943066 100644 --- a/crates/chattyness-user-ui/src/components/avatar_canvas.rs +++ b/crates/chattyness-user-ui/src/components/avatar_canvas.rs @@ -226,235 +226,6 @@ 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) @@ -522,29 +293,102 @@ 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 and avatar screen position + // Compute screen boundaries for avatar clamping 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; - // Create unified layout - all calculations happen in one place - let layout = CanvasLayout::new( - &content_bounds, - ps, - te, + // 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( avatar_screen_x, avatar_screen_y, - boundaries, - bubble.is_some(), - bubble.as_ref().map(|b| b.message.content.as_str()), + content_half_width, + content_half_height, ); - // Generate CSS style from layout - layout.css_style(z_index, pointer_events, opacity) + // 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 + ) }; // Store references for the effect @@ -578,7 +422,7 @@ pub fn AvatarCanvas( return; }; - // Calculate content bounds for the avatar + // Calculate dimensions (same as in style closure) let content_bounds = ContentBounds::from_layers( &m.avatar.skin_layer, &m.avatar.clothes_layer, @@ -586,35 +430,61 @@ pub fn AvatarCanvas( &m.avatar.emotion_layer, ); - // 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); + 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; - // 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; + // 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(); - 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()), - ); + // 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 canvas_el: &web_sys::HtmlCanvasElement = &canvas; - // Set canvas resolution from layout - canvas_el.set_width(layout.canvas_width as u32); - canvas_el.set_height(layout.canvas_height as u32); + // Set canvas resolution + canvas_el.set_width(canvas_width as u32); + canvas_el.set_height(canvas_height as u32); let Ok(Some(ctx)) = canvas_el.get_context("2d") else { return; @@ -622,7 +492,16 @@ pub fn AvatarCanvas( let ctx: web_sys::CanvasRenderingContext2d = ctx.dyn_into().unwrap(); // Clear canvas - ctx.clear_rect(0.0, 0.0, layout.canvas_width, layout.canvas_height); + 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, + }; // Helper to load and draw an image // Images are cached; when loaded, triggers a redraw via signal @@ -665,9 +544,9 @@ pub fn AvatarCanvas( }; // Draw all 9 positions of the avatar grid (3x3 layout) - 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; + let cell_size = ps; + let grid_origin_x = avatar_cx - avatar_size / 2.0; + let grid_origin_y = avatar_cy - avatar_size / 2.0; // Draw skin layer for all 9 positions for pos in 0..9 { @@ -737,9 +616,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 * 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; + 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; ctx.begin_path(); let _ = ctx.arc( @@ -753,21 +632,23 @@ pub fn AvatarCanvas( ctx.fill(); ctx.set_fill_style_str("#000"); - ctx.set_font(&format!("bold {}px sans-serif", 10.0 * layout.text_scale)); + ctx.set_font(&format!("bold {}px sans-serif", 10.0 * text_scale)); ctx.set_text_align("center"); ctx.set_text_baseline("middle"); let _ = ctx.fill_text(&format!("{}", current_emotion), badge_x, badge_y); } - // 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; + // 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 display_name = &m.member.display_name; - ctx.set_font(&format!("{}px sans-serif", 12.0 * layout.text_scale)); + ctx.set_font(&format!("{}px sans-serif", 12.0 * 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); @@ -780,7 +661,35 @@ pub fn AvatarCanvas( if let Some(ref b) = bubble { let current_time = js_sys::Date::now() as i64; if b.expires_at >= current_time { - draw_bubble_with_layout(&ctx, b, &layout, te); + 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), + ); } } }); @@ -815,18 +724,33 @@ fn normalize_asset_path(path: &str) -> String { } } -/// Draw a speech bubble using the unified CanvasLayout. +/// Draw a speech bubble relative to the avatar with boundary awareness. /// -/// This is the preferred method for drawing bubbles - it uses the layout's -/// coordinate transformation and clamping methods, ensuring consistency -/// with the canvas positioning. +/// # 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) #[cfg(feature = "hydrate")] -fn draw_bubble_with_layout( +fn draw_bubble( ctx: &web_sys::CanvasRenderingContext2d, bubble: &ActiveBubble, - layout: &CanvasLayout, + center_x: f64, + top_y: f64, + bottom_y: f64, + content_x_offset: f64, + content_top_adjustment: f64, 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; @@ -847,7 +771,11 @@ fn draw_bubble_with_layout( // 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 @@ -858,14 +786,25 @@ fn draw_bubble_with_layout( let bubble_width = bubble_width.max(60.0 * text_scale); let bubble_height = (lines.len() as f64) * line_height + padding * 2.0; - // Get content center from layout - let content_center_x = layout.content_center_x(); + // Center bubble horizontally on content (not grid center) + let content_center_x = center_x + content_x_offset; - // Calculate initial bubble X (centered on content) - let initial_bubble_x = content_center_x - bubble_width / 2.0; + // Calculate initial bubble X position (centered on content) + let mut bubble_x = content_center_x - bubble_width / 2.0; - // Use layout's clamping method - this handles coordinate transformation correctly - let bubble_x = layout.clamp_bubble_x(initial_bubble_x, bubble_width); + // 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; + } + } // Calculate tail center - point toward content center but stay within bubble bounds let tail_center_x = content_center_x @@ -873,20 +812,27 @@ fn draw_bubble_with_layout( .min(bubble_x + bubble_width - tail_size - border_radius); // Calculate vertical position based on bubble position - let bubble_y = match layout.bubble_position { + let bubble_y = match position { BubblePosition::Above => { // Position vertically closer to content when top rows are empty - let adjusted_top_y = layout.avatar_top_y() + layout.content_top_adjustment(); + let adjusted_top_y = top_y + content_top_adjustment; adjusted_top_y - bubble_height - tail_size - gap } BubblePosition::Below => { // Position below avatar with gap for tail - layout.avatar_bottom_y() + tail_size + gap + 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); @@ -895,7 +841,7 @@ fn draw_bubble_with_layout( // Draw tail pointing to content center ctx.begin_path(); - match layout.bubble_position { + match position { BubblePosition::Above => { // Tail points DOWN toward avatar ctx.move_to(tail_center_x - tail_size, bubble_y + bubble_height); @@ -915,7 +861,7 @@ fn draw_bubble_with_layout( ctx.set_stroke_style_str(border_color); ctx.stroke(); - // Draw text + // Draw text (re-set font in case canvas state changed) ctx.set_font(&format!("{}{}px sans-serif", font_style, font_size)); ctx.set_fill_style_str(text_color); ctx.set_text_align("left"); diff --git a/crates/chattyness-user-ui/src/components/avatar_editor.rs b/crates/chattyness-user-ui/src/components/avatar_editor.rs index b1d7a00..b4bb3b6 100644 --- a/crates/chattyness-user-ui/src/components/avatar_editor.rs +++ b/crates/chattyness-user-ui/src/components/avatar_editor.rs @@ -12,7 +12,6 @@ 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; @@ -217,7 +216,6 @@ 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, @@ -226,11 +224,7 @@ 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); @@ -804,11 +798,6 @@ pub fn AvatarEditorPopup( - - // Guest locked overlay - - - // Context menu diff --git a/crates/chattyness-user-ui/src/components/chat.rs b/crates/chattyness-user-ui/src/components/chat.rs index 0be1a35..1ff1f6e 100644 --- a/crates/chattyness-user-ui/src/components/chat.rs +++ b/crates/chattyness-user-ui/src/components/chat.rs @@ -1,13 +1,11 @@ //! Chat components for realm chat interface. use leptos::prelude::*; -use uuid::Uuid; -use chattyness_db::models::{EmotionAvailability, SceneSummary}; +use chattyness_db::models::EmotionAvailability; 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. @@ -21,8 +19,6 @@ 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. @@ -48,28 +44,6 @@ 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`. @@ -110,9 +84,6 @@ 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, @@ -126,23 +97,11 @@ 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 @@ -162,21 +121,6 @@ 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")] { @@ -218,24 +162,25 @@ pub fn ChatInput( return; }; - // 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); + // Pre-fill with /whisper command + let placeholder = "your message here"; + let whisper_text = format!("/whisper {} {}", target_name, placeholder); if let Some(input) = input_ref.get() { // Set the message - set_message.set(whisper_prefix.clone()); - // Don't show hint - user already knows they're whispering + set_message.set(whisper_text.clone()); set_command_mode.set(CommandMode::None); // Update input value - input.set_value(&whisper_prefix); + input.set_value(&whisper_text); - // Focus the input and position cursor at end + // Focus the input let _ = input.focus(); - let len = whisper_prefix.len() as u32; - let _ = input.set_selection_range(len, len); + + // 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); } }); } @@ -260,20 +205,13 @@ pub fn ChatInput( let value = event_target_value(&ev); set_message.set(value.clone()); - // If emotion list is showing, update filter (input is the filter text) + // If 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(); @@ -292,45 +230,16 @@ 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], /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) - 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 - } - }; - - // 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() + // Match: /s[etting], /i[nventory], /w[hisper], or their full forms with args + 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 { @@ -356,8 +265,6 @@ 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() { @@ -407,51 +314,6 @@ 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(); @@ -475,15 +337,6 @@ 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(); @@ -548,43 +401,6 @@ 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; @@ -654,7 +470,7 @@ pub fn ChatInput( } }; - // Popup select handler for emotions + // Popup select handler let on_popup_select = Callback::new(move |emotion: String| { set_list_filter.set(String::new()); apply_emotion(emotion); @@ -665,27 +481,7 @@ 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! { @@ -702,7 +498,7 @@ pub fn ChatInput( - // Slash command hint bar (/s[etting], /i[nventory], /w[hisper], /t[eleport]) + // Slash command hint bar (/s[etting], /i[nventory], /w[hisper]) "/" @@ -716,12 +512,6 @@ pub fn ChatInput( "/" "w" "[hisper] name" - - "|" - "/" - "t" - "[eleport]" - @@ -738,17 +528,6 @@ pub fn ChatInput( /> - // Scene list popup for teleport - - - - , - /// Optional header text (e.g., username) displayed above the menu items. - #[prop(optional, into)] - header: Option>>, /// Menu items to display. #[prop(into)] items: Signal>, @@ -86,40 +82,12 @@ pub fn ContextMenu( ) }; - // Click outside handler - use Effect with cleanup to properly remove handlers + // Click outside handler #[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; } @@ -132,7 +100,6 @@ 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() { @@ -144,9 +111,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(); @@ -162,7 +129,10 @@ pub fn ContextMenu( "keydown", keydown_handler.as_ref().unchecked_ref(), ); - *keydown_closure_clone.borrow_mut() = Some(keydown_handler); + + // Store handlers to clean up (they get cleaned up when Effect reruns) + handler.forget(); + keydown_handler.forget(); }); } @@ -175,17 +145,6 @@ 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} - - - } - }) - }} , 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"); @@ -243,59 +238,52 @@ 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 - - - - - - // Guest locked overlay - - + // Realm tab + + diff --git a/crates/chattyness-user-ui/src/components/layout.rs b/crates/chattyness-user-ui/src/components/layout.rs index 6892f82..9f12aac 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: Signal, + online_count: i32, 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 = move || format!("{} ONLINE", online_count.get()); + let online_text = format!("{} ONLINE", online_count); let admin_url = format!("/admin/realms/{}", realm_slug); view! { diff --git a/crates/chattyness-user-ui/src/components/modals.rs b/crates/chattyness-user-ui/src/components/modals.rs index 3bcde67..5baa588 100644 --- a/crates/chattyness-user-ui/src/components/modals.rs +++ b/crates/chattyness-user-ui/src/components/modals.rs @@ -314,45 +314,3 @@ 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! { - - - - - "Registered Users" - - - - } -} diff --git a/crates/chattyness-user-ui/src/components/reconnection_overlay.rs b/crates/chattyness-user-ui/src/components/reconnection_overlay.rs deleted file mode 100644 index 43c3f44..0000000 --- a/crates/chattyness-user-ui/src/components/reconnection_overlay.rs +++ /dev/null @@ -1,399 +0,0 @@ -//! 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::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(); - 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." - - - - "Refresh Page" - - - - } - .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/scene_list_popup.rs b/crates/chattyness-user-ui/src/components/scene_list_popup.rs deleted file mode 100644 index fb6c459..0000000 --- a/crates/chattyness-user-ui/src/components/scene_list_popup.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! Scene list popup component for teleport command. -//! -//! Shows available scenes in a realm for teleportation. - -use leptos::prelude::*; - -use chattyness_db::models::SceneSummary; - -/// Scene list popup component. -/// -/// Shows scenes in a list with keyboard navigation for teleport selection. -/// -/// Props: -/// - `scenes`: List of available scenes -/// - `on_select`: Callback when a scene is selected (receives SceneSummary) -/// - `on_close`: Callback when popup should close -/// - `scene_filter`: Signal containing the current filter text -/// - `selected_idx`: Signal containing the currently selected index -#[component] -pub fn SceneListPopup( - scenes: Signal>, - 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! { - - - {scene_name} - - - "/teleport "{scene_slug} - - - } - }) - .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/scene_viewer.rs b/crates/chattyness-user-ui/src/components/scene_viewer.rs index 23dff40..5f19fd2 100644 --- a/crates/chattyness-user-ui/src/components/scene_viewer.rs +++ b/crates/chattyness-user-ui/src/components/scene_viewer.rs @@ -56,14 +56,10 @@ 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); @@ -187,19 +183,14 @@ pub fn RealmSceneViewer( move |ev: web_sys::MouseEvent| { use wasm_bindgen::JsCast; - // Guests cannot message other users - don't show context menu - if is_guest.get() { - return; - } + // Get click position + let client_x = ev.client_x() as f64; + let client_y = ev.client_y() as f64; // 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(); @@ -1042,7 +1033,6 @@ pub fn RealmSceneViewer( , /// 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. @@ -79,15 +71,6 @@ 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: @@ -97,7 +80,6 @@ pub struct TeleportInfo { pub fn use_channel_websocket( realm_slug: Signal, channel_id: Signal>, - reconnect_trigger: RwSignal, on_members_update: Callback>, on_chat_message: Callback, on_loose_props_sync: Callback>, @@ -106,7 +88,6 @@ 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; @@ -116,11 +97,6 @@ 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(); @@ -140,24 +116,14 @@ 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(); let ch_id = channel_id.get(); - // Track reconnect_trigger to force reconnection when it changes - let _trigger = reconnect_trigger.get(); // Cleanup previous connection if let Some(old_ws) = ws_ref_clone.borrow_mut().take() { - #[cfg(debug_assertions)] - web_sys::console::log_1( - &format!("[WS] Closing old connection, readyState={}", old_ws.ready_state()).into(), - ); - // Set flag BEFORE closing - guarantees local state even if close code doesn't arrive - *is_intentional_close_for_cleanup.borrow_mut() = true; - // Close with SCENE_CHANGE code so onclose handler knows this was intentional - let _ = old_ws.close_with_code_and_reason(close_codes::SCENE_CHANGE, "scene change"); + let _ = old_ws.close(); } let Some(ch_id) = ch_id else { @@ -219,13 +185,10 @@ 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)); 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(); @@ -240,9 +203,6 @@ 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; @@ -277,7 +237,6 @@ 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); } @@ -292,8 +251,6 @@ pub fn use_channel_websocket( &on_prop_picked_up_clone, &on_member_fading_clone, &on_error_clone, - &on_teleport_approved_clone, - ¤t_user_id_for_msg, ); } } @@ -303,82 +260,22 @@ 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()); - - // 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); - } + 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={}", code, e.reason()).into(), + &format!("[WS] Closed: code={}, reason={}", e.code(), e.reason()).into(), ); - - // Handle based on close code with defense-in-depth using flag - if code == close_codes::SERVER_TIMEOUT { - // Server timeout - attempt silent reconnection (highest priority) - #[cfg(debug_assertions)] - web_sys::console::log_1(&"[WS] Server timeout, attempting silent reconnection".into()); - set_ws_state_close.set(WsState::SilentReconnecting(1)); - // Schedule reconnection after delay - let reconnect_trigger = reconnect_trigger_for_close; - gloo_timers::callback::Timeout::new(SILENT_RECONNECT_DELAY_MS, move || { - reconnect_trigger.update(|v| *v = v.wrapping_add(1)); - }) - .forget(); - } else if code == close_codes::SCENE_CHANGE || *is_intentional_close_for_onclose.borrow() { - // Intentional close (scene change/teleport) - don't show disconnection - // Check both code AND flag for defense-in-depth (flag is guaranteed local state) - #[cfg(debug_assertions)] - web_sys::console::log_1(&"[WS] Intentional close, not setting Disconnected".into()); - // Reset the flag for future connections - *is_intentional_close_for_onclose.borrow_mut() = false; - } else { - // Other close codes - treat as disconnection - set_ws_state_close.set(WsState::Disconnected); - } + set_ws_state_close.set(WsState::Disconnected); }) as Box); ws.set_onclose(Some(onclose.as_ref().unchecked_ref())); onclose.forget(); @@ -401,8 +298,6 @@ fn handle_server_message( on_prop_picked_up: &Callback, on_member_fading: &Callback, on_error: &Option>, - on_teleport_approved: &Option>, - current_user_id: &std::rc::Rc>>, ) { let mut members_vec = members.borrow_mut(); @@ -429,18 +324,6 @@ 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() @@ -567,17 +450,6 @@ 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, - }); - } - } } } @@ -586,7 +458,6 @@ fn handle_server_message( pub fn use_channel_websocket( _realm_slug: Signal, _channel_id: Signal>, - _reconnect_trigger: RwSignal, _on_members_update: Callback>, _on_chat_message: Callback, _on_loose_props_sync: Callback>, @@ -595,7 +466,6 @@ 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 ef81a32..38f9609 100644 --- a/crates/chattyness-user-ui/src/pages/realm.rs +++ b/crates/chattyness-user-ui/src/pages/realm.rs @@ -14,20 +14,20 @@ use uuid::Uuid; use crate::components::{ ActiveBubble, AvatarEditorPopup, Card, ChatInput, ConversationModal, EmotionKeybindings, FadingMember, InventoryPopup, KeybindingsPopup, MessageLog, NotificationHistoryModal, - NotificationMessage, NotificationToast, RealmHeader, RealmSceneViewer, ReconnectionOverlay, - SettingsPopup, ViewerSettings, + NotificationMessage, NotificationToast, RealmHeader, RealmSceneViewer, SettingsPopup, + ViewerSettings, }; #[cfg(feature = "hydrate")] use crate::components::{ ChannelMemberInfo, ChatMessage, DEFAULT_BUBBLE_TIMEOUT_MS, FADE_DURATION_MS, HistoryEntry, - TeleportInfo, WsError, add_to_history, use_channel_websocket, + 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, SceneSummary, + RealmWithUserRole, Scene, }; #[cfg(feature = "hydrate")] use chattyness_db::ws_messages::ClientMessage; @@ -44,9 +44,6 @@ 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); @@ -101,8 +98,6 @@ 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); @@ -123,18 +118,6 @@ 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 = RwSignal::new(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 { @@ -327,7 +310,6 @@ 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.) @@ -336,8 +318,6 @@ 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)); @@ -349,74 +329,10 @@ 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.clone(); - let realm_slug = slug.get_untracked(); - - // Fetch the new scene data to update the canvas background - let scene_slug_for_url = scene_slug.clone(); - let realm_slug_for_url = realm_slug.clone(); - spawn_local(async move { - use gloo_net::http::Request; - let response = Request::get(&format!( - "/api/realms/{}/scenes/{}", - realm_slug, scene_slug - )) - .send() - .await; - - if let Ok(resp) = response { - if resp.ok() { - if let Ok(scene) = resp.json::().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 URL to reflect new scene - if let Some(window) = web_sys::window() { - if let Ok(history) = window.history() { - let new_url = if scene.is_entry_point { - format!("/realms/{}", realm_slug_for_url) - } else { - format!( - "/realms/{}/scenes/{}", - realm_slug_for_url, scene_slug_for_url - ) - }; - let _ = history.replace_state_with_url( - &wasm_bindgen::JsValue::NULL, - "", - Some(&new_url), - ); - } - } - - // Update the current scene for the viewer - set_current_scene.set(Some(scene)); - } - } - } - - // Update channel_id to trigger WebSocket reconnection - set_channel_id.set(Some(scene_id)); - - // Clear members since we're switching scenes - set_members.set(Vec::new()); - - // Trigger a reconnect to ensure fresh connection - reconnect_trigger.update(|t| *t += 1); - }); - }); - - #[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()), - reconnect_trigger, on_members_update, on_chat_message, on_loose_props_sync, @@ -425,172 +341,23 @@ pub fn RealmPage() -> impl IntoView { on_member_fading, Some(on_welcome), Some(on_ws_error), - Some(on_teleport_approved), ); - // Set channel ID, current scene, and scene dimensions when entry scene loads + // Set channel ID and scene dimensions when 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")] { - // Track whether we've handled initial scene load to prevent double-loading - let initial_scene_handled = StoredValue::new_local(false); - 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)); - } - } - }); - } - - // 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 { + let Some(scene) = entry_scene.get().flatten() else { return; }; + set_channel_id.set(Some(scene.id)); - // 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; + // 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)); } - - 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); - } - } - } - }); }); } @@ -919,8 +686,7 @@ pub fn RealmPage() -> impl IntoView { let realm_name = realm.name.clone(); let realm_slug_val = realm.slug.clone(); let realm_description = realm.tagline.clone(); - // Derive online count reactively from members signal - let online_count = Signal::derive(move || members.get().len() as i32); + let online_count = realm.current_user_count; let total_members = realm.member_count; let max_capacity = realm.max_users; let scene_name = scene_info.0; @@ -955,16 +721,11 @@ 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(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()); + Some(scene) => { 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()); @@ -986,22 +747,10 @@ 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 { 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 /> @@ -1032,9 +780,6 @@ pub fn RealmPage() -> 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 /> @@ -1073,7 +818,6 @@ 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()) /> } } @@ -1118,7 +862,6 @@ 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()) /> } } @@ -1210,22 +953,6 @@ 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() } diff --git a/crates/chattyness-user-ui/src/routes.rs b/crates/chattyness-user-ui/src/routes.rs index f523062..b6febbe 100644 --- a/crates/chattyness-user-ui/src/routes.rs +++ b/crates/chattyness-user-ui/src/routes.rs @@ -31,15 +31,6 @@ pub fn UserRoutes() -> impl IntoView { - } } diff --git a/db/schema/tables/030_realm.sql b/db/schema/tables/030_realm.sql index 99fa82d..41dd830 100644 --- a/db/schema/tables/030_realm.sql +++ b/db/schema/tables/030_realm.sql @@ -31,7 +31,6 @@ 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,
- {format!("attempting to reconnect in {} seconds", remaining)} -
- {format!("{} {}", phase_text, attempt_text)} -
"Attempting to restore connection"
- "Unable to reconnect after multiple attempts. Please check your network connection and try again." -