//! Chat components for realm chat interface. use leptos::prelude::*; use chattyness_db::models::EmotionAvailability; use chattyness_db::ws_messages::ClientMessage; use super::emotion_picker::{EmoteListPopup, EMOTIONS}; 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`). ShowingSlashHint, /// Showing emotion list popup. ShowingList, } /// 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()) }) } /// 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 #[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>, ) -> 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); 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() }; // 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); } } }); } // 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 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 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) if cmd.is_empty() || "setting".starts_with(&cmd) || "inventory".starts_with(&cmd) || cmd == "setting" || cmd == "settings" || cmd == "inventory" { 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(); 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_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; } } // 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; } } // Always prevent Tab from moving focus when in input ev.prevent_default(); return; } if key == "Enter" { let msg = message.get(); // Handle slash commands - NEVER send as message 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(); } } // /i, /in, /inv, /inve, /inven, /invent, /invento, /inventor, /inventory else 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(); } } // 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(), }); } }); 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 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); }); let filter_signal = Signal::derive(move || list_filter.get()); view! {
// Colon command hint bar (:e[mote], :l[ist])
":" "e" "[mote] name" "|" ":" "l" "[ist]"
// Slash command hint bar (/s[etting], /i[nventory])
"/" "s" "[etting]" "|" "/" "i" "[nventory]"
// Emotion list popup
} }