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

@ -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<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.
#[prop(optional, into)]
whisper_target: Option<Signal<Option<String>>>,
@ -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(
</div>
</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>
<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>
@ -716,6 +752,10 @@ pub fn ChatInput(
<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>

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()
}
}