feat: add log and hotkey list

This commit is contained in:
Evan Carroll 2026-01-20 19:25:58 -06:00
parent 7852790a1e
commit 41ea9d13cb
5 changed files with 415 additions and 4 deletions

View file

@ -9,10 +9,12 @@ pub mod conversation_modal;
pub mod editor; pub mod editor;
pub mod emotion_picker; pub mod emotion_picker;
pub mod forms; pub mod forms;
pub mod hotkey_help;
pub mod inventory; pub mod inventory;
pub mod keybindings; pub mod keybindings;
pub mod keybindings_popup; pub mod keybindings_popup;
pub mod layout; pub mod layout;
pub mod log_popup;
pub mod modals; pub mod modals;
pub mod notification_history; pub mod notification_history;
pub mod notifications; pub mod notifications;
@ -33,10 +35,12 @@ pub use conversation_modal::*;
pub use editor::*; pub use editor::*;
pub use emotion_picker::*; pub use emotion_picker::*;
pub use forms::*; pub use forms::*;
pub use hotkey_help::*;
pub use inventory::*; pub use inventory::*;
pub use keybindings::*; pub use keybindings::*;
pub use keybindings_popup::*; pub use keybindings_popup::*;
pub use layout::*; pub use layout::*;
pub use log_popup::*;
pub use modals::*; pub use modals::*;
pub use notification_history::*; pub use notification_history::*;
pub use notifications::*; pub use notifications::*;

View file

