From 41ea9d13cb0f362fc8dd204624245871b599a4458da0a1b1873aa0382d199891 Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Tue, 20 Jan 2026 19:25:58 -0600 Subject: [PATCH] feat: add log and hotkey list --- crates/chattyness-user-ui/src/components.rs | 4 + .../chattyness-user-ui/src/components/chat.rs | 42 +++- .../src/components/hotkey_help.rs | 106 +++++++++ .../src/components/log_popup.rs | 206 ++++++++++++++++++ crates/chattyness-user-ui/src/pages/realm.rs | 61 +++++- 5 files changed, 415 insertions(+), 4 deletions(-) create mode 100644 crates/chattyness-user-ui/src/components/hotkey_help.rs create mode 100644 crates/chattyness-user-ui/src/components/log_popup.rs diff --git a/crates/chattyness-user-ui/src/components.rs b/crates/chattyness-user-ui/src/components.rs index 5779f00..ec189ea 100644 --- a/crates/chattyness-user-ui/src/components.rs +++ b/crates/chattyness-user-ui/src/components.rs @@ -9,10 +9,12 @@ pub mod conversation_modal; pub mod editor; pub mod emotion_picker; pub mod forms; +pub mod hotkey_help; pub mod inventory; pub mod keybindings; pub mod keybindings_popup; pub mod layout; +pub mod log_popup; pub mod modals; pub mod notification_history; pub mod notifications; @@ -33,10 +35,12 @@ pub use conversation_modal::*; pub use editor::*; pub use emotion_picker::*; pub use forms::*; +pub use hotkey_help::*; pub use inventory::*; pub use keybindings::*; pub use keybindings_popup::*; pub use layout::*; +pub use log_popup::*; pub use modals::*; pub use notification_history::*; pub use notifications::*; diff --git a/crates/chattyness-user-ui/src/components/chat.rs b/crates/chattyness-user-ui/src/components/chat.rs index 0be1a35..314eb67 100644 --- a/crates/chattyness-user-ui/src/components/chat.rs +++ b/crates/chattyness-user-ui/src/components/chat.rs @@ -109,6 +109,7 @@ fn parse_whisper_command(cmd: &str) -> Option<(String, String)> { /// - `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 @@ -123,6 +124,9 @@ pub fn ChatInput( 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. #[prop(optional, into)] whisper_target: Option>>, @@ -324,9 +328,11 @@ pub fn ChatInput( || "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 ") @@ -348,6 +354,7 @@ pub fn ChatInput( 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(); @@ -484,6 +491,20 @@ pub fn ChatInput( 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(); @@ -527,6 +548,21 @@ pub fn ChatInput( 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() { @@ -702,7 +738,7 @@ pub fn ChatInput( - // Slash command hint bar (/s[etting], /i[nventory], /w[hisper], /t[eleport]) + // Slash command hint bar (/s[etting], /i[nventory], /w[hisper], /l[og], /t[eleport])
"/" @@ -716,6 +752,10 @@ pub fn ChatInput( "/" "w" "[hisper] name" + "|" + "/" + "l" + "[og]" "|" "/" diff --git a/crates/chattyness-user-ui/src/components/hotkey_help.rs b/crates/chattyness-user-ui/src/components/hotkey_help.rs new file mode 100644 index 0000000..8f25986 --- /dev/null +++ b/crates/chattyness-user-ui/src/components/hotkey_help.rs @@ -0,0 +1,106 @@ +//! Hotkey help overlay component. +//! +//! Displays available keyboard shortcuts while held. + +use leptos::prelude::*; + +/// Hotkey help overlay that shows available keyboard shortcuts. +/// +/// This component displays while the user holds down the `?` key. +#[component] +pub fn HotkeyHelp( + /// Whether the help overlay is visible. + #[prop(into)] + visible: Signal, +) -> impl IntoView { + let outer_class = move || { + if visible.get() { + "fixed inset-0 z-50 flex items-center justify-center pointer-events-none" + } else { + "hidden" + } + }; + + view! { +
+ // Semi-transparent backdrop + + } +} + +/// A single hotkey row with key and description. +#[component] +fn HotkeyRow( + /// The key or key combination. + key: &'static str, + /// Description of what the key does. + description: &'static str, +) -> impl IntoView { + view! { +
+
+ + {key} + +
+
+ {description} +
+
+ } +} diff --git a/crates/chattyness-user-ui/src/components/log_popup.rs b/crates/chattyness-user-ui/src/components/log_popup.rs new file mode 100644 index 0000000..c4b09f1 --- /dev/null +++ b/crates/chattyness-user-ui/src/components/log_popup.rs @@ -0,0 +1,206 @@ +//! Message log popup component. +//! +//! Displays a filterable chronological log of received messages. + +use leptos::prelude::*; +use leptos::reactive::owner::LocalStorage; + +use super::chat_types::{ChatMessage, MessageLog}; +use super::modals::Modal; + +/// Filter mode for message log display. +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum LogFilter { + /// Show all messages. + #[default] + All, + /// Show only broadcast chat messages. + Chat, + /// Show only whispers. + Whispers, +} + +/// Message log popup component. +/// +/// Displays a filterable list of messages from the session. +#[component] +pub fn LogPopup( + #[prop(into)] open: Signal, + message_log: StoredValue, + on_close: Callback<()>, +) -> impl IntoView { + let (filter, set_filter) = signal(LogFilter::All); + + // Get filtered messages based on current filter + // Note: We read `open` to force re-evaluation when the modal opens, + // since StoredValue is not reactive. + let filtered_messages = move || { + // Reading open ensures we re-fetch messages when modal opens + let _ = open.get(); + let current_filter = filter.get(); + message_log.with_value(|log| { + log.all_messages() + .iter() + .filter(|msg| match current_filter { + LogFilter::All => true, + LogFilter::Chat => !msg.is_whisper, + LogFilter::Whispers => msg.is_whisper, + }) + .cloned() + .collect::>() + }) + }; + + // Auto-scroll to bottom when modal opens + #[cfg(feature = "hydrate")] + { + Effect::new(move |_| { + if open.get() { + // Use a small delay to ensure the DOM is rendered + use gloo_timers::callback::Timeout; + Timeout::new(50, || { + if let Some(document) = web_sys::window().and_then(|w| w.document()) { + if let Some(container) = document + .query_selector(".log-popup-messages") + .ok() + .flatten() + { + let scroll_height = container.scroll_height(); + container.set_scroll_top(scroll_height); + } + } + }) + .forget(); + } + }); + } + + let tab_class = |is_active: bool| { + if is_active { + "px-3 py-1.5 rounded text-sm font-medium bg-blue-600 text-white" + } else { + "px-3 py-1.5 rounded text-sm font-medium bg-gray-700 text-gray-300 hover:bg-gray-600" + } + }; + + view! { + + // Filter tabs +
+ + + +
+ + // Message list +
+ + "No messages yet" +

