//! 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 { let cmd = cmd.trim().to_lowercase(); // Strip the leading colon if present let cmd = cmd.strip_prefix(':').unwrap_or(&cmd); // Check for `:e ` or `:emote ` 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 { let cmd = cmd.trim(); // Strip the leading slash if present let cmd = cmd.strip_prefix('/').unwrap_or(cmd); // Check for `t ` or `teleport ` let slug = cmd .strip_prefix("teleport ") .or_else(|| cmd.strip_prefix("t ")) .map(str::trim)?; if slug.is_empty() { return None; } Some(slug.to_string()) } /// Parse a whisper command and return (target_name, message) if valid. /// /// Supports `/w name message` and `/whisper name message`. #[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 ` or `whisper ` 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)> { let cmd = cmd.trim(); // Strip the leading slash if present let cmd = cmd.strip_prefix('/').unwrap_or(cmd); // Check for `mod [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 = 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>, skin_preview_path: Signal>, focus_trigger: Signal, #[prop(default = Signal::derive(|| ':'))] focus_prefix: Signal, on_focus_change: Callback, #[prop(optional)] on_open_settings: Option>, #[prop(optional)] on_open_inventory: Option>, /// Callback to open message log popup. #[prop(optional)] on_open_log: Option>, /// 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>>, /// List of available scenes for teleport command. #[prop(optional, into)] scenes: Option>>, /// Whether teleporting is enabled for this realm. #[prop(default = Signal::derive(|| false))] allow_user_teleport: Signal, /// Callback when a teleport is requested. #[prop(optional)] on_teleport: Option>, /// Whether the current user is a moderator. #[prop(default = Signal::derive(|| false))] is_moderator: Signal, /// Callback to send a mod command. #[prop(optional)] on_mod_command: Option)>>, ) -> impl IntoView { let (message, set_message) = signal(String::new()); let (command_mode, set_command_mode) = signal(CommandMode::None); let (list_filter, set_list_filter) = signal(String::new()); let (selected_index, set_selected_index) = signal(0usize); // Separate filter/index for scene list let (scene_filter, set_scene_filter) = signal(String::new()); let (scene_selected_index, set_scene_selected_index) = signal(0usize); let input_ref = NodeRef::::new(); // Compute filtered emotions for keyboard navigation 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::>() }) .unwrap_or_default() }; // Compute filtered scenes for teleport navigation let filtered_scenes = move || { let filter_text = scene_filter.get().to_lowercase(); scenes .map(|s| s.get()) .unwrap_or_default() .into_iter() .filter(|s| { filter_text.is_empty() || s.name.to_lowercase().contains(&filter_text) || s.slug.to_lowercase().contains(&filter_text) }) .collect::>() }; // Handle focus trigger from parent (when space, ':' or '/' is pressed globally) #[cfg(feature = "hydrate")] { 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 [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 or :emote - 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! {
// Colon command hint bar (:e[mote], :l[ist])
":" "e" "[mote] name" "|" ":" "l" "[ist]"
// Slash command hint bar (/s[etting], /i[nventory], /w[hisper], /l[og], /t[eleport])
"/" "s" "[etting]" "|" "/" "i" "[nventory]" "|" "/" "w" "[hisper] name" "|" "/" "l" "[og]" "|" "/" "t" "[eleport]" // Show /mod hint for moderators (details shown when typing /mod) "|" "/" "mod"
// Mod command hint bar (shown when typing /mod)
"/" "mod" " summon" " [nick|*]" " | " "teleport" " [nick] [slug]"
// Emotion list popup // Scene list popup for teleport
} }