@ -109,6 +109,7 @@ fn parse_whisper_command(cmd: &str) -> Option<(String, String)> {
/// - `on_focus_change`: Callback when focus state changes /// - `on_focus_change`: Callback when focus state changes
/// - `on_open_settings`: Callback to open settings popup /// - `on_open_settings`: Callback to open settings popup
/// - `on_open_inventory`: Callback to open inventory 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) /// - `whisper_target`: Signal containing the display name to whisper to (triggers pre-fill)
/// - `scenes`: List of available scenes for teleport command /// - `scenes`: List of available scenes for teleport command
/// - `allow_user_teleport`: Whether teleporting is enabled for this realm /// - `allow_user_teleport`: Whether teleporting is enabled for this realm
@ -123,6 +124,9 @@ pub fn ChatInput(
on_focus_change: Callback<bool>, on_focus_change: Callback<bool>,
#[prop(optional)] on_open_settings: Option<Callback<()>>, #[prop(optional)] on_open_settings: Option<Callback<()>>,
#[prop(optional)] on_open_inventory: 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. /// Signal containing the display name to whisper to. When set, pre-fills the input.
#[prop(optional, into)] #[prop(optional, into)]
whisper_target: Option<Signal<Option<String>>>, whisper_target: Option<Signal<Option<String>>>,
@ -324,9 +328,11 @@ pub fn ChatInput(
|| "inventory".starts_with(&cmd) || "inventory".starts_with(&cmd)
|| "whisper".starts_with(&cmd) || "whisper".starts_with(&cmd)
|| "teleport".starts_with(&cmd) || "teleport".starts_with(&cmd)
|| "log".starts_with(&cmd)
|| cmd == "setting" || cmd == "setting"
|| cmd == "settings" || cmd == "settings"
|| cmd == "inventory" || cmd == "inventory"
|| cmd == "log"
|| cmd.starts_with("w ") || cmd.starts_with("w ")
|| cmd.starts_with("whisper ") || cmd.starts_with("whisper ")
|| cmd.starts_with("t ") || cmd.starts_with("t ")
@ -348,6 +354,7 @@ pub fn ChatInput(
let apply_emotion = apply_emotion.clone(); let apply_emotion = apply_emotion.clone();
let on_open_settings = on_open_settings.clone(); let on_open_settings = on_open_settings.clone();
let on_open_inventory = on_open_inventory.clone(); let on_open_inventory = on_open_inventory.clone();
let on_open_log = on_open_log.clone();
move |ev: web_sys::KeyboardEvent| { move |ev: web_sys::KeyboardEvent| {
let key = ev.key(); let key = ev.key();
let current_mode = command_mode.get_untracked(); let current_mode = command_mode.get_untracked();
@ -484,6 +491,20 @@ pub fn ChatInput(
ev.prevent_default(); ev.prevent_default();
return; 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 // Always prevent Tab from moving focus when in input
ev.prevent_default(); ev.prevent_default();
@ -527,6 +548,21 @@ pub fn ChatInput(
return; 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 // /w NAME message or /whisper NAME message
if let Some((target_name, whisper_content)) = parse_whisper_command(&msg) { if let Some((target_name, whisper_content)) = parse_whisper_command(&msg) {
if !whisper_content.trim().is_empty() { if !whisper_content.trim().is_empty() {
@ -702,7 +738,7 @@ pub fn ChatInput(
</div> </div>
</Show> </Show>
// 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])
<Show when=move || command_mode.get() == CommandMode::ShowingSlashHint> <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"> <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-gray-400">"/"</span>
@ -716,6 +752,10 @@ pub fn ChatInput(
<span class="text-gray-400">"/"</span> <span class="text-gray-400">"/"</span>
<span class="text-blue-400">"w"</span> <span class="text-blue-400">"w"</span>
<span class="text-gray-500">"[hisper] name"</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()> <Show when=move || allow_user_teleport.get()>
<span class="text-gray-600 mx-2">"|"</span> <span class="text-gray-600 mx-2">"|"</span>
<span class="text-gray-400">"/"</span> <span class="text-gray-400">"/"</span>

View file

@ -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<bool>,
) -> 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! {
<div class=outer_class>
// Semi-transparent backdrop
<div class="absolute inset-0 bg-black/60" aria-hidden="true" />
// Help content
<div class="relative bg-gray-800/95 backdrop-blur-sm rounded-lg shadow-2xl p-6 border border-gray-600 max-w-md">
<h2 class="text-lg font-bold text-white mb-4 text-center">
"Keyboard Shortcuts"
</h2>
<div class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
// Navigation & Chat
<div class="col-span-2 text-gray-400 font-medium mt-2 first:mt-0">
"Navigation & Chat"
</div>
<HotkeyRow key="Space" description="Focus chat input" />
<HotkeyRow key=":" description="Open emote commands" />
<HotkeyRow key="/" description="Open slash commands" />
<HotkeyRow key="Esc" description="Close/unfocus" />
// Popups
<div class="col-span-2 text-gray-400 font-medium mt-3">
"Popups"
</div>
<HotkeyRow key="s" description="Settings" />
<HotkeyRow key="i" description="Inventory" />
<HotkeyRow key="k" description="Keybindings" />
<HotkeyRow key="a" description="Avatar editor" />
<HotkeyRow key="l" description="Message log" />
// Emotions
<div class="col-span-2 text-gray-400 font-medium mt-3">
"Emotions"
</div>
<div class="col-span-2 text-gray-300">
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-xs">"e"</kbd>
" + "
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-xs">"0-9"</kbd>
" / "
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-xs">"q"</kbd>
" / "
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-xs">"w"</kbd>
<span class="text-gray-500 ml-2">"Apply emotion"</span>
</div>
// View controls
<div class="col-span-2 text-gray-400 font-medium mt-3">
"View (when panning enabled)"
</div>
<HotkeyRow key="Arrows" description="Pan view" />
<HotkeyRow key="+/-" description="Zoom in/out" />
</div>
<div class="mt-4 pt-3 border-t border-gray-600 text-xs text-gray-500 text-center">
"Release " <kbd class="px-1.5 py-0.5 bg-gray-700 rounded">"?"</kbd> " to close"
</div>
</div>
</div>
}
}
/// 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! {
<div class="contents">
<div class="text-right">
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-xs text-gray-200">
{key}
</kbd>
</div>
<div class="text-gray-300">
{description}
</div>
</div>
}
}

View file

@ -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<bool>,
message_log: StoredValue<MessageLog, LocalStorage>,
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::<Vec<_>>()
})
};
// 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! {
<Modal
open=open
on_close=on_close.clone()
title="Message Log"
title_id="message-log-title"
max_width="max-w-2xl"
>
// Filter tabs
<div class="flex gap-2 mb-4">
<button
class=move || tab_class(filter.get() == LogFilter::All)
on:click=move |_| set_filter.set(LogFilter::All)
>
"All"
</button>
<button
class=move || tab_class(filter.get() == LogFilter::Chat)
on:click=move |_| set_filter.set(LogFilter::Chat)
>
"Chat"
</button>
<button
class=move || tab_class(filter.get() == LogFilter::Whispers)
on:click=move |_| set_filter.set(LogFilter::Whispers)
>
"Whispers"
</button>
</div>
// Message list
<div class="log-popup-messages max-h-[70vh] overflow-y-auto bg-gray-900/50 rounded-lg p-2">
<Show
when=move || !filtered_messages().is_empty()
fallback=|| view! {
<p class="text-gray-400 text-center py-8">
"No messages yet"
</p>
}
>
<ul class="space-y-1 font-mono text-sm">
<For
each=move || filtered_messages()
key=|msg| msg.message_id
children=move |msg: ChatMessage| {
let is_whisper = msg.is_whisper;
let display_name = msg.display_name.clone();
let content = msg.content.clone();
let timestamp = msg.timestamp;
view! {
<li class=move || {
if is_whisper {
"py-1 px-2 rounded bg-purple-900/30 border-l-2 border-purple-500"
} else {
"py-1 px-2"
}
}>
<span class="text-gray-500">
"["
{format_timestamp(timestamp)}
"] "
</span>
<span class=move || {
if is_whisper {
"text-purple-300 font-medium"
} else {
"text-blue-300 font-medium"
}
}>
{display_name}
</span>
<Show when=move || is_whisper>
<span class="text-purple-400 text-xs ml-1">
"(whisper)"
</span>
</Show>
<span class="text-gray-400">
": "
</span>
<span class=move || {
if is_whisper {
"text-gray-300 italic"
} else {
"text-gray-200"
}
}>
{content}
</span>
</li>
}
}
/>
</ul>
</Show>
</div>
// Footer hint
<div class="mt-4 pt-4 border-t border-gray-600 text-xs text-gray-500 text-center">
"Press " <kbd class="px-1.5 py-0.5 bg-gray-700 rounded">"Esc"</kbd> " to close"
</div>
</Modal>
}
}
/// 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()
}
}

View file

@ -13,9 +13,9 @@ use uuid::Uuid;
use crate::components::{ use crate::components::{
ActiveBubble, AvatarEditorPopup, Card, ChatInput, ConversationModal, EmotionKeybindings, ActiveBubble, AvatarEditorPopup, Card, ChatInput, ConversationModal, EmotionKeybindings,
FadingMember, InventoryPopup, KeybindingsPopup, MessageLog, NotificationHistoryModal, FadingMember, HotkeyHelp, InventoryPopup, KeybindingsPopup, LogPopup, MessageLog,
NotificationMessage, NotificationToast, RealmHeader, RealmSceneViewer, ReconnectionOverlay, NotificationHistoryModal, NotificationMessage, NotificationToast, RealmHeader,
SettingsPopup, ViewerSettings, RealmSceneViewer, ReconnectionOverlay, SettingsPopup, ViewerSettings,
}; };
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
use crate::components::{ use crate::components::{
@ -74,6 +74,12 @@ pub fn RealmPage() -> impl IntoView {
let (settings_open, set_settings_open) = signal(false); let (settings_open, set_settings_open) = signal(false);
let viewer_settings = RwSignal::new(ViewerSettings::load()); 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 // Keybindings popup state
let keybindings = RwSignal::new(EmotionKeybindings::load()); let keybindings = RwSignal::new(EmotionKeybindings::load());
let (keybindings_open, set_keybindings_open) = signal(false); let (keybindings_open, set_keybindings_open) = signal(false);
@ -794,6 +800,20 @@ pub fn RealmPage() -> impl IntoView {
return; 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 // Check if 'e' key was pressed
if key == "e" || key == "E" { if key == "e" || key == "E" {
*e_pressed_clone.borrow_mut() = true; *e_pressed_clone.borrow_mut() = true;
@ -830,6 +850,25 @@ pub fn RealmPage() -> impl IntoView {
// Store the closure for cleanup // Store the closure for cleanup
*closure_holder_clone.borrow_mut() = Some(closure); *closure_holder_clone.borrow_mut() = Some(closure);
// Add keyup handler for releasing '?' (hotkey help)
let keyup_closure = Closure::<dyn Fn(web_sys::KeyboardEvent)>::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) // Save position on page unload (beforeunload event)
@ -982,6 +1021,9 @@ pub fn RealmPage() -> impl IntoView {
let on_open_inventory_cb = Callback::new(move |_: ()| { let on_open_inventory_cb = Callback::new(move |_: ()| {
set_inventory_open.set(true); 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 whisper_target_signal = Signal::derive(move || whisper_target.get());
let on_whisper_request_cb = Callback::new(move |target: String| { let on_whisper_request_cb = Callback::new(move |target: String| {
set_whisper_target.set(Some(target)); set_whisper_target.set(Some(target));
@ -1031,6 +1073,7 @@ pub fn RealmPage() -> impl IntoView {
on_focus_change=on_chat_focus_change.clone() on_focus_change=on_chat_focus_change.clone()
on_open_settings=on_open_settings_cb on_open_settings=on_open_settings_cb
on_open_inventory=on_open_inventory_cb on_open_inventory=on_open_inventory_cb
on_open_log=on_open_log_cb
whisper_target=whisper_target_signal whisper_target=whisper_target_signal
scenes=scenes_signal scenes=scenes_signal
allow_user_teleport=teleport_enabled_signal allow_user_teleport=teleport_enabled_signal
@ -1088,6 +1131,15 @@ pub fn RealmPage() -> impl IntoView {
scene_dimensions=scene_dimensions.get() scene_dimensions=scene_dimensions.get()
/> />
// Log popup
<LogPopup
open=Signal::derive(move || log_open.get())
message_log=message_log
on_close=Callback::new(move |_: ()| {
set_log_open.set(false);
})
/>
// Keybindings popup // Keybindings popup
<KeybindingsPopup <KeybindingsPopup
open=Signal::derive(move || keybindings_open.get()) open=Signal::derive(move || keybindings_open.get())
@ -1226,6 +1278,9 @@ pub fn RealmPage() -> impl IntoView {
/> />
} }
} }
// Hotkey help overlay (shown while ? is held)
<HotkeyHelp visible=Signal::derive(move || hotkey_help_visible.get()) />
} }
.into_any() .into_any()
} }