feat: add teleport
This commit is contained in:
parent
226c2e02b5
commit
32e5e42462
11 changed files with 603 additions and 16 deletions
|
|
@ -489,6 +489,7 @@ pub struct Realm {
|
||||||
pub thumbnail_path: Option<String>,
|
pub thumbnail_path: Option<String>,
|
||||||
pub max_users: i32,
|
pub max_users: i32,
|
||||||
pub allow_guest_access: bool,
|
pub allow_guest_access: bool,
|
||||||
|
pub allow_user_teleport: bool,
|
||||||
pub default_scene_id: Option<Uuid>,
|
pub default_scene_id: Option<Uuid>,
|
||||||
pub member_count: i32,
|
pub member_count: i32,
|
||||||
pub current_user_count: i32,
|
pub current_user_count: i32,
|
||||||
|
|
@ -516,6 +517,7 @@ pub struct CreateRealmRequest {
|
||||||
pub is_nsfw: bool,
|
pub is_nsfw: bool,
|
||||||
pub max_users: i32,
|
pub max_users: i32,
|
||||||
pub allow_guest_access: bool,
|
pub allow_guest_access: bool,
|
||||||
|
pub allow_user_teleport: bool,
|
||||||
pub theme_color: Option<String>,
|
pub theme_color: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1361,6 +1363,7 @@ pub struct RealmDetail {
|
||||||
pub thumbnail_path: Option<String>,
|
pub thumbnail_path: Option<String>,
|
||||||
pub max_users: i32,
|
pub max_users: i32,
|
||||||
pub allow_guest_access: bool,
|
pub allow_guest_access: bool,
|
||||||
|
pub allow_user_teleport: bool,
|
||||||
pub member_count: i32,
|
pub member_count: i32,
|
||||||
pub current_user_count: i32,
|
pub current_user_count: i32,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
|
|
@ -1377,6 +1380,7 @@ pub struct UpdateRealmRequest {
|
||||||
pub is_nsfw: bool,
|
pub is_nsfw: bool,
|
||||||
pub max_users: i32,
|
pub max_users: i32,
|
||||||
pub allow_guest_access: bool,
|
pub allow_guest_access: bool,
|
||||||
|
pub allow_user_teleport: bool,
|
||||||
pub theme_color: Option<String>,
|
pub theme_color: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -254,6 +254,7 @@ pub async fn get_realm_by_slug(pool: &PgPool, slug: &str) -> Result<RealmDetail,
|
||||||
r.thumbnail_path,
|
r.thumbnail_path,
|
||||||
r.max_users,
|
r.max_users,
|
||||||
r.allow_guest_access,
|
r.allow_guest_access,
|
||||||
|
r.allow_user_teleport,
|
||||||
r.member_count,
|
r.member_count,
|
||||||
COALESCE((
|
COALESCE((
|
||||||
SELECT COUNT(*)::INTEGER
|
SELECT COUNT(*)::INTEGER
|
||||||
|
|
@ -294,9 +295,10 @@ pub async fn update_realm(
|
||||||
is_nsfw = $5,
|
is_nsfw = $5,
|
||||||
max_users = $6,
|
max_users = $6,
|
||||||
allow_guest_access = $7,
|
allow_guest_access = $7,
|
||||||
theme_color = $8,
|
allow_user_teleport = $8,
|
||||||
|
theme_color = $9,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $9
|
WHERE id = $10
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(&req.name)
|
.bind(&req.name)
|
||||||
|
|
@ -306,6 +308,7 @@ pub async fn update_realm(
|
||||||
.bind(req.is_nsfw)
|
.bind(req.is_nsfw)
|
||||||
.bind(req.max_users)
|
.bind(req.max_users)
|
||||||
.bind(req.allow_guest_access)
|
.bind(req.allow_guest_access)
|
||||||
|
.bind(req.allow_user_teleport)
|
||||||
.bind(&req.theme_color)
|
.bind(&req.theme_color)
|
||||||
.bind(realm_id)
|
.bind(realm_id)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
|
|
@ -331,6 +334,7 @@ pub async fn update_realm(
|
||||||
r.thumbnail_path,
|
r.thumbnail_path,
|
||||||
r.max_users,
|
r.max_users,
|
||||||
r.allow_guest_access,
|
r.allow_guest_access,
|
||||||
|
r.allow_user_teleport,
|
||||||
r.member_count,
|
r.member_count,
|
||||||
COALESCE((
|
COALESCE((
|
||||||
SELECT COUNT(*)::INTEGER
|
SELECT COUNT(*)::INTEGER
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,9 @@ pub async fn create_realm(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO realm.realms (
|
INSERT INTO realm.realms (
|
||||||
name, slug, description, tagline, owner_id,
|
name, slug, description, tagline, owner_id,
|
||||||
privacy, is_nsfw, max_users, allow_guest_access, theme_color
|
privacy, is_nsfw, max_users, allow_guest_access, allow_user_teleport, theme_color
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
RETURNING
|
RETURNING
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
|
|
@ -35,6 +35,7 @@ pub async fn create_realm(
|
||||||
thumbnail_path,
|
thumbnail_path,
|
||||||
max_users,
|
max_users,
|
||||||
allow_guest_access,
|
allow_guest_access,
|
||||||
|
allow_user_teleport,
|
||||||
default_scene_id,
|
default_scene_id,
|
||||||
member_count,
|
member_count,
|
||||||
current_user_count,
|
current_user_count,
|
||||||
|
|
@ -51,6 +52,7 @@ pub async fn create_realm(
|
||||||
.bind(req.is_nsfw)
|
.bind(req.is_nsfw)
|
||||||
.bind(req.max_users)
|
.bind(req.max_users)
|
||||||
.bind(req.allow_guest_access)
|
.bind(req.allow_guest_access)
|
||||||
|
.bind(req.allow_user_teleport)
|
||||||
.bind(&req.theme_color)
|
.bind(&req.theme_color)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
@ -91,6 +93,7 @@ pub async fn get_realm_by_slug<'e>(
|
||||||
r.thumbnail_path,
|
r.thumbnail_path,
|
||||||
r.max_users,
|
r.max_users,
|
||||||
r.allow_guest_access,
|
r.allow_guest_access,
|
||||||
|
r.allow_user_teleport,
|
||||||
r.default_scene_id,
|
r.default_scene_id,
|
||||||
r.member_count,
|
r.member_count,
|
||||||
COALESCE((
|
COALESCE((
|
||||||
|
|
@ -131,6 +134,7 @@ pub async fn get_realm_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Realm>, A
|
||||||
r.thumbnail_path,
|
r.thumbnail_path,
|
||||||
r.max_users,
|
r.max_users,
|
||||||
r.allow_guest_access,
|
r.allow_guest_access,
|
||||||
|
r.allow_user_teleport,
|
||||||
r.default_scene_id,
|
r.default_scene_id,
|
||||||
r.member_count,
|
r.member_count,
|
||||||
COALESCE((
|
COALESCE((
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,12 @@ pub enum ClientMessage {
|
||||||
|
|
||||||
/// Request to broadcast avatar appearance to other users.
|
/// Request to broadcast avatar appearance to other users.
|
||||||
SyncAvatar,
|
SyncAvatar,
|
||||||
|
|
||||||
|
/// Request to teleport to a different scene.
|
||||||
|
Teleport {
|
||||||
|
/// Scene ID to teleport to.
|
||||||
|
scene_id: Uuid,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Server-to-client WebSocket messages.
|
/// Server-to-client WebSocket messages.
|
||||||
|
|
@ -212,4 +218,12 @@ pub enum ServerMessage {
|
||||||
/// Updated avatar render data.
|
/// Updated avatar render data.
|
||||||
avatar: AvatarRenderData,
|
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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -391,11 +391,15 @@ async fn handle_socket(
|
||||||
// Clone ws_state for use in recv_task
|
// Clone ws_state for use in recv_task
|
||||||
let ws_state_for_recv = ws_state.clone();
|
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
|
// Create recv timeout from config
|
||||||
let recv_timeout = Duration::from_secs(ws_config.recv_timeout_secs);
|
let recv_timeout = Duration::from_secs(ws_config.recv_timeout_secs);
|
||||||
|
|
||||||
// Spawn task to handle incoming messages from client
|
// Spawn task to handle incoming messages from client
|
||||||
let recv_task = tokio::spawn(async move {
|
let recv_task = tokio::spawn(async move {
|
||||||
|
let pool = pool_for_recv;
|
||||||
let ws_state = ws_state_for_recv;
|
let ws_state = ws_state_for_recv;
|
||||||
let mut disconnect_reason = DisconnectReason::Graceful;
|
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) => {
|
Message::Close(close_frame) => {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ pub mod layout;
|
||||||
pub mod modals;
|
pub mod modals;
|
||||||
pub mod notification_history;
|
pub mod notification_history;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
|
pub mod scene_list_popup;
|
||||||
pub mod scene_viewer;
|
pub mod scene_viewer;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod settings_popup;
|
pub mod settings_popup;
|
||||||
|
|
@ -40,6 +41,7 @@ pub use modals::*;
|
||||||
pub use notification_history::*;
|
pub use notification_history::*;
|
||||||
pub use notifications::*;
|
pub use notifications::*;
|
||||||
pub use reconnection_overlay::*;
|
pub use reconnection_overlay::*;
|
||||||
|
pub use scene_list_popup::*;
|
||||||
pub use scene_viewer::*;
|
pub use scene_viewer::*;
|
||||||
pub use settings::*;
|
pub use settings::*;
|
||||||
pub use settings_popup::*;
|
pub use settings_popup::*;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
//! Chat components for realm chat interface.
|
//! Chat components for realm chat interface.
|
||||||
|
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use chattyness_db::models::EmotionAvailability;
|
use chattyness_db::models::{EmotionAvailability, SceneSummary};
|
||||||
use chattyness_db::ws_messages::ClientMessage;
|
use chattyness_db::ws_messages::ClientMessage;
|
||||||
|
|
||||||
use super::emotion_picker::{EMOTIONS, EmoteListPopup, LabelStyle};
|
use super::emotion_picker::{EMOTIONS, EmoteListPopup, LabelStyle};
|
||||||
|
use super::scene_list_popup::SceneListPopup;
|
||||||
use super::ws_client::WsSenderStorage;
|
use super::ws_client::WsSenderStorage;
|
||||||
|
|
||||||
/// Command mode state for the chat input.
|
/// Command mode state for the chat input.
|
||||||
|
|
@ -19,6 +21,8 @@ enum CommandMode {
|
||||||
ShowingSlashHint,
|
ShowingSlashHint,
|
||||||
/// Showing emotion list popup.
|
/// Showing emotion list popup.
|
||||||
ShowingList,
|
ShowingList,
|
||||||
|
/// Showing scene list popup for teleport.
|
||||||
|
ShowingSceneList,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse an emote command and return the emotion name if valid.
|
/// Parse an emote command and return the emotion name if valid.
|
||||||
|
|
@ -44,6 +48,28 @@ fn parse_emote_command(cmd: &str) -> Option<String> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse a teleport command and return the scene slug if valid.
|
||||||
|
///
|
||||||
|
/// Supports `/t slug` and `/teleport slug`.
|
||||||
|
fn parse_teleport_command(cmd: &str) -> Option<String> {
|
||||||
|
let cmd = cmd.trim();
|
||||||
|
|
||||||
|
// Strip the leading slash if present
|
||||||
|
let cmd = cmd.strip_prefix('/').unwrap_or(cmd);
|
||||||
|
|
||||||
|
// Check for `t <slug>` or `teleport <slug>`
|
||||||
|
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.
|
/// Parse a whisper command and return (target_name, message) if valid.
|
||||||
///
|
///
|
||||||
/// Supports `/w name message` and `/whisper name message`.
|
/// 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_settings`: Callback to open settings popup
|
||||||
/// - `on_open_inventory`: Callback to open inventory popup
|
/// - `on_open_inventory`: Callback to open inventory popup
|
||||||
/// - `whisper_target`: Signal containing the display name to whisper to (triggers pre-fill)
|
/// - `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]
|
#[component]
|
||||||
pub fn ChatInput(
|
pub fn ChatInput(
|
||||||
ws_sender: WsSenderStorage,
|
ws_sender: WsSenderStorage,
|
||||||
|
|
@ -97,11 +126,23 @@ pub fn ChatInput(
|
||||||
/// Signal containing the display name to whisper to. When set, pre-fills the input.
|
/// Signal containing the display name to whisper to. When set, pre-fills the input.
|
||||||
#[prop(optional, into)]
|
#[prop(optional, into)]
|
||||||
whisper_target: Option<Signal<Option<String>>>,
|
whisper_target: Option<Signal<Option<String>>>,
|
||||||
|
/// List of available scenes for teleport command.
|
||||||
|
#[prop(optional, into)]
|
||||||
|
scenes: Option<Signal<Vec<SceneSummary>>>,
|
||||||
|
/// Whether teleporting is enabled for this realm.
|
||||||
|
#[prop(default = Signal::derive(|| false))]
|
||||||
|
allow_user_teleport: Signal<bool>,
|
||||||
|
/// Callback when a teleport is requested.
|
||||||
|
#[prop(optional)]
|
||||||
|
on_teleport: Option<Callback<Uuid>>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let (message, set_message) = signal(String::new());
|
let (message, set_message) = signal(String::new());
|
||||||
let (command_mode, set_command_mode) = signal(CommandMode::None);
|
let (command_mode, set_command_mode) = signal(CommandMode::None);
|
||||||
let (list_filter, set_list_filter) = signal(String::new());
|
let (list_filter, set_list_filter) = signal(String::new());
|
||||||
let (selected_index, set_selected_index) = signal(0usize);
|
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::<leptos::html::Input>::new();
|
let input_ref = NodeRef::<leptos::html::Input>::new();
|
||||||
|
|
||||||
// Compute filtered emotions for keyboard navigation
|
// Compute filtered emotions for keyboard navigation
|
||||||
|
|
@ -121,6 +162,21 @@ pub fn ChatInput(
|
||||||
.unwrap_or_default()
|
.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::<Vec<_>>()
|
||||||
|
};
|
||||||
|
|
||||||
// Handle focus trigger from parent (when space, ':' or '/' is pressed globally)
|
// Handle focus trigger from parent (when space, ':' or '/' is pressed globally)
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
{
|
{
|
||||||
|
|
@ -204,13 +260,20 @@ pub fn ChatInput(
|
||||||
let value = event_target_value(&ev);
|
let value = event_target_value(&ev);
|
||||||
set_message.set(value.clone());
|
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 {
|
if command_mode.get_untracked() == CommandMode::ShowingList {
|
||||||
set_list_filter.set(value.clone());
|
set_list_filter.set(value.clone());
|
||||||
set_selected_index.set(0); // Reset selection when filter changes
|
set_selected_index.set(0); // Reset selection when filter changes
|
||||||
return;
|
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(':') {
|
if value.starts_with(':') {
|
||||||
let cmd = value[1..].to_lowercase();
|
let cmd = value[1..].to_lowercase();
|
||||||
|
|
||||||
|
|
@ -229,7 +292,7 @@ pub fn ChatInput(
|
||||||
let cmd = value[1..].to_lowercase();
|
let cmd = value[1..].to_lowercase();
|
||||||
|
|
||||||
// Show hint for slash commands (don't execute until Enter)
|
// 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)
|
// But NOT when whisper command is complete (has name + space for message)
|
||||||
let is_complete_whisper = {
|
let is_complete_whisper = {
|
||||||
// Check if it's "/w name " or "/whisper name " (name followed by space)
|
// Check if it's "/w name " or "/whisper name " (name followed by space)
|
||||||
|
|
@ -243,18 +306,31 @@ pub fn ChatInput(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if is_complete_whisper {
|
// Check if teleport command is complete (has slug)
|
||||||
// User is typing the message part, no hint needed
|
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);
|
set_command_mode.set(CommandMode::None);
|
||||||
} else if cmd.is_empty()
|
} else if cmd.is_empty()
|
||||||
|| "setting".starts_with(&cmd)
|
|| "setting".starts_with(&cmd)
|
||||||
|| "inventory".starts_with(&cmd)
|
|| "inventory".starts_with(&cmd)
|
||||||
|| "whisper".starts_with(&cmd)
|
|| "whisper".starts_with(&cmd)
|
||||||
|
|| "teleport".starts_with(&cmd)
|
||||||
|| cmd == "setting"
|
|| cmd == "setting"
|
||||||
|| cmd == "settings"
|
|| cmd == "settings"
|
||||||
|| cmd == "inventory"
|
|| cmd == "inventory"
|
||||||
|| cmd.starts_with("w ")
|
|| cmd.starts_with("w ")
|
||||||
|| cmd.starts_with("whisper ")
|
|| cmd.starts_with("whisper ")
|
||||||
|
|| cmd.starts_with("t ")
|
||||||
|
|| cmd.starts_with("teleport ")
|
||||||
{
|
{
|
||||||
set_command_mode.set(CommandMode::ShowingSlashHint);
|
set_command_mode.set(CommandMode::ShowingSlashHint);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -280,6 +356,8 @@ pub fn ChatInput(
|
||||||
set_command_mode.set(CommandMode::None);
|
set_command_mode.set(CommandMode::None);
|
||||||
set_list_filter.set(String::new());
|
set_list_filter.set(String::new());
|
||||||
set_selected_index.set(0);
|
set_selected_index.set(0);
|
||||||
|
set_scene_filter.set(String::new());
|
||||||
|
set_scene_selected_index.set(0);
|
||||||
set_message.set(String::new());
|
set_message.set(String::new());
|
||||||
// Blur the input to unfocus chat
|
// Blur the input to unfocus chat
|
||||||
if let Some(input) = input_ref.get() {
|
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
|
// Tab for autocomplete
|
||||||
if key == "Tab" {
|
if key == "Tab" {
|
||||||
let msg = message.get();
|
let msg = message.get();
|
||||||
|
|
@ -352,6 +475,15 @@ pub fn ChatInput(
|
||||||
ev.prevent_default();
|
ev.prevent_default();
|
||||||
return;
|
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
|
// Always prevent Tab from moving focus when in input
|
||||||
ev.prevent_default();
|
ev.prevent_default();
|
||||||
|
|
@ -416,6 +548,43 @@ pub fn ChatInput(
|
||||||
return;
|
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
|
// Invalid slash command - just ignore, don't send
|
||||||
ev.prevent_default();
|
ev.prevent_default();
|
||||||
return;
|
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| {
|
let on_popup_select = Callback::new(move |emotion: String| {
|
||||||
set_list_filter.set(String::new());
|
set_list_filter.set(String::new());
|
||||||
apply_emotion(emotion);
|
apply_emotion(emotion);
|
||||||
|
|
@ -496,7 +665,27 @@ pub fn ChatInput(
|
||||||
set_command_mode.set(CommandMode::None);
|
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 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! {
|
view! {
|
||||||
<div class="chat-input-container w-full max-w-4xl mx-auto pointer-events-auto relative">
|
<div class="chat-input-container w-full max-w-4xl mx-auto pointer-events-auto relative">
|
||||||
|
|
@ -513,7 +702,7 @@ pub fn ChatInput(
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
// Slash command hint bar (/s[etting], /i[nventory], /w[hisper])
|
// Slash command hint bar (/s[etting], /i[nventory], /w[hisper], /t[eleport])
|
||||||
<Show when=move || command_mode.get() == CommandMode::ShowingSlashHint>
|
<Show when=move || command_mode.get() == CommandMode::ShowingSlashHint>
|
||||||
<div class="absolute bottom-full left-0 mb-1 px-3 py-1 bg-gray-700/90 backdrop-blur-sm rounded text-sm">
|
<div class="absolute bottom-full left-0 mb-1 px-3 py-1 bg-gray-700/90 backdrop-blur-sm rounded text-sm">
|
||||||
<span class="text-gray-400">"/"</span>
|
<span class="text-gray-400">"/"</span>
|
||||||
|
|
@ -527,6 +716,12 @@ pub fn ChatInput(
|
||||||
<span class="text-gray-400">"/"</span>
|
<span class="text-gray-400">"/"</span>
|
||||||
<span class="text-blue-400">"w"</span>
|
<span class="text-blue-400">"w"</span>
|
||||||
<span class="text-gray-500">"[hisper] name"</span>
|
<span class="text-gray-500">"[hisper] name"</span>
|
||||||
|
<Show when=move || allow_user_teleport.get()>
|
||||||
|
<span class="text-gray-600 mx-2">"|"</span>
|
||||||
|
<span class="text-gray-400">"/"</span>
|
||||||
|
<span class="text-blue-400">"t"</span>
|
||||||
|
<span class="text-gray-500">"[eleport]"</span>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
|
@ -543,6 +738,17 @@ pub fn ChatInput(
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
// Scene list popup for teleport
|
||||||
|
<Show when=move || command_mode.get() == CommandMode::ShowingSceneList>
|
||||||
|
<SceneListPopup
|
||||||
|
scenes=scenes_signal
|
||||||
|
on_select=on_scene_select
|
||||||
|
on_close=on_scene_popup_close
|
||||||
|
scene_filter=scene_filter_signal
|
||||||
|
selected_idx=Signal::derive(move || scene_selected_index.get())
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<div class="flex items-center bg-gray-900/80 backdrop-blur-sm rounded-lg p-2 border border-gray-600">
|
<div class="flex items-center bg-gray-900/80 backdrop-blur-sm rounded-lg p-2 border border-gray-600">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
|
||||||
127
crates/chattyness-user-ui/src/components/scene_list_popup.rs
Normal file
127
crates/chattyness-user-ui/src/components/scene_list_popup.rs
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
//! 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<Vec<SceneSummary>>,
|
||||||
|
on_select: Callback<SceneSummary>,
|
||||||
|
#[prop(into)] on_close: Callback<()>,
|
||||||
|
#[prop(into)] scene_filter: Signal<String>,
|
||||||
|
#[prop(into)] selected_idx: Signal<usize>,
|
||||||
|
) -> 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::<Vec<_>>()
|
||||||
|
};
|
||||||
|
|
||||||
|
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::<Vec<_>>()
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div
|
||||||
|
class="absolute bottom-full left-0 mb-2 w-full max-w-lg bg-gray-800/95 backdrop-blur-sm rounded-lg shadow-2xl border border-gray-700 p-3 z-50"
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Available scenes"
|
||||||
|
>
|
||||||
|
<div class="flex justify-between items-center text-xs mb-2 px-1">
|
||||||
|
<span class="text-gray-400">"Select a scene to teleport to:"</span>
|
||||||
|
<span class="text-blue-400 italic">{filter_display}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1 max-h-64 overflow-y-auto">
|
||||||
|
{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! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class=move || {
|
||||||
|
if is_selected() {
|
||||||
|
"flex flex-col gap-0.5 p-2 rounded bg-blue-600 text-left w-full"
|
||||||
|
} else {
|
||||||
|
"flex flex-col gap-0.5 p-2 rounded hover:bg-gray-700 transition-colors text-left w-full"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
on:click=move |_| on_select.run(scene_for_click.clone())
|
||||||
|
role="option"
|
||||||
|
aria-selected=is_selected
|
||||||
|
>
|
||||||
|
<span class="text-white text-sm font-medium">
|
||||||
|
{scene_name}
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-400 text-xs">
|
||||||
|
"/teleport "{scene_slug}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect_view()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<Show when=move || filtered_scenes().is_empty()>
|
||||||
|
<div class="text-gray-500 text-sm text-center py-4">
|
||||||
|
{move || {
|
||||||
|
if scene_filter.get().is_empty() {
|
||||||
|
"No scenes available"
|
||||||
|
} else {
|
||||||
|
"No matching scenes"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<div class="mt-2 pt-2 border-t border-gray-700 text-xs text-gray-500">
|
||||||
|
<kbd class="px-1 py-0.5 bg-gray-700 rounded font-mono">"^_"</kbd>
|
||||||
|
" navigate "
|
||||||
|
<kbd class="px-1 py-0.5 bg-gray-700 rounded font-mono">"Enter"</kbd>
|
||||||
|
" select "
|
||||||
|
<kbd class="px-1 py-0.5 bg-gray-700 rounded font-mono">"Esc"</kbd>
|
||||||
|
" cancel"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -73,6 +73,15 @@ pub struct WsError {
|
||||||
pub message: String,
|
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.
|
/// Hook to manage WebSocket connection for a channel.
|
||||||
///
|
///
|
||||||
/// Returns a tuple of:
|
/// Returns a tuple of:
|
||||||
|
|
@ -91,6 +100,7 @@ pub fn use_channel_websocket(
|
||||||
on_member_fading: Callback<FadingMember>,
|
on_member_fading: Callback<FadingMember>,
|
||||||
on_welcome: Option<Callback<ChannelMemberInfo>>,
|
on_welcome: Option<Callback<ChannelMemberInfo>>,
|
||||||
on_error: Option<Callback<WsError>>,
|
on_error: Option<Callback<WsError>>,
|
||||||
|
on_teleport_approved: Option<Callback<TeleportInfo>>,
|
||||||
) -> (Signal<WsState>, WsSenderStorage) {
|
) -> (Signal<WsState>, WsSenderStorage) {
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
@ -190,6 +200,7 @@ pub fn use_channel_websocket(
|
||||||
let on_member_fading_clone = on_member_fading.clone();
|
let on_member_fading_clone = on_member_fading.clone();
|
||||||
let on_welcome_clone = on_welcome.clone();
|
let on_welcome_clone = on_welcome.clone();
|
||||||
let on_error_clone = on_error.clone();
|
let on_error_clone = on_error.clone();
|
||||||
|
let on_teleport_approved_clone = on_teleport_approved.clone();
|
||||||
// For starting heartbeat on Welcome
|
// For starting heartbeat on Welcome
|
||||||
let ws_ref_for_heartbeat = ws_ref.clone();
|
let ws_ref_for_heartbeat = ws_ref.clone();
|
||||||
let heartbeat_started: Rc<RefCell<bool>> = Rc::new(RefCell::new(false));
|
let heartbeat_started: Rc<RefCell<bool>> = Rc::new(RefCell::new(false));
|
||||||
|
|
@ -257,6 +268,7 @@ pub fn use_channel_websocket(
|
||||||
&on_prop_picked_up_clone,
|
&on_prop_picked_up_clone,
|
||||||
&on_member_fading_clone,
|
&on_member_fading_clone,
|
||||||
&on_error_clone,
|
&on_error_clone,
|
||||||
|
&on_teleport_approved_clone,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -304,6 +316,7 @@ fn handle_server_message(
|
||||||
on_prop_picked_up: &Callback<uuid::Uuid>,
|
on_prop_picked_up: &Callback<uuid::Uuid>,
|
||||||
on_member_fading: &Callback<FadingMember>,
|
on_member_fading: &Callback<FadingMember>,
|
||||||
on_error: &Option<Callback<WsError>>,
|
on_error: &Option<Callback<WsError>>,
|
||||||
|
on_teleport_approved: &Option<Callback<TeleportInfo>>,
|
||||||
) {
|
) {
|
||||||
let mut members_vec = members.borrow_mut();
|
let mut members_vec = members.borrow_mut();
|
||||||
|
|
||||||
|
|
@ -456,6 +469,17 @@ fn handle_server_message(
|
||||||
}
|
}
|
||||||
on_update.run(members_vec.clone());
|
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<FadingMember>,
|
_on_member_fading: Callback<FadingMember>,
|
||||||
_on_welcome: Option<Callback<ChannelMemberInfo>>,
|
_on_welcome: Option<Callback<ChannelMemberInfo>>,
|
||||||
_on_error: Option<Callback<WsError>>,
|
_on_error: Option<Callback<WsError>>,
|
||||||
|
_on_teleport_approved: Option<Callback<TeleportInfo>>,
|
||||||
) -> (Signal<WsState>, WsSenderStorage) {
|
) -> (Signal<WsState>, WsSenderStorage) {
|
||||||
let (ws_state, _) = signal(WsState::Disconnected);
|
let (ws_state, _) = signal(WsState::Disconnected);
|
||||||
let sender: WsSenderStorage = StoredValue::new_local(None);
|
let sender: WsSenderStorage = StoredValue::new_local(None);
|
||||||
|
|
|
||||||
|
|
@ -20,14 +20,14 @@ use crate::components::{
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
use crate::components::{
|
use crate::components::{
|
||||||
ChannelMemberInfo, ChatMessage, DEFAULT_BUBBLE_TIMEOUT_MS, FADE_DURATION_MS, HistoryEntry,
|
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;
|
use crate::utils::LocalStoragePersist;
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
use crate::utils::parse_bounds_dimensions;
|
use crate::utils::parse_bounds_dimensions;
|
||||||
use chattyness_db::models::{
|
use chattyness_db::models::{
|
||||||
AvatarWithPaths, ChannelMemberWithAvatar, EmotionAvailability, LooseProp, RealmRole,
|
AvatarWithPaths, ChannelMemberWithAvatar, EmotionAvailability, LooseProp, RealmRole,
|
||||||
RealmWithUserRole, Scene,
|
RealmWithUserRole, Scene, SceneSummary,
|
||||||
};
|
};
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
use chattyness_db::ws_messages::ClientMessage;
|
use chattyness_db::ws_messages::ClientMessage;
|
||||||
|
|
@ -123,6 +123,15 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
// Reconnection trigger - increment to force WebSocket reconnection
|
// Reconnection trigger - increment to force WebSocket reconnection
|
||||||
let (reconnect_trigger, set_reconnect_trigger) = signal(0u32);
|
let (reconnect_trigger, set_reconnect_trigger) = signal(0u32);
|
||||||
|
|
||||||
|
// Current scene (changes when teleporting)
|
||||||
|
let (current_scene, set_current_scene) = signal(Option::<Scene>::None);
|
||||||
|
|
||||||
|
// Available scenes for teleportation (cached on load)
|
||||||
|
let (available_scenes, set_available_scenes) = signal(Vec::<SceneSummary>::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 realm_data = LocalResource::new(move || {
|
||||||
let slug = slug.get();
|
let slug = slug.get();
|
||||||
async move {
|
async move {
|
||||||
|
|
@ -324,6 +333,8 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
// Display user-friendly error message
|
// Display user-friendly error message
|
||||||
let msg = match error.code.as_str() {
|
let msg = match error.code.as_str() {
|
||||||
"WHISPER_TARGET_NOT_FOUND" => error.message,
|
"WHISPER_TARGET_NOT_FOUND" => error.message,
|
||||||
|
"TELEPORT_DISABLED" => error.message,
|
||||||
|
"SCENE_NOT_FOUND" => error.message,
|
||||||
_ => format!("Error: {}", error.message),
|
_ => format!("Error: {}", error.message),
|
||||||
};
|
};
|
||||||
set_error_message.set(Some(msg));
|
set_error_message.set(Some(msg));
|
||||||
|
|
@ -335,6 +346,47 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
.forget();
|
.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::<Scene>().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")]
|
#[cfg(feature = "hydrate")]
|
||||||
let (ws_state, ws_sender) = use_channel_websocket(
|
let (ws_state, ws_sender) = use_channel_websocket(
|
||||||
slug,
|
slug,
|
||||||
|
|
@ -348,9 +400,10 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
on_member_fading,
|
on_member_fading,
|
||||||
Some(on_welcome),
|
Some(on_welcome),
|
||||||
Some(on_ws_error),
|
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
|
// Note: Currently using scene.id as the channel_id since channel_members
|
||||||
// uses scenes directly. Proper channel infrastructure can be added later.
|
// uses scenes directly. Proper channel infrastructure can be added later.
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
|
|
@ -360,6 +413,7 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
set_channel_id.set(Some(scene.id));
|
set_channel_id.set(Some(scene.id));
|
||||||
|
set_current_scene.set(Some(scene.clone()));
|
||||||
|
|
||||||
// Extract scene dimensions from bounds_wkt
|
// Extract scene dimensions from bounds_wkt
|
||||||
if let Some((w, h)) = parse_bounds_dimensions(&scene.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::<Vec<SceneSummary>>().await {
|
||||||
|
// Filter out hidden scenes
|
||||||
|
let visible_scenes: Vec<SceneSummary> = scenes
|
||||||
|
.into_iter()
|
||||||
|
.filter(|s| !s.is_hidden)
|
||||||
|
.collect();
|
||||||
|
set_available_scenes.set(visible_scenes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Cleanup expired speech bubbles and fading members every second
|
// Cleanup expired speech bubbles and fading members every second
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
{
|
{
|
||||||
|
|
@ -729,11 +821,16 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
let realm_slug_for_viewer = realm_slug_val.clone();
|
let realm_slug_for_viewer = realm_slug_val.clone();
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
let ws_sender_clone = ws_sender.clone();
|
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
|
entry_scene
|
||||||
.get()
|
.get()
|
||||||
.map(|maybe_scene| {
|
.map(|maybe_scene| {
|
||||||
match 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 members_signal = Signal::derive(move || members.get());
|
||||||
let emotion_avail_signal = Signal::derive(move || emotion_availability.get());
|
let emotion_avail_signal = Signal::derive(move || emotion_availability.get());
|
||||||
let skin_path_signal = Signal::derive(move || skin_preview_path.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| {
|
let on_whisper_request_cb = Callback::new(move |target: String| {
|
||||||
set_whisper_target.set(Some(target));
|
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! {
|
view! {
|
||||||
<div class="relative w-full">
|
<div class="relative w-full">
|
||||||
<RealmSceneViewer
|
<RealmSceneViewer
|
||||||
scene=scene
|
scene=display_scene
|
||||||
realm_slug=realm_slug_for_viewer.clone()
|
realm_slug=realm_slug_for_viewer.clone()
|
||||||
members=members_signal
|
members=members_signal
|
||||||
active_bubbles=active_bubbles_signal
|
active_bubbles=active_bubbles_signal
|
||||||
|
|
@ -789,6 +898,9 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
on_open_settings=on_open_settings_cb
|
on_open_settings=on_open_settings_cb
|
||||||
on_open_inventory=on_open_inventory_cb
|
on_open_inventory=on_open_inventory_cb
|
||||||
whisper_target=whisper_target_signal
|
whisper_target=whisper_target_signal
|
||||||
|
scenes=scenes_signal
|
||||||
|
allow_user_teleport=teleport_enabled_signal
|
||||||
|
on_teleport=on_teleport_cb
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ CREATE TABLE realm.realms (
|
||||||
|
|
||||||
max_users INTEGER NOT NULL DEFAULT 100 CHECK (max_users > 0 AND max_users <= 10000),
|
max_users INTEGER NOT NULL DEFAULT 100 CHECK (max_users > 0 AND max_users <= 10000),
|
||||||
allow_guest_access BOOLEAN NOT NULL DEFAULT true,
|
allow_guest_access BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
allow_user_teleport BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
default_scene_id UUID,
|
default_scene_id UUID,
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue