From 32e5e424621c9c2d7ab25d06ca61c6b887d073c67265b97d68bae81a0e7296e9 Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Mon, 19 Jan 2026 11:48:12 -0600 Subject: [PATCH] feat: add teleport --- crates/chattyness-db/src/models.rs | 4 + .../chattyness-db/src/queries/owner/realms.rs | 8 +- crates/chattyness-db/src/queries/realms.rs | 8 +- crates/chattyness-db/src/ws_messages.rs | 14 ++ .../chattyness-user-ui/src/api/websocket.rs | 88 +++++++ crates/chattyness-user-ui/src/components.rs | 2 + .../chattyness-user-ui/src/components/chat.rs | 220 +++++++++++++++++- .../src/components/scene_list_popup.rs | 127 ++++++++++ .../src/components/ws_client.rs | 25 ++ crates/chattyness-user-ui/src/pages/realm.rs | 122 +++++++++- db/schema/tables/030_realm.sql | 1 + 11 files changed, 603 insertions(+), 16 deletions(-) create mode 100644 crates/chattyness-user-ui/src/components/scene_list_popup.rs diff --git a/crates/chattyness-db/src/models.rs b/crates/chattyness-db/src/models.rs index 1378e00..c943e18 100644 --- a/crates/chattyness-db/src/models.rs +++ b/crates/chattyness-db/src/models.rs @@ -489,6 +489,7 @@ pub struct Realm { pub thumbnail_path: Option, pub max_users: i32, pub allow_guest_access: bool, + pub allow_user_teleport: bool, pub default_scene_id: Option, pub member_count: i32, pub current_user_count: i32, @@ -516,6 +517,7 @@ pub struct CreateRealmRequest { pub is_nsfw: bool, pub max_users: i32, pub allow_guest_access: bool, + pub allow_user_teleport: bool, pub theme_color: Option, } @@ -1361,6 +1363,7 @@ pub struct RealmDetail { pub thumbnail_path: Option, pub max_users: i32, pub allow_guest_access: bool, + pub allow_user_teleport: bool, pub member_count: i32, pub current_user_count: i32, pub created_at: DateTime, @@ -1377,6 +1380,7 @@ pub struct UpdateRealmRequest { pub is_nsfw: bool, pub max_users: i32, pub allow_guest_access: bool, + pub allow_user_teleport: bool, pub theme_color: Option, } diff --git a/crates/chattyness-db/src/queries/owner/realms.rs b/crates/chattyness-db/src/queries/owner/realms.rs index 156d6f0..17ca002 100644 --- a/crates/chattyness-db/src/queries/owner/realms.rs +++ b/crates/chattyness-db/src/queries/owner/realms.rs @@ -254,6 +254,7 @@ pub async fn get_realm_by_slug(pool: &PgPool, slug: &str) -> Result( r.thumbnail_path, r.max_users, r.allow_guest_access, + r.allow_user_teleport, r.default_scene_id, r.member_count, COALESCE(( @@ -131,6 +134,7 @@ pub async fn get_realm_by_id(pool: &PgPool, id: Uuid) -> Result, A r.thumbnail_path, r.max_users, r.allow_guest_access, + r.allow_user_teleport, r.default_scene_id, r.member_count, COALESCE(( diff --git a/crates/chattyness-db/src/ws_messages.rs b/crates/chattyness-db/src/ws_messages.rs index 80ad935..0068dc7 100644 --- a/crates/chattyness-db/src/ws_messages.rs +++ b/crates/chattyness-db/src/ws_messages.rs @@ -76,6 +76,12 @@ pub enum ClientMessage { /// Request to broadcast avatar appearance to other users. SyncAvatar, + + /// Request to teleport to a different scene. + Teleport { + /// Scene ID to teleport to. + scene_id: Uuid, + }, } /// Server-to-client WebSocket messages. @@ -212,4 +218,12 @@ pub enum ServerMessage { /// Updated avatar render data. avatar: AvatarRenderData, }, + + /// Teleport approved - client should disconnect and reconnect to new scene. + TeleportApproved { + /// Scene ID to navigate to. + scene_id: Uuid, + /// Scene slug for URL. + scene_slug: String, + }, } diff --git a/crates/chattyness-user-ui/src/api/websocket.rs b/crates/chattyness-user-ui/src/api/websocket.rs index 32a5274..934825c 100644 --- a/crates/chattyness-user-ui/src/api/websocket.rs +++ b/crates/chattyness-user-ui/src/api/websocket.rs @@ -391,11 +391,15 @@ async fn handle_socket( // Clone ws_state for use in recv_task let ws_state_for_recv = ws_state.clone(); + // Clone pool for use in recv_task (for teleport queries) + let pool_for_recv = pool.clone(); + // Create recv timeout from config let recv_timeout = Duration::from_secs(ws_config.recv_timeout_secs); // Spawn task to handle incoming messages from client let recv_task = tokio::spawn(async move { + let pool = pool_for_recv; let ws_state = ws_state_for_recv; let mut disconnect_reason = DisconnectReason::Graceful; @@ -724,6 +728,90 @@ async fn handle_socket( } } } + ClientMessage::Teleport { scene_id } => { + // Validate teleport permission and scene + // 1. Check realm allows user teleport + let realm = match realms::get_realm_by_id( + &pool, + realm_id, + ) + .await + { + Ok(Some(r)) => r, + Ok(None) => { + let _ = direct_tx.send(ServerMessage::Error { + code: "REALM_NOT_FOUND".to_string(), + message: "Realm not found".to_string(), + }).await; + continue; + } + Err(e) => { + tracing::error!("[WS] Teleport realm lookup failed: {:?}", e); + let _ = direct_tx.send(ServerMessage::Error { + code: "TELEPORT_FAILED".to_string(), + message: "Failed to verify teleport permission".to_string(), + }).await; + continue; + } + }; + + if !realm.allow_user_teleport { + let _ = direct_tx.send(ServerMessage::Error { + code: "TELEPORT_DISABLED".to_string(), + message: "Teleporting is not enabled for this realm".to_string(), + }).await; + continue; + } + + // 2. Validate scene exists, belongs to realm, and is not hidden + let scene = match scenes::get_scene_by_id(&pool, scene_id).await { + Ok(Some(s)) => s, + Ok(None) => { + let _ = direct_tx.send(ServerMessage::Error { + code: "SCENE_NOT_FOUND".to_string(), + message: "Scene not found".to_string(), + }).await; + continue; + } + Err(e) => { + tracing::error!("[WS] Teleport scene lookup failed: {:?}", e); + let _ = direct_tx.send(ServerMessage::Error { + code: "TELEPORT_FAILED".to_string(), + message: "Failed to verify scene".to_string(), + }).await; + continue; + } + }; + + if scene.realm_id != realm_id { + let _ = direct_tx.send(ServerMessage::Error { + code: "SCENE_NOT_IN_REALM".to_string(), + message: "Scene does not belong to this realm".to_string(), + }).await; + continue; + } + + if scene.is_hidden { + let _ = direct_tx.send(ServerMessage::Error { + code: "SCENE_HIDDEN".to_string(), + message: "Cannot teleport to a hidden scene".to_string(), + }).await; + continue; + } + + // 3. Send approval - client will disconnect and reconnect + #[cfg(debug_assertions)] + tracing::debug!( + "[WS] User {} teleporting to scene {} ({})", + user_id, + scene.name, + scene.slug + ); + let _ = direct_tx.send(ServerMessage::TeleportApproved { + scene_id: scene.id, + scene_slug: scene.slug, + }).await; + } } } Message::Close(close_frame) => { diff --git a/crates/chattyness-user-ui/src/components.rs b/crates/chattyness-user-ui/src/components.rs index ce93976..5779f00 100644 --- a/crates/chattyness-user-ui/src/components.rs +++ b/crates/chattyness-user-ui/src/components.rs @@ -16,6 +16,7 @@ pub mod layout; pub mod modals; pub mod notification_history; pub mod notifications; +pub mod scene_list_popup; pub mod scene_viewer; pub mod settings; pub mod settings_popup; @@ -40,6 +41,7 @@ pub use modals::*; pub use notification_history::*; pub use notifications::*; pub use reconnection_overlay::*; +pub use scene_list_popup::*; pub use scene_viewer::*; pub use settings::*; pub use settings_popup::*; diff --git a/crates/chattyness-user-ui/src/components/chat.rs b/crates/chattyness-user-ui/src/components/chat.rs index 18fdabd..0be1a35 100644 --- a/crates/chattyness-user-ui/src/components/chat.rs +++ b/crates/chattyness-user-ui/src/components/chat.rs @@ -1,11 +1,13 @@ //! Chat components for realm chat interface. use leptos::prelude::*; +use uuid::Uuid; -use chattyness_db::models::EmotionAvailability; +use chattyness_db::models::{EmotionAvailability, SceneSummary}; use chattyness_db::ws_messages::ClientMessage; use super::emotion_picker::{EMOTIONS, EmoteListPopup, LabelStyle}; +use super::scene_list_popup::SceneListPopup; use super::ws_client::WsSenderStorage; /// Command mode state for the chat input. @@ -19,6 +21,8 @@ enum CommandMode { ShowingSlashHint, /// Showing emotion list popup. ShowingList, + /// Showing scene list popup for teleport. + ShowingSceneList, } /// Parse an emote command and return the emotion name if valid. @@ -44,6 +48,28 @@ fn parse_emote_command(cmd: &str) -> Option { }) } +/// Parse a teleport command and return the scene slug if valid. +/// +/// Supports `/t slug` and `/teleport slug`. +fn parse_teleport_command(cmd: &str) -> Option { + let cmd = cmd.trim(); + + // Strip the leading slash if present + let cmd = cmd.strip_prefix('/').unwrap_or(cmd); + + // Check for `t ` or `teleport ` + let slug = cmd + .strip_prefix("teleport ") + .or_else(|| cmd.strip_prefix("t ")) + .map(str::trim)?; + + if slug.is_empty() { + return None; + } + + Some(slug.to_string()) +} + /// Parse a whisper command and return (target_name, message) if valid. /// /// Supports `/w name message` and `/whisper name message`. @@ -84,6 +110,9 @@ fn parse_whisper_command(cmd: &str) -> Option<(String, String)> { /// - `on_open_settings`: Callback to open settings popup /// - `on_open_inventory`: Callback to open inventory popup /// - `whisper_target`: Signal containing the display name to whisper to (triggers pre-fill) +/// - `scenes`: List of available scenes for teleport command +/// - `allow_user_teleport`: Whether teleporting is enabled for this realm +/// - `on_teleport`: Callback when a teleport is requested (receives scene ID) #[component] pub fn ChatInput( ws_sender: WsSenderStorage, @@ -97,11 +126,23 @@ pub fn ChatInput( /// Signal containing the display name to whisper to. When set, pre-fills the input. #[prop(optional, into)] whisper_target: Option>>, + /// List of available scenes for teleport command. + #[prop(optional, into)] + scenes: Option>>, + /// Whether teleporting is enabled for this realm. + #[prop(default = Signal::derive(|| false))] + allow_user_teleport: Signal, + /// Callback when a teleport is requested. + #[prop(optional)] + on_teleport: Option>, ) -> impl IntoView { let (message, set_message) = signal(String::new()); let (command_mode, set_command_mode) = signal(CommandMode::None); let (list_filter, set_list_filter) = signal(String::new()); let (selected_index, set_selected_index) = signal(0usize); + // Separate filter/index for scene list + let (scene_filter, set_scene_filter) = signal(String::new()); + let (scene_selected_index, set_scene_selected_index) = signal(0usize); let input_ref = NodeRef::::new(); // Compute filtered emotions for keyboard navigation @@ -121,6 +162,21 @@ pub fn ChatInput( .unwrap_or_default() }; + // Compute filtered scenes for teleport navigation + let filtered_scenes = move || { + let filter_text = scene_filter.get().to_lowercase(); + scenes + .map(|s| s.get()) + .unwrap_or_default() + .into_iter() + .filter(|s| { + filter_text.is_empty() + || s.name.to_lowercase().contains(&filter_text) + || s.slug.to_lowercase().contains(&filter_text) + }) + .collect::>() + }; + // Handle focus trigger from parent (when space, ':' or '/' is pressed globally) #[cfg(feature = "hydrate")] { @@ -204,13 +260,20 @@ pub fn ChatInput( let value = event_target_value(&ev); set_message.set(value.clone()); - // If list is showing, update filter (input is the filter text) + // If emotion list is showing, update filter (input is the filter text) if command_mode.get_untracked() == CommandMode::ShowingList { set_list_filter.set(value.clone()); set_selected_index.set(0); // Reset selection when filter changes return; } + // If scene list is showing, update filter (input is the filter text) + if command_mode.get_untracked() == CommandMode::ShowingSceneList { + set_scene_filter.set(value.clone()); + set_scene_selected_index.set(0); // Reset selection when filter changes + return; + } + if value.starts_with(':') { let cmd = value[1..].to_lowercase(); @@ -229,7 +292,7 @@ pub fn ChatInput( let cmd = value[1..].to_lowercase(); // Show hint for slash commands (don't execute until Enter) - // Match: /s[etting], /i[nventory], /w[hisper] + // Match: /s[etting], /i[nventory], /w[hisper], /t[eleport] // But NOT when whisper command is complete (has name + space for message) let is_complete_whisper = { // Check if it's "/w name " or "/whisper name " (name followed by space) @@ -243,18 +306,31 @@ pub fn ChatInput( } }; - if is_complete_whisper { - // User is typing the message part, no hint needed + // Check if teleport command is complete (has slug) + let is_complete_teleport = { + let rest = cmd.strip_prefix("teleport ").or_else(|| cmd.strip_prefix("t ")); + if let Some(after_cmd) = rest { + !after_cmd.is_empty() + } else { + false + } + }; + + if is_complete_whisper || is_complete_teleport { + // User is typing the argument part, no hint needed set_command_mode.set(CommandMode::None); } else if cmd.is_empty() || "setting".starts_with(&cmd) || "inventory".starts_with(&cmd) || "whisper".starts_with(&cmd) + || "teleport".starts_with(&cmd) || cmd == "setting" || cmd == "settings" || cmd == "inventory" || cmd.starts_with("w ") || cmd.starts_with("whisper ") + || cmd.starts_with("t ") + || cmd.starts_with("teleport ") { set_command_mode.set(CommandMode::ShowingSlashHint); } else { @@ -280,6 +356,8 @@ pub fn ChatInput( set_command_mode.set(CommandMode::None); set_list_filter.set(String::new()); set_selected_index.set(0); + set_scene_filter.set(String::new()); + set_scene_selected_index.set(0); set_message.set(String::new()); // Blur the input to unfocus chat if let Some(input) = input_ref.get() { @@ -329,6 +407,51 @@ pub fn ChatInput( } } + // Arrow key navigation when scene list is showing + if current_mode == CommandMode::ShowingSceneList { + let scene_list = filtered_scenes(); + let count = scene_list.len(); + + if key == "ArrowDown" && count > 0 { + set_scene_selected_index.update(|idx| { + *idx = (*idx + 1) % count; + }); + ev.prevent_default(); + return; + } + + if key == "ArrowUp" && count > 0 { + set_scene_selected_index.update(|idx| { + *idx = if *idx == 0 { count - 1 } else { *idx - 1 }; + }); + ev.prevent_default(); + return; + } + + if key == "Enter" && count > 0 { + // Select the currently highlighted scene - fill in command + let idx = scene_selected_index.get_untracked(); + if let Some(scene) = scene_list.get(idx) { + let cmd = format!("/teleport {}", scene.slug); + set_scene_filter.set(String::new()); + set_scene_selected_index.set(0); + set_command_mode.set(CommandMode::None); + set_message.set(cmd.clone()); + if let Some(input) = input_ref.get() { + input.set_value(&cmd); + } + } + ev.prevent_default(); + return; + } + + // Any other key in scene list mode is handled by on_input + if key == "Enter" { + ev.prevent_default(); + return; + } + } + // Tab for autocomplete if key == "Tab" { let msg = message.get(); @@ -352,6 +475,15 @@ pub fn ChatInput( ev.prevent_default(); return; } + // Autocomplete to /teleport if /t, /te, /tel, etc. + if !cmd.is_empty() && "teleport".starts_with(&cmd) && cmd != "teleport" { + set_message.set("/teleport".to_string()); + if let Some(input) = input_ref.get() { + input.set_value("/teleport"); + } + ev.prevent_default(); + return; + } } // Always prevent Tab from moving focus when in input ev.prevent_default(); @@ -416,6 +548,43 @@ pub fn ChatInput( return; } + // /t or /teleport (no slug yet) - show scene list if enabled + if allow_user_teleport.get_untracked() + && !cmd.is_empty() + && ("teleport".starts_with(&cmd) || cmd == "teleport") + { + set_command_mode.set(CommandMode::ShowingSceneList); + set_scene_filter.set(String::new()); + set_scene_selected_index.set(0); + set_message.set(String::new()); + if let Some(input) = input_ref.get() { + input.set_value(""); + } + ev.prevent_default(); + return; + } + + // /teleport {slug} - execute teleport + if let Some(slug) = parse_teleport_command(&msg) { + if allow_user_teleport.get_untracked() { + // Find the scene by slug + let scene_list = scenes.map(|s| s.get()).unwrap_or_default(); + if let Some(scene) = scene_list.iter().find(|s| s.slug == slug) { + if let Some(ref callback) = on_teleport { + callback.run(scene.id); + } + set_message.set(String::new()); + set_command_mode.set(CommandMode::None); + if let Some(input) = input_ref.get() { + input.set_value(""); + let _ = input.blur(); + } + } + } + ev.prevent_default(); + return; + } + // Invalid slash command - just ignore, don't send ev.prevent_default(); return; @@ -485,7 +654,7 @@ pub fn ChatInput( } }; - // Popup select handler + // Popup select handler for emotions let on_popup_select = Callback::new(move |emotion: String| { set_list_filter.set(String::new()); apply_emotion(emotion); @@ -496,7 +665,27 @@ pub fn ChatInput( set_command_mode.set(CommandMode::None); }); + // Scene popup select handler - fills in the command + let on_scene_select = Callback::new(move |scene: SceneSummary| { + let cmd = format!("/teleport {}", scene.slug); + set_scene_filter.set(String::new()); + set_scene_selected_index.set(0); + set_command_mode.set(CommandMode::None); + set_message.set(cmd.clone()); + if let Some(input) = input_ref.get() { + input.set_value(&cmd); + } + }); + + let on_scene_popup_close = Callback::new(move |_: ()| { + set_scene_filter.set(String::new()); + set_scene_selected_index.set(0); + set_command_mode.set(CommandMode::None); + }); + let filter_signal = Signal::derive(move || list_filter.get()); + let scene_filter_signal = Signal::derive(move || scene_filter.get()); + let scenes_signal = Signal::derive(move || scenes.map(|s| s.get()).unwrap_or_default()); view! {
@@ -513,7 +702,7 @@ pub fn ChatInput(
- // Slash command hint bar (/s[etting], /i[nventory], /w[hisper]) + // Slash command hint bar (/s[etting], /i[nventory], /w[hisper], /t[eleport])
"/" @@ -527,6 +716,12 @@ pub fn ChatInput( "/" "w" "[hisper] name" + + "|" + "/" + "t" + "[eleport]" +
@@ -543,6 +738,17 @@ pub fn ChatInput( /> + // Scene list popup for teleport + + + +
>, + on_select: Callback, + #[prop(into)] on_close: Callback<()>, + #[prop(into)] scene_filter: Signal, + #[prop(into)] selected_idx: Signal, +) -> impl IntoView { + let _ = on_close; // Suppress unused warning + + // Get list of scenes, filtered by search text + let filtered_scenes = move || { + let filter_text = scene_filter.get().to_lowercase(); + scenes + .get() + .into_iter() + .filter(|s| { + filter_text.is_empty() + || s.name.to_lowercase().contains(&filter_text) + || s.slug.to_lowercase().contains(&filter_text) + }) + .collect::>() + }; + + let filter_display = move || { + let f = scene_filter.get(); + if f.is_empty() { + "Type to filter...".to_string() + } else { + format!("Filter: {}", f) + } + }; + + // Indexed scenes for selection tracking + let indexed_scenes = move || { + filtered_scenes() + .into_iter() + .enumerate() + .collect::>() + }; + + view! { +
+
+ "Select a scene to teleport to:" + {filter_display} +
+
+ {move || { + indexed_scenes() + .into_iter() + .map(|(idx, scene)| { + let on_select = on_select.clone(); + let scene_for_click = scene.clone(); + let scene_name = scene.name.clone(); + let scene_slug = scene.slug.clone(); + let is_selected = move || selected_idx.get() == idx; + view! { + + } + }) + .collect_view() + }} +
+ +
+ {move || { + if scene_filter.get().is_empty() { + "No scenes available" + } else { + "No matching scenes" + } + }} +
+
+
+ "^_" + " navigate " + "Enter" + " select " + "Esc" + " cancel" +
+
+ } +} diff --git a/crates/chattyness-user-ui/src/components/ws_client.rs b/crates/chattyness-user-ui/src/components/ws_client.rs index ef026fa..8ec1877 100644 --- a/crates/chattyness-user-ui/src/components/ws_client.rs +++ b/crates/chattyness-user-ui/src/components/ws_client.rs @@ -73,6 +73,15 @@ pub struct WsError { pub message: String, } +/// Teleport information received from server. +#[derive(Clone, Debug)] +pub struct TeleportInfo { + /// Scene ID to teleport to. + pub scene_id: uuid::Uuid, + /// Scene slug for URL. + pub scene_slug: String, +} + /// Hook to manage WebSocket connection for a channel. /// /// Returns a tuple of: @@ -91,6 +100,7 @@ pub fn use_channel_websocket( on_member_fading: Callback, on_welcome: Option>, on_error: Option>, + on_teleport_approved: Option>, ) -> (Signal, WsSenderStorage) { use std::cell::RefCell; use std::rc::Rc; @@ -190,6 +200,7 @@ pub fn use_channel_websocket( let on_member_fading_clone = on_member_fading.clone(); let on_welcome_clone = on_welcome.clone(); let on_error_clone = on_error.clone(); + let on_teleport_approved_clone = on_teleport_approved.clone(); // For starting heartbeat on Welcome let ws_ref_for_heartbeat = ws_ref.clone(); let heartbeat_started: Rc> = Rc::new(RefCell::new(false)); @@ -257,6 +268,7 @@ pub fn use_channel_websocket( &on_prop_picked_up_clone, &on_member_fading_clone, &on_error_clone, + &on_teleport_approved_clone, ); } } @@ -304,6 +316,7 @@ fn handle_server_message( on_prop_picked_up: &Callback, on_member_fading: &Callback, on_error: &Option>, + on_teleport_approved: &Option>, ) { let mut members_vec = members.borrow_mut(); @@ -456,6 +469,17 @@ fn handle_server_message( } on_update.run(members_vec.clone()); } + ServerMessage::TeleportApproved { + scene_id, + scene_slug, + } => { + if let Some(callback) = on_teleport_approved { + callback.run(TeleportInfo { + scene_id, + scene_slug, + }); + } + } } } @@ -473,6 +497,7 @@ pub fn use_channel_websocket( _on_member_fading: Callback, _on_welcome: Option>, _on_error: Option>, + _on_teleport_approved: Option>, ) -> (Signal, WsSenderStorage) { let (ws_state, _) = signal(WsState::Disconnected); let sender: WsSenderStorage = StoredValue::new_local(None); diff --git a/crates/chattyness-user-ui/src/pages/realm.rs b/crates/chattyness-user-ui/src/pages/realm.rs index 882847f..980f71f 100644 --- a/crates/chattyness-user-ui/src/pages/realm.rs +++ b/crates/chattyness-user-ui/src/pages/realm.rs @@ -20,14 +20,14 @@ use crate::components::{ #[cfg(feature = "hydrate")] use crate::components::{ ChannelMemberInfo, ChatMessage, DEFAULT_BUBBLE_TIMEOUT_MS, FADE_DURATION_MS, HistoryEntry, - WsError, add_to_history, use_channel_websocket, + TeleportInfo, WsError, add_to_history, use_channel_websocket, }; use crate::utils::LocalStoragePersist; #[cfg(feature = "hydrate")] use crate::utils::parse_bounds_dimensions; use chattyness_db::models::{ AvatarWithPaths, ChannelMemberWithAvatar, EmotionAvailability, LooseProp, RealmRole, - RealmWithUserRole, Scene, + RealmWithUserRole, Scene, SceneSummary, }; #[cfg(feature = "hydrate")] use chattyness_db::ws_messages::ClientMessage; @@ -123,6 +123,15 @@ pub fn RealmPage() -> impl IntoView { // Reconnection trigger - increment to force WebSocket reconnection let (reconnect_trigger, set_reconnect_trigger) = signal(0u32); + // Current scene (changes when teleporting) + let (current_scene, set_current_scene) = signal(Option::::None); + + // Available scenes for teleportation (cached on load) + let (available_scenes, set_available_scenes) = signal(Vec::::new()); + + // Whether teleportation is allowed in this realm + let (allow_user_teleport, set_allow_user_teleport) = signal(false); + let realm_data = LocalResource::new(move || { let slug = slug.get(); async move { @@ -324,6 +333,8 @@ pub fn RealmPage() -> impl IntoView { // Display user-friendly error message let msg = match error.code.as_str() { "WHISPER_TARGET_NOT_FOUND" => error.message, + "TELEPORT_DISABLED" => error.message, + "SCENE_NOT_FOUND" => error.message, _ => format!("Error: {}", error.message), }; set_error_message.set(Some(msg)); @@ -335,6 +346,47 @@ pub fn RealmPage() -> impl IntoView { .forget(); }); + // Callback for teleport approval - navigate to new scene + #[cfg(feature = "hydrate")] + let on_teleport_approved = Callback::new(move |info: TeleportInfo| { + let scene_id = info.scene_id; + let scene_slug = info.scene_slug; + let realm_slug = slug.get_untracked(); + + // Fetch the new scene data to update the canvas background + spawn_local(async move { + use gloo_net::http::Request; + let response = Request::get(&format!( + "/api/realms/{}/scenes/{}", + realm_slug, scene_slug + )) + .send() + .await; + + if let Ok(resp) = response { + if resp.ok() { + if let Ok(scene) = resp.json::().await { + // Update scene dimensions from the new scene + if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) { + set_scene_dimensions.set((w as f64, h as f64)); + } + // Update the current scene for the viewer + set_current_scene.set(Some(scene)); + } + } + } + + // Update channel_id to trigger WebSocket reconnection + set_channel_id.set(Some(scene_id)); + + // Clear members since we're switching scenes + set_members.set(Vec::new()); + + // Trigger a reconnect to ensure fresh connection + set_reconnect_trigger.update(|t| *t += 1); + }); + }); + #[cfg(feature = "hydrate")] let (ws_state, ws_sender) = use_channel_websocket( slug, @@ -348,9 +400,10 @@ pub fn RealmPage() -> impl IntoView { on_member_fading, Some(on_welcome), Some(on_ws_error), + Some(on_teleport_approved), ); - // Set channel ID and scene dimensions when scene loads + // Set channel ID, current scene, and scene dimensions when entry scene loads // Note: Currently using scene.id as the channel_id since channel_members // uses scenes directly. Proper channel infrastructure can be added later. #[cfg(feature = "hydrate")] @@ -360,6 +413,7 @@ pub fn RealmPage() -> impl IntoView { return; }; set_channel_id.set(Some(scene.id)); + set_current_scene.set(Some(scene.clone())); // Extract scene dimensions from bounds_wkt if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) { @@ -368,6 +422,44 @@ pub fn RealmPage() -> impl IntoView { }); } + // Fetch available scenes and realm settings when realm loads + #[cfg(feature = "hydrate")] + { + Effect::new(move |_| { + let Some(realm_with_role) = realm_data.get().flatten() else { + return; + }; + + // Set allow_user_teleport from realm settings + set_allow_user_teleport.set(realm_with_role.realm.allow_user_teleport); + + // Fetch scenes list for teleport command + let current_slug = slug.get(); + if current_slug.is_empty() { + return; + } + + spawn_local(async move { + use gloo_net::http::Request; + let response = Request::get(&format!("/api/realms/{}/scenes", current_slug)) + .send() + .await; + if let Ok(resp) = response { + if resp.ok() { + if let Ok(scenes) = resp.json::>().await { + // Filter out hidden scenes + let visible_scenes: Vec = scenes + .into_iter() + .filter(|s| !s.is_hidden) + .collect(); + set_available_scenes.set(visible_scenes); + } + } + } + }); + }); + } + // Cleanup expired speech bubbles and fading members every second #[cfg(feature = "hydrate")] { @@ -729,11 +821,16 @@ pub fn RealmPage() -> impl IntoView { let realm_slug_for_viewer = realm_slug_val.clone(); #[cfg(feature = "hydrate")] let ws_sender_clone = ws_sender.clone(); + // Read current_scene in reactive context (before .map()) + // so changes trigger re-render + let current_scene_val = current_scene.get(); entry_scene .get() .map(|maybe_scene| { match maybe_scene { - Some(scene) => { + Some(entry_scene_data) => { + // Use current_scene if set (after teleport), otherwise use entry scene + let display_scene = current_scene_val.clone().unwrap_or_else(|| entry_scene_data.clone()); let members_signal = Signal::derive(move || members.get()); let emotion_avail_signal = Signal::derive(move || emotion_availability.get()); let skin_path_signal = Signal::derive(move || skin_preview_path.get()); @@ -755,10 +852,22 @@ pub fn RealmPage() -> impl IntoView { let on_whisper_request_cb = Callback::new(move |target: String| { set_whisper_target.set(Some(target)); }); + let scenes_signal = Signal::derive(move || available_scenes.get()); + let teleport_enabled_signal = Signal::derive(move || allow_user_teleport.get()); + #[cfg(feature = "hydrate")] + let ws_for_teleport = ws_sender_clone.clone(); + let on_teleport_cb = Callback::new(move |scene_id: Uuid| { + #[cfg(feature = "hydrate")] + ws_for_teleport.with_value(|sender| { + if let Some(send_fn) = sender { + send_fn(ClientMessage::Teleport { scene_id }); + } + }); + }); view! {
impl IntoView { on_open_settings=on_open_settings_cb on_open_inventory=on_open_inventory_cb whisper_target=whisper_target_signal + scenes=scenes_signal + allow_user_teleport=teleport_enabled_signal + on_teleport=on_teleport_cb />
diff --git a/db/schema/tables/030_realm.sql b/db/schema/tables/030_realm.sql index 41dd830..99fa82d 100644 --- a/db/schema/tables/030_realm.sql +++ b/db/schema/tables/030_realm.sql @@ -31,6 +31,7 @@ CREATE TABLE realm.realms ( max_users INTEGER NOT NULL DEFAULT 100 CHECK (max_users > 0 AND max_users <= 10000), allow_guest_access BOOLEAN NOT NULL DEFAULT true, + allow_user_teleport BOOLEAN NOT NULL DEFAULT false, default_scene_id UUID,