+ } + > +
    + + + "[" + {format_timestamp(timestamp)} + "] " + + + {display_name} + + + + "(whisper)" + + + + ": " + + + {content} + + + } + } + /> +
+
+
+ + // Footer hint +
+ "Press " "Esc" " to close" +
+
+ } +} + +/// Format a timestamp for display (HH:MM:SS). +fn format_timestamp(timestamp: i64) -> String { + #[cfg(feature = "hydrate")] + { + let date = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(timestamp as f64)); + let hours = date.get_hours(); + let minutes = date.get_minutes(); + let seconds = date.get_seconds(); + format!("{:02}:{:02}:{:02}", hours, minutes, seconds) + } + #[cfg(not(feature = "hydrate"))] + { + let _ = timestamp; + String::new() + } +} diff --git a/crates/chattyness-user-ui/src/pages/realm.rs b/crates/chattyness-user-ui/src/pages/realm.rs index ef81a32..113fb53 100644 --- a/crates/chattyness-user-ui/src/pages/realm.rs +++ b/crates/chattyness-user-ui/src/pages/realm.rs @@ -13,9 +13,9 @@ use uuid::Uuid; use crate::components::{ ActiveBubble, AvatarEditorPopup, Card, ChatInput, ConversationModal, EmotionKeybindings, - FadingMember, InventoryPopup, KeybindingsPopup, MessageLog, NotificationHistoryModal, - NotificationMessage, NotificationToast, RealmHeader, RealmSceneViewer, ReconnectionOverlay, - SettingsPopup, ViewerSettings, + FadingMember, HotkeyHelp, InventoryPopup, KeybindingsPopup, LogPopup, MessageLog, + NotificationHistoryModal, NotificationMessage, NotificationToast, RealmHeader, + RealmSceneViewer, ReconnectionOverlay, SettingsPopup, ViewerSettings, }; #[cfg(feature = "hydrate")] use crate::components::{ @@ -74,6 +74,12 @@ pub fn RealmPage() -> impl IntoView { let (settings_open, set_settings_open) = signal(false); let viewer_settings = RwSignal::new(ViewerSettings::load()); + // Log popup state + let (log_open, set_log_open) = signal(false); + + // Hotkey help overlay state (shown while ? is held) + let (hotkey_help_visible, set_hotkey_help_visible) = signal(false); + // Keybindings popup state let keybindings = RwSignal::new(EmotionKeybindings::load()); let (keybindings_open, set_keybindings_open) = signal(false); @@ -794,6 +800,20 @@ pub fn RealmPage() -> impl IntoView { return; } + // Handle 'l' to toggle message log + if key == "l" || key == "L" { + set_log_open.update(|v| *v = !*v); + ev.prevent_default(); + return; + } + + // Handle '?' to show hotkey help (while held) + if key == "?" { + set_hotkey_help_visible.set(true); + ev.prevent_default(); + return; + } + // Check if 'e' key was pressed if key == "e" || key == "E" { *e_pressed_clone.borrow_mut() = true; @@ -830,6 +850,25 @@ pub fn RealmPage() -> impl IntoView { // Store the closure for cleanup *closure_holder_clone.borrow_mut() = Some(closure); + + // Add keyup handler for releasing '?' (hotkey help) + let keyup_closure = Closure::::new( + move |ev: web_sys::KeyboardEvent| { + if ev.key() == "?" { + set_hotkey_help_visible.set(false); + } + }, + ); + + if let Some(window) = web_sys::window() { + let _ = window.add_event_listener_with_callback( + "keyup", + keyup_closure.as_ref().unchecked_ref(), + ); + } + + // Forget the keyup closure (it lives for the duration of the page) + keyup_closure.forget(); }); // Save position on page unload (beforeunload event) @@ -982,6 +1021,9 @@ pub fn RealmPage() -> impl IntoView { let on_open_inventory_cb = Callback::new(move |_: ()| { set_inventory_open.set(true); }); + let on_open_log_cb = Callback::new(move |_: ()| { + set_log_open.set(true); + }); let whisper_target_signal = Signal::derive(move || whisper_target.get()); let on_whisper_request_cb = Callback::new(move |target: String| { set_whisper_target.set(Some(target)); @@ -1031,6 +1073,7 @@ pub fn RealmPage() -> impl IntoView { on_focus_change=on_chat_focus_change.clone() on_open_settings=on_open_settings_cb on_open_inventory=on_open_inventory_cb + on_open_log=on_open_log_cb whisper_target=whisper_target_signal scenes=scenes_signal allow_user_teleport=teleport_enabled_signal @@ -1088,6 +1131,15 @@ pub fn RealmPage() -> impl IntoView { scene_dimensions=scene_dimensions.get() /> + // Log popup + + // Keybindings popup impl IntoView { /> } } + + // Hotkey help overlay (shown while ? is held) + } .into_any() }