feat: add teleport
This commit is contained in:
parent
226c2e02b5
commit
32e5e42462
11 changed files with 603 additions and 16 deletions
|
|
@ -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<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.
|
||||
///
|
||||
/// 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<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 {
|
||||
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::<leptos::html::Input>::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::<Vec<_>>()
|
||||
};
|
||||
|
||||
// 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! {
|
||||
<div class="chat-input-container w-full max-w-4xl mx-auto pointer-events-auto relative">
|
||||
|
|
@ -513,7 +702,7 @@ pub fn ChatInput(
|
|||
</div>
|
||||
</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>
|
||||
<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>
|
||||
|
|
@ -527,6 +716,12 @@ pub fn ChatInput(
|
|||
<span class="text-gray-400">"/"</span>
|
||||
<span class="text-blue-400">"w"</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>
|
||||
</Show>
|
||||
|
||||
|
|
@ -543,6 +738,17 @@ pub fn ChatInput(
|
|||
/>
|
||||
</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">
|
||||
<input
|
||||
type="text"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue