899 lines
37 KiB
Rust
899 lines
37 KiB
Rust
//! Chat components for realm chat interface.
|
|
|
|
use leptos::prelude::*;
|
|
use uuid::Uuid;
|
|
|
|
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.
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
enum CommandMode {
|
|
/// Normal chat mode, no command active.
|
|
None,
|
|
/// Showing command hint for colon commands (`:e[mote], :l[ist]`).
|
|
ShowingColonHint,
|
|
/// Showing command hint for slash commands (`/setting`, `/mod` for mods).
|
|
ShowingSlashHint,
|
|
/// Showing mod command hints only (`/mod summon [nick|*]`).
|
|
ShowingModHint,
|
|
/// Showing emotion list popup.
|
|
ShowingList,
|
|
/// Showing scene list popup for teleport.
|
|
ShowingSceneList,
|
|
}
|
|
|
|
/// Parse an emote command and return the emotion name if valid.
|
|
///
|
|
/// Supports `:e name`, `:emote name` with partial matching.
|
|
fn parse_emote_command(cmd: &str) -> Option<String> {
|
|
let cmd = cmd.trim().to_lowercase();
|
|
|
|
// Strip the leading colon if present
|
|
let cmd = cmd.strip_prefix(':').unwrap_or(&cmd);
|
|
|
|
// Check for `:e <name>` or `:emote <name>`
|
|
let name = cmd
|
|
.strip_prefix("emote ")
|
|
.or_else(|| cmd.strip_prefix("e "))
|
|
.map(str::trim);
|
|
|
|
name.and_then(|n| {
|
|
EMOTIONS
|
|
.iter()
|
|
.find(|ename| ename.starts_with(n) || n.starts_with(**ename))
|
|
.map(|ename| (*ename).to_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`.
|
|
#[cfg(feature = "hydrate")]
|
|
fn parse_whisper_command(cmd: &str) -> Option<(String, String)> {
|
|
let cmd = cmd.trim();
|
|
|
|
// Strip the leading slash if present
|
|
let cmd = cmd.strip_prefix('/').unwrap_or(cmd);
|
|
|
|
// Check for `w <name> <message>` or `whisper <name> <message>`
|
|
let rest = cmd
|
|
.strip_prefix("whisper ")
|
|
.or_else(|| cmd.strip_prefix("w "))
|
|
.map(str::trim)?;
|
|
|
|
// Find the first space to separate name from message
|
|
let space_idx = rest.find(' ')?;
|
|
let name = rest[..space_idx].trim().to_string();
|
|
let message = rest[space_idx + 1..].trim().to_string();
|
|
|
|
if name.is_empty() || message.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
Some((name, message))
|
|
}
|
|
|
|
/// Parse a mod command and return (subcommand, args) if valid.
|
|
///
|
|
/// Supports `/mod summon [nick|*]` etc.
|
|
#[cfg(feature = "hydrate")]
|
|
fn parse_mod_command(cmd: &str) -> Option<(String, Vec<String>)> {
|
|
let cmd = cmd.trim();
|
|
|
|
// Strip the leading slash if present
|
|
let cmd = cmd.strip_prefix('/').unwrap_or(cmd);
|
|
|
|
// Check for `mod <subcommand> [args...]`
|
|
let rest = cmd.strip_prefix("mod ").map(str::trim)?;
|
|
|
|
if rest.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
// Split into parts
|
|
let parts: Vec<&str> = rest.split_whitespace().collect();
|
|
if parts.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let subcommand = parts[0].to_lowercase();
|
|
let args: Vec<String> = parts[1..].iter().map(|s| s.to_string()).collect();
|
|
|
|
Some((subcommand, args))
|
|
}
|
|
|
|
/// Chat input component with emote command support.
|
|
///
|
|
/// Props:
|
|
/// - `ws_sender`: WebSocket sender for emotion updates
|
|
/// - `emotion_availability`: Which emotions are available for the user's avatar
|
|
/// - `skin_preview_path`: Path to the user's skin layer center asset (for previews)
|
|
/// - `focus_trigger`: Signal that triggers focus when set to true (prefix char in value)
|
|
/// - `focus_prefix`: The prefix character that triggered focus (':' or '/')
|
|
/// - `on_focus_change`: Callback when focus state changes
|
|
/// - `on_open_settings`: Callback to open settings popup
|
|
/// - `on_open_inventory`: Callback to open inventory popup
|
|
/// - `on_open_log`: Callback to open message log 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,
|
|
emotion_availability: Signal<Option<EmotionAvailability>>,
|
|
skin_preview_path: Signal<Option<String>>,
|
|
focus_trigger: Signal<bool>,
|
|
#[prop(default = Signal::derive(|| ':'))] focus_prefix: Signal<char>,
|
|
on_focus_change: Callback<bool>,
|
|
#[prop(optional)] on_open_settings: Option<Callback<()>>,
|
|
#[prop(optional)] on_open_inventory: Option<Callback<()>>,
|
|
/// Callback to open message log popup.
|
|
#[prop(optional)]
|
|
on_open_log: Option<Callback<()>>,
|
|
/// Signal containing the display name to whisper to. When set, pre-fills the input.
|
|
/// Uses RwSignal so the component can clear it after consuming.
|
|
#[prop(optional)]
|
|
whisper_target: Option<RwSignal<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>>,
|
|
/// Whether the current user is a moderator.
|
|
#[prop(default = Signal::derive(|| false))]
|
|
is_moderator: Signal<bool>,
|
|
/// Callback to send a mod command.
|
|
#[prop(optional)]
|
|
on_mod_command: Option<Callback<(String, Vec<String>)>>,
|
|
) -> 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
|
|
let filtered_emotions = move || {
|
|
let filter_text = list_filter.get().to_lowercase();
|
|
emotion_availability
|
|
.get()
|
|
.map(|avail| {
|
|
EMOTIONS
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(idx, _)| avail.available.get(*idx).copied().unwrap_or(false))
|
|
.filter(|(_, name)| filter_text.is_empty() || name.starts_with(&filter_text))
|
|
.map(|(_, name)| (*name).to_string())
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.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")]
|
|
{
|
|
Effect::new(move |_| {
|
|
if focus_trigger.get() {
|
|
if let Some(input) = input_ref.get() {
|
|
let _ = input.focus();
|
|
let prefix = focus_prefix.get();
|
|
|
|
// Space means just focus, no prefix
|
|
if prefix == ' ' {
|
|
return;
|
|
}
|
|
|
|
let prefix_str = prefix.to_string();
|
|
set_message.set(prefix_str.clone());
|
|
// Show appropriate hint based on prefix
|
|
set_command_mode.set(if prefix == '/' {
|
|
CommandMode::ShowingSlashHint
|
|
} else {
|
|
CommandMode::ShowingColonHint
|
|
});
|
|
// Update the input value directly
|
|
input.set_value(&prefix_str);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Handle whisper target pre-fill
|
|
#[cfg(feature = "hydrate")]
|
|
{
|
|
Effect::new(move |_| {
|
|
let Some(whisper_signal) = whisper_target else {
|
|
return;
|
|
};
|
|
|
|
let Some(target_name) = whisper_signal.get() else {
|
|
return;
|
|
};
|
|
|
|
// Pre-fill with /whisper command prefix only (no placeholder text)
|
|
// User types their message after the space
|
|
// parse_whisper_command already rejects empty messages
|
|
let whisper_prefix = format!("/whisper {} ", target_name);
|
|
|
|
if let Some(input) = input_ref.get() {
|
|
// Set the message
|
|
set_message.set(whisper_prefix.clone());
|
|
// Don't show hint - user already knows they're whispering
|
|
set_command_mode.set(CommandMode::None);
|
|
|
|
// Update input value
|
|
input.set_value(&whisper_prefix);
|
|
|
|
// Focus the input and position cursor at end
|
|
let _ = input.focus();
|
|
let len = whisper_prefix.len() as u32;
|
|
let _ = input.set_selection_range(len, len);
|
|
|
|
// Clear the whisper target so it doesn't re-trigger on re-render
|
|
whisper_signal.set(None);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Apply emotion via WebSocket
|
|
let apply_emotion = {
|
|
move |emotion: String| {
|
|
ws_sender.with_value(|sender| {
|
|
if let Some(send_fn) = sender {
|
|
send_fn(ClientMessage::UpdateEmotion { emotion });
|
|
}
|
|
});
|
|
// Clear input and close popup
|
|
set_message.set(String::new());
|
|
set_command_mode.set(CommandMode::None);
|
|
}
|
|
};
|
|
|
|
// Handle input changes to detect commands
|
|
let on_input = {
|
|
move |ev| {
|
|
let value = event_target_value(&ev);
|
|
set_message.set(value.clone());
|
|
|
|
// 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();
|
|
|
|
// Show hint for colon commands
|
|
if cmd.is_empty()
|
|
|| "list".starts_with(&cmd)
|
|
|| "emote".starts_with(&cmd)
|
|
|| cmd.starts_with("e ")
|
|
|| cmd.starts_with("emote ")
|
|
{
|
|
set_command_mode.set(CommandMode::ShowingColonHint);
|
|
} else {
|
|
set_command_mode.set(CommandMode::None);
|
|
}
|
|
} else if value.starts_with('/') {
|
|
let cmd = value[1..].to_lowercase();
|
|
|
|
// Show hint for slash commands (don't execute until Enter)
|
|
// Match: /s[etting], /i[nventory], /w[hisper], /t[eleport]
|
|
// But NOT when whisper command is complete (has name + space for message)
|
|
let is_complete_whisper = {
|
|
// Check if it's "/w name " or "/whisper name " (name followed by space)
|
|
let rest = cmd.strip_prefix("whisper ").or_else(|| cmd.strip_prefix("w "));
|
|
if let Some(after_cmd) = rest {
|
|
// If there's content after the command and it contains a space,
|
|
// user has typed "name " and is now typing the message
|
|
after_cmd.contains(' ')
|
|
} else {
|
|
false
|
|
}
|
|
};
|
|
|
|
// Check if teleport command is complete (has slug)
|
|
let is_complete_teleport = {
|
|
let rest = cmd.strip_prefix("teleport ").or_else(|| cmd.strip_prefix("t "));
|
|
if let Some(after_cmd) = rest {
|
|
!after_cmd.is_empty()
|
|
} else {
|
|
false
|
|
}
|
|
};
|
|
|
|
// Check if typing mod command (only for moderators)
|
|
// Show mod hint when typing "/mod" or "/mod ..."
|
|
let is_typing_mod = is_moderator.get_untracked()
|
|
&& (cmd == "mod" || cmd.starts_with("mod "));
|
|
// Show /mod in slash hints when just starting to type it
|
|
let is_partial_mod = is_moderator.get_untracked()
|
|
&& !cmd.is_empty()
|
|
&& "mod".starts_with(&cmd)
|
|
&& cmd != "mod";
|
|
|
|
if is_complete_whisper || is_complete_teleport {
|
|
// User is typing the argument part, no hint needed
|
|
set_command_mode.set(CommandMode::None);
|
|
} else if is_typing_mod {
|
|
// Show mod-specific hint bar
|
|
set_command_mode.set(CommandMode::ShowingModHint);
|
|
} else if cmd.is_empty()
|
|
|| "setting".starts_with(&cmd)
|
|
|| "inventory".starts_with(&cmd)
|
|
|| "whisper".starts_with(&cmd)
|
|
|| "teleport".starts_with(&cmd)
|
|
|| "log".starts_with(&cmd)
|
|
|| cmd == "setting"
|
|
|| cmd == "settings"
|
|
|| cmd == "inventory"
|
|
|| cmd == "log"
|
|
|| cmd.starts_with("w ")
|
|
|| cmd.starts_with("whisper ")
|
|
|| cmd.starts_with("t ")
|
|
|| cmd.starts_with("teleport ")
|
|
|| is_partial_mod
|
|
{
|
|
set_command_mode.set(CommandMode::ShowingSlashHint);
|
|
} else {
|
|
set_command_mode.set(CommandMode::None);
|
|
}
|
|
} else {
|
|
set_command_mode.set(CommandMode::None);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Handle key presses (Tab for autocomplete, Enter to execute, Escape to close and blur)
|
|
#[cfg(feature = "hydrate")]
|
|
let on_keydown = {
|
|
let apply_emotion = apply_emotion.clone();
|
|
let on_open_settings = on_open_settings.clone();
|
|
let on_open_inventory = on_open_inventory.clone();
|
|
let on_open_log = on_open_log.clone();
|
|
move |ev: web_sys::KeyboardEvent| {
|
|
let key = ev.key();
|
|
let current_mode = command_mode.get_untracked();
|
|
|
|
if key == "Escape" {
|
|
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() {
|
|
let _ = input.blur();
|
|
}
|
|
ev.prevent_default();
|
|
return;
|
|
}
|
|
|
|
// Arrow key navigation when list is showing
|
|
if current_mode == CommandMode::ShowingList {
|
|
let emotions = filtered_emotions();
|
|
let count = emotions.len();
|
|
|
|
if key == "ArrowDown" && count > 0 {
|
|
set_selected_index.update(|idx| {
|
|
*idx = (*idx + 1) % count;
|
|
});
|
|
ev.prevent_default();
|
|
return;
|
|
}
|
|
|
|
if key == "ArrowUp" && count > 0 {
|
|
set_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 emotion
|
|
let idx = selected_index.get_untracked();
|
|
if let Some(emotion) = emotions.get(idx) {
|
|
set_list_filter.set(String::new());
|
|
set_selected_index.set(0);
|
|
apply_emotion(emotion.clone());
|
|
}
|
|
ev.prevent_default();
|
|
return;
|
|
}
|
|
|
|
// Any other key in list mode is handled by on_input
|
|
if key == "Enter" {
|
|
ev.prevent_default();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
if msg.starts_with('/') {
|
|
let cmd = msg[1..].to_lowercase();
|
|
// Autocomplete to /setting if /s, /se, /set, etc.
|
|
if !cmd.is_empty() && "setting".starts_with(&cmd) && cmd != "setting" {
|
|
set_message.set("/setting".to_string());
|
|
if let Some(input) = input_ref.get() {
|
|
input.set_value("/setting");
|
|
}
|
|
ev.prevent_default();
|
|
return;
|
|
}
|
|
// Autocomplete to /inventory if /i, /in, /inv, etc.
|
|
if !cmd.is_empty() && "inventory".starts_with(&cmd) && cmd != "inventory" {
|
|
set_message.set("/inventory".to_string());
|
|
if let Some(input) = input_ref.get() {
|
|
input.set_value("/inventory");
|
|
}
|
|
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;
|
|
}
|
|
// Autocomplete to /log if /l, /lo (but not if it could be /list or /teleport)
|
|
// Only match /l if it's exactly /l (not /li which would match /list)
|
|
if !cmd.is_empty()
|
|
&& "log".starts_with(&cmd)
|
|
&& cmd != "log"
|
|
&& !cmd.starts_with("li")
|
|
{
|
|
set_message.set("/log".to_string());
|
|
if let Some(input) = input_ref.get() {
|
|
input.set_value("/log");
|
|
}
|
|
ev.prevent_default();
|
|
return;
|
|
}
|
|
}
|
|
// Always prevent Tab from moving focus when in input
|
|
ev.prevent_default();
|
|
return;
|
|
}
|
|
|
|
if key == "Enter" {
|
|
let msg = message.get();
|
|
|
|
// Handle slash commands
|
|
if msg.starts_with('/') {
|
|
let cmd = msg[1..].to_lowercase();
|
|
|
|
// /s, /se, /set, /sett, /setti, /settin, /setting, /settings
|
|
if !cmd.is_empty() && ("setting".starts_with(&cmd) || cmd == "settings") {
|
|
if let Some(ref callback) = on_open_settings {
|
|
callback.run(());
|
|
}
|
|
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;
|
|
}
|
|
|
|
// /i, /in, /inv, /inve, /inven, /invent, /invento, /inventor, /inventory
|
|
if !cmd.is_empty() && "inventory".starts_with(&cmd) {
|
|
if let Some(ref callback) = on_open_inventory {
|
|
callback.run(());
|
|
}
|
|
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;
|
|
}
|
|
|
|
// /l, /lo, /log - open message log
|
|
if !cmd.is_empty() && "log".starts_with(&cmd) {
|
|
if let Some(ref callback) = on_open_log {
|
|
callback.run(());
|
|
}
|
|
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;
|
|
}
|
|
|
|
// /w NAME message or /whisper NAME message
|
|
if let Some((target_name, whisper_content)) = parse_whisper_command(&msg) {
|
|
if !whisper_content.trim().is_empty() {
|
|
ws_sender.with_value(|sender| {
|
|
if let Some(send_fn) = sender {
|
|
send_fn(ClientMessage::SendChatMessage {
|
|
content: whisper_content.trim().to_string(),
|
|
target_display_name: Some(target_name),
|
|
});
|
|
}
|
|
});
|
|
set_message.set(String::new());
|
|
set_command_mode.set(CommandMode::None);
|
|
if let Some(input) = input_ref.get() {
|
|
input.set_value("");
|
|
}
|
|
}
|
|
ev.prevent_default();
|
|
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;
|
|
}
|
|
|
|
// /mod <subcommand> [args...] - execute mod command
|
|
if is_moderator.get_untracked() {
|
|
if let Some((subcommand, args)) = parse_mod_command(&msg) {
|
|
if let Some(ref callback) = on_mod_command {
|
|
callback.run((subcommand, args));
|
|
}
|
|
set_message.set(String::new());
|
|
set_command_mode.set(CommandMode::None);
|
|
if let Some(input) = input_ref.get() {
|
|
input.set_value("");
|
|
}
|
|
ev.prevent_default();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Invalid slash command - just ignore, don't send
|
|
ev.prevent_default();
|
|
return;
|
|
}
|
|
|
|
// Handle colon commands - NEVER send as message
|
|
if msg.starts_with(':') {
|
|
let cmd = msg[1..].to_lowercase();
|
|
|
|
// :l, :li, :lis, :list - open the emotion list
|
|
if !cmd.is_empty() && "list".starts_with(&cmd) {
|
|
set_command_mode.set(CommandMode::ShowingList);
|
|
set_list_filter.set(String::new());
|
|
set_message.set(String::new());
|
|
if let Some(input) = input_ref.get() {
|
|
input.set_value("");
|
|
}
|
|
ev.prevent_default();
|
|
return;
|
|
}
|
|
|
|
// :e <name> or :emote <name> - apply emotion if valid
|
|
if let Some(emotion) = parse_emote_command(&msg) {
|
|
apply_emotion(emotion);
|
|
}
|
|
// Invalid colon command - just ignore, don't send
|
|
ev.prevent_default();
|
|
return;
|
|
}
|
|
|
|
// Send regular chat message (only if not a command)
|
|
if !msg.trim().is_empty() {
|
|
ws_sender.with_value(|sender| {
|
|
if let Some(send_fn) = sender {
|
|
send_fn(ClientMessage::SendChatMessage {
|
|
content: msg.trim().to_string(),
|
|
target_display_name: None, // Broadcast to scene
|
|
});
|
|
}
|
|
});
|
|
set_message.set(String::new());
|
|
if let Some(input) = input_ref.get() {
|
|
input.set_value("");
|
|
}
|
|
ev.prevent_default();
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
#[cfg(not(feature = "hydrate"))]
|
|
let on_keydown = move |_ev| {};
|
|
|
|
// Focus/blur handlers
|
|
let on_focus = {
|
|
let on_focus_change = on_focus_change.clone();
|
|
move |_ev| {
|
|
on_focus_change.run(true);
|
|
}
|
|
};
|
|
|
|
let on_blur = {
|
|
move |_ev| {
|
|
on_focus_change.run(false);
|
|
// Note: We don't close the popup on blur to allow click events on popup items to fire
|
|
// The popup is closed when an item is selected or Escape is pressed
|
|
}
|
|
};
|
|
|
|
// Popup select handler for emotions
|
|
let on_popup_select = Callback::new(move |emotion: String| {
|
|
set_list_filter.set(String::new());
|
|
apply_emotion(emotion);
|
|
});
|
|
|
|
let on_popup_close = Callback::new(move |_: ()| {
|
|
set_list_filter.set(String::new());
|
|
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">
|
|
// Colon command hint bar (:e[mote], :l[ist])
|
|
<Show when=move || command_mode.get() == CommandMode::ShowingColonHint>
|
|
<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-blue-400">"e"</span>
|
|
<span class="text-gray-500">"[mote] name"</span>
|
|
<span class="text-gray-600 mx-2">"|"</span>
|
|
<span class="text-gray-400">":"</span>
|
|
<span class="text-blue-400">"l"</span>
|
|
<span class="text-gray-500">"[ist]"</span>
|
|
</div>
|
|
</Show>
|
|
|
|
// Slash command hint bar (/s[etting], /i[nventory], /w[hisper], /l[og], /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>
|
|
<span class="text-blue-400">"s"</span>
|
|
<span class="text-gray-500">"[etting]"</span>
|
|
<span class="text-gray-600 mx-2">"|"</span>
|
|
<span class="text-gray-400">"/"</span>
|
|
<span class="text-blue-400">"i"</span>
|
|
<span class="text-gray-500">"[nventory]"</span>
|
|
<span class="text-gray-600 mx-2">"|"</span>
|
|
<span class="text-gray-400">"/"</span>
|
|
<span class="text-blue-400">"w"</span>
|
|
<span class="text-gray-500">"[hisper] name"</span>
|
|
<span class="text-gray-600 mx-2">"|"</span>
|
|
<span class="text-gray-400">"/"</span>
|
|
<span class="text-blue-400">"l"</span>
|
|
<span class="text-gray-500">"[og]"</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>
|
|
// Show /mod hint for moderators (details shown when typing /mod)
|
|
<Show when=move || is_moderator.get()>
|
|
<span class="text-gray-600 mx-2">"|"</span>
|
|
<span class="text-purple-400">"/"</span>
|
|
<span class="text-purple-400">"mod"</span>
|
|
</Show>
|
|
</div>
|
|
</Show>
|
|
|
|
// Mod command hint bar (shown when typing /mod)
|
|
<Show when=move || command_mode.get() == CommandMode::ShowingModHint>
|
|
<div class="absolute bottom-full left-0 mb-1 px-3 py-1 bg-purple-900/90 backdrop-blur-sm rounded text-sm">
|
|
<span class="text-purple-400">"/"</span>
|
|
<span class="text-purple-400">"mod"</span>
|
|
<span class="text-purple-300">" summon"</span>
|
|
<span class="text-purple-500">" [nick|*]"</span>
|
|
<span class="text-purple-600">" | "</span>
|
|
<span class="text-purple-300">"teleport"</span>
|
|
<span class="text-purple-500">" [nick] [slug]"</span>
|
|
</div>
|
|
</Show>
|
|
|
|
// Emotion list popup
|
|
<Show when=move || command_mode.get() == CommandMode::ShowingList>
|
|
<EmoteListPopup
|
|
emotion_availability=emotion_availability
|
|
skin_preview_path=skin_preview_path
|
|
on_select=on_popup_select
|
|
on_close=on_popup_close
|
|
emotion_filter=filter_signal
|
|
selected_idx=Signal::derive(move || selected_index.get())
|
|
label=LabelStyle::Command
|
|
/>
|
|
</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"
|
|
placeholder="Type a message... (: or / for commands)"
|
|
class="flex-1 bg-transparent text-white placeholder-gray-500 px-3 py-2 outline-none"
|
|
prop:value=move || message.get()
|
|
on:input=on_input
|
|
on:keydown=on_keydown
|
|
on:focus=on_focus
|
|
on:blur=on_blur
|
|
node_ref=input_ref
|
|
autocomplete="off"
|
|
aria-label="Chat message input"
|
|
/>
|
|
</div>
|
|
</div>
|
|
}
|
|
}
|