diff --git a/crates/chattyness-user-ui/src/components.rs b/crates/chattyness-user-ui/src/components.rs index afdd99f..363a261 100644 --- a/crates/chattyness-user-ui/src/components.rs +++ b/crates/chattyness-user-ui/src/components.rs @@ -16,9 +16,8 @@ pub mod keybindings_popup; pub mod layout; pub mod log_popup; pub mod modals; -pub mod notification_history; -pub mod register_modal; pub mod notifications; +pub mod register_modal; pub mod scene_list_popup; pub mod scene_viewer; pub mod settings; @@ -43,7 +42,6 @@ pub use keybindings_popup::*; pub use layout::*; pub use log_popup::*; pub use modals::*; -pub use notification_history::*; pub use notifications::*; pub use register_modal::*; pub use reconnection_overlay::*; diff --git a/crates/chattyness-user-ui/src/components/chat_types.rs b/crates/chattyness-user-ui/src/components/chat_types.rs index 60b3f14..d5bfbb1 100644 --- a/crates/chattyness-user-ui/src/components/chat_types.rs +++ b/crates/chattyness-user-ui/src/components/chat_types.rs @@ -31,6 +31,9 @@ pub struct ChatMessage { /// For whispers: true = same scene (show as bubble), false = cross-scene (show as notification). #[serde(default = "default_true")] pub is_same_scene: bool, + /// Whether this is a system/admin message (teleport, summon, mod commands). + #[serde(default)] + pub is_system: bool, } /// Default function for serde that returns true. diff --git a/crates/chattyness-user-ui/src/components/log_popup.rs b/crates/chattyness-user-ui/src/components/log_popup.rs index c4b09f1..23c8426 100644 --- a/crates/chattyness-user-ui/src/components/log_popup.rs +++ b/crates/chattyness-user-ui/src/components/log_popup.rs @@ -1,6 +1,7 @@ //! Message log popup component. //! //! Displays a filterable chronological log of received messages. +//! Includes persistent whisper history via LocalStorage. use leptos::prelude::*; use leptos::reactive::owner::LocalStorage; @@ -8,6 +9,77 @@ use leptos::reactive::owner::LocalStorage; use super::chat_types::{ChatMessage, MessageLog}; use super::modals::Modal; +/// LocalStorage key for persistent whisper history. +const WHISPER_STORAGE_KEY: &str = "chattyness_whisper_history"; +/// Maximum number of whispers to keep in persistent history. +const MAX_WHISPER_HISTORY: usize = 100; + +/// Load whisper history from LocalStorage. +#[cfg(feature = "hydrate")] +pub fn load_whisper_history() -> Vec { + let window = match web_sys::window() { + Some(w) => w, + None => return Vec::new(), + }; + + let storage = match window.local_storage() { + Ok(Some(s)) => s, + _ => return Vec::new(), + }; + + let json = match storage.get_item(WHISPER_STORAGE_KEY) { + Ok(Some(j)) => j, + _ => return Vec::new(), + }; + + serde_json::from_str(&json).unwrap_or_default() +} + +/// Save whisper history to LocalStorage. +#[cfg(feature = "hydrate")] +pub fn save_whisper_history(whispers: &[ChatMessage]) { + let window = match web_sys::window() { + Some(w) => w, + None => return, + }; + + let storage = match window.local_storage() { + Ok(Some(s)) => s, + _ => return, + }; + + if let Ok(json) = serde_json::to_string(whispers) { + let _ = storage.set_item(WHISPER_STORAGE_KEY, &json); + } +} + +/// Add a whisper to persistent history, maintaining max size. +/// Deduplicates by message_id. +#[cfg(feature = "hydrate")] +pub fn add_whisper_to_history(msg: ChatMessage) { + let mut history = load_whisper_history(); + // Avoid duplicates by message_id + if !history.iter().any(|m| m.message_id == msg.message_id) { + history.insert(0, msg); + if history.len() > MAX_WHISPER_HISTORY { + history.truncate(MAX_WHISPER_HISTORY); + } + save_whisper_history(&history); + } +} + +/// SSR stubs for persistence functions. +#[cfg(not(feature = "hydrate"))] +pub fn load_whisper_history() -> Vec { + Vec::new() +} + +#[cfg(not(feature = "hydrate"))] +pub fn save_whisper_history(_whispers: &[ChatMessage]) {} + +#[cfg(not(feature = "hydrate"))] +pub fn add_whisper_to_history(_msg: ChatMessage) {} + /// Filter mode for message log display. #[derive(Clone, Copy, PartialEq, Eq, Default)] pub enum LogFilter { @@ -18,16 +90,23 @@ pub enum LogFilter { Chat, /// Show only whispers. Whispers, + /// Show only system/admin messages (teleports, summons, mod commands). + System, } /// Message log popup component. /// -/// Displays a filterable list of messages from the session. +/// Displays a filterable list of messages from the session, +/// with persistent whisper history from LocalStorage. #[component] pub fn LogPopup( #[prop(into)] open: Signal, message_log: StoredValue, on_close: Callback<()>, + /// Callback when user wants to reply to a whisper. + on_reply: Callback, + /// Callback when user wants to see conversation context. + on_context: Callback, ) -> impl IntoView { let (filter, set_filter) = signal(LogFilter::All); @@ -38,17 +117,46 @@ pub fn LogPopup( // 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, + + match current_filter { + LogFilter::Whispers => { + // For whispers, merge session messages with persistent history + let mut session_whispers: Vec = message_log.with_value(|log| { + log.all_messages() + .iter() + .filter(|msg| msg.is_whisper) + .cloned() + .collect() + }); + + // Load persistent whispers and merge (avoiding duplicates by message_id) + let persistent = load_whisper_history(); + for msg in persistent { + if !session_whispers.iter().any(|m| m.message_id == msg.message_id) { + session_whispers.push(msg); + } + } + + // Sort by timestamp (oldest first for display) + session_whispers.sort_by_key(|m| m.timestamp); + session_whispers + } + _ => { + // For All, Chat, or System, use session messages only + message_log.with_value(|log| { + log.all_messages() + .iter() + .filter(|msg| match current_filter { + LogFilter::All => true, + LogFilter::Chat => !msg.is_whisper && !msg.is_system, + LogFilter::System => msg.is_system, + LogFilter::Whispers => unreachable!(), + }) + .cloned() + .collect::>() }) - .cloned() - .collect::>() - }) + } + } }; // Auto-scroll to bottom when modal opens @@ -111,6 +219,12 @@ pub fn LogPopup( > "Whispers" + // Message list @@ -127,52 +241,114 @@ pub fn LogPopup( - - "[" - {format_timestamp(timestamp)} - "] " - - - {display_name} - - - - "(whisper)" - - - - ": " - - - {content} - - +
+
+ + "[" + {format_timestamp(timestamp)} + "] " + + + {display_name} + + + + "(system)" + + + + + "(whisper)" + + + + ": " + + + {content} + +
+ +
+ + +
+
+
+ + } } } /> diff --git a/crates/chattyness-user-ui/src/components/notification_history.rs b/crates/chattyness-user-ui/src/components/notification_history.rs deleted file mode 100644 index f1c9fdf..0000000 --- a/crates/chattyness-user-ui/src/components/notification_history.rs +++ /dev/null @@ -1,241 +0,0 @@ -//! Notification history component with LocalStorage persistence. -//! -//! Shows last 100 notifications across sessions. - -use leptos::prelude::*; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use super::chat_types::ChatMessage; -use super::modals::Modal; - -const STORAGE_KEY: &str = "chattyness_notification_history"; -const MAX_HISTORY_SIZE: usize = 100; - -/// A stored notification entry for history. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct HistoryEntry { - pub id: Uuid, - pub sender_name: String, - pub content: String, - pub timestamp: i64, - pub is_whisper: bool, - /// Type of notification (e.g., "whisper", "system", "mod"). - pub notification_type: String, -} - -impl HistoryEntry { - /// Create from a whisper chat message. - pub fn from_whisper(msg: &ChatMessage) -> Self { - Self { - id: Uuid::new_v4(), - sender_name: msg.display_name.clone(), - content: msg.content.clone(), - timestamp: msg.timestamp, - is_whisper: true, - notification_type: "whisper".to_string(), - } - } -} - -/// Load history from LocalStorage. -#[cfg(feature = "hydrate")] -pub fn load_history() -> Vec { - let window = match web_sys::window() { - Some(w) => w, - None => return Vec::new(), - }; - - let storage = match window.local_storage() { - Ok(Some(s)) => s, - _ => return Vec::new(), - }; - - let json = match storage.get_item(STORAGE_KEY) { - Ok(Some(j)) => j, - _ => return Vec::new(), - }; - - serde_json::from_str(&json).unwrap_or_default() -} - -/// Save history to LocalStorage. -#[cfg(feature = "hydrate")] -pub fn save_history(history: &[HistoryEntry]) { - let window = match web_sys::window() { - Some(w) => w, - None => return, - }; - - let storage = match window.local_storage() { - Ok(Some(s)) => s, - _ => return, - }; - - if let Ok(json) = serde_json::to_string(history) { - let _ = storage.set_item(STORAGE_KEY, &json); - } -} - -/// Add an entry to history, maintaining max size. -#[cfg(feature = "hydrate")] -pub fn add_to_history(entry: HistoryEntry) { - let mut history = load_history(); - history.insert(0, entry); - if history.len() > MAX_HISTORY_SIZE { - history.truncate(MAX_HISTORY_SIZE); - } - save_history(&history); -} - -/// SSR stubs -#[cfg(not(feature = "hydrate"))] -pub fn load_history() -> Vec { - Vec::new() -} - -#[cfg(not(feature = "hydrate"))] -pub fn save_history(_history: &[HistoryEntry]) {} - -#[cfg(not(feature = "hydrate"))] -pub fn add_to_history(_entry: HistoryEntry) {} - -/// Notification history modal component. -#[component] -pub fn NotificationHistoryModal( - #[prop(into)] open: Signal, - on_close: Callback<()>, - /// Callback when user wants to reply to a message. - on_reply: Callback, - /// Callback when user wants to see conversation context. - on_context: Callback, -) -> impl IntoView { - // Load history when modal opens - let history = Signal::derive(move || { - if open.get() { - load_history() - } else { - Vec::new() - } - }); - - view! { - -
- - "No notifications yet" -

- } - > -
    - -
    -
    -
    - - {sender_name} - - - {format_timestamp(entry.timestamp)} - - - - "whisper" - - -
    -

    - {entry.content.clone()} -

    -
    -
    - - -
    -
    - - } - } - } - /> -
-
-
- - // Footer hint -
- "Press " "Esc" " to close" -
-
- } -} - -/// Format a timestamp for display. -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(); - format!("{:02}:{:02}", hours, minutes) - } - #[cfg(not(feature = "hydrate"))] - { - let _ = timestamp; - String::new() - } -} diff --git a/crates/chattyness-user-ui/src/components/notifications.rs b/crates/chattyness-user-ui/src/components/notifications.rs index f060463..a87c84e 100644 --- a/crates/chattyness-user-ui/src/components/notifications.rs +++ b/crates/chattyness-user-ui/src/components/notifications.rs @@ -56,8 +56,6 @@ pub fn NotificationToast( on_reply: Callback, /// Callback when user wants to see context (press 'c'). on_context: Callback, - /// Callback when user wants to see history (press 'h'). - on_history: Callback<()>, /// Callback when notification is dismissed. on_dismiss: Callback, ) -> impl IntoView { @@ -139,7 +137,6 @@ pub fn NotificationToast( let on_reply = on_reply.clone(); let on_context = on_context.clone(); - let on_history = on_history.clone(); let on_dismiss = on_dismiss.clone(); let closure = wasm_bindgen::closure::Closure::::new( @@ -156,11 +153,6 @@ pub fn NotificationToast( on_context.run(display_name.clone()); on_dismiss.run(notif_id); } - "h" | "H" => { - ev.prevent_default(); - on_history.run(()); - on_dismiss.run(notif_id); - } "Escape" => { ev.prevent_default(); on_dismiss.run(notif_id); @@ -217,10 +209,6 @@ pub fn NotificationToast( "c" " context" - - "h" - " history" - diff --git a/crates/chattyness-user-ui/src/components/ws_client.rs b/crates/chattyness-user-ui/src/components/ws_client.rs index 64cc618..71c2fef 100644 --- a/crates/chattyness-user-ui/src/components/ws_client.rs +++ b/crates/chattyness-user-ui/src/components/ws_client.rs @@ -578,6 +578,7 @@ fn handle_server_message( timestamp, is_whisper, is_same_scene, + is_system: false, }; on_chat_message.run(chat_msg); } diff --git a/crates/chattyness-user-ui/src/pages/realm.rs b/crates/chattyness-user-ui/src/pages/realm.rs index b40d0fe..73805de 100644 --- a/crates/chattyness-user-ui/src/pages/realm.rs +++ b/crates/chattyness-user-ui/src/pages/realm.rs @@ -14,14 +14,14 @@ use uuid::Uuid; use crate::components::{ ActiveBubble, AvatarEditorPopup, Card, ChatInput, ConversationModal, EmotionKeybindings, FadingMember, HotkeyHelp, InventoryPopup, KeybindingsPopup, LogPopup, MessageLog, - NotificationHistoryModal, NotificationMessage, NotificationToast, RealmHeader, - RealmSceneViewer, ReconnectionOverlay, RegisterModal, SettingsPopup, ViewerSettings, + NotificationMessage, NotificationToast, RealmHeader, RealmSceneViewer, ReconnectionOverlay, + RegisterModal, SettingsPopup, ViewerSettings, }; #[cfg(feature = "hydrate")] use crate::components::{ - ChannelMemberInfo, ChatMessage, DEFAULT_BUBBLE_TIMEOUT_MS, FADE_DURATION_MS, HistoryEntry, - MemberIdentityInfo, ModCommandResultInfo, SummonInfo, TeleportInfo, WsError, add_to_history, - use_channel_websocket, + ChannelMemberInfo, ChatMessage, DEFAULT_BUBBLE_TIMEOUT_MS, FADE_DURATION_MS, + MemberIdentityInfo, ModCommandResultInfo, SummonInfo, TeleportInfo, WsError, + add_whisper_to_history, use_channel_websocket, }; use crate::utils::LocalStoragePersist; #[cfg(feature = "hydrate")] @@ -120,7 +120,6 @@ pub fn RealmPage() -> impl IntoView { // Notification state for cross-scene whispers let (current_notification, set_current_notification) = signal(Option::::None); - let (history_modal_open, set_history_modal_open) = signal(false); let (conversation_modal_open, set_conversation_modal_open) = signal(false); let (conversation_partner, set_conversation_partner) = signal(String::new()); // Track all whisper messages for conversation view (client-side only) @@ -268,8 +267,8 @@ pub fn RealmPage() -> impl IntoView { } }); - // Add to notification history for persistence - add_to_history(HistoryEntry::from_whisper(&msg)); + // Add to persistent whisper history in LocalStorage + add_whisper_to_history(msg.clone()); if msg.is_same_scene { // Same scene whisper: show as italic bubble (handled by bubble rendering) @@ -368,6 +367,23 @@ pub fn RealmPage() -> impl IntoView { // Callback for teleport approval - navigate to new scene #[cfg(feature = "hydrate")] let on_teleport_approved = Callback::new(move |info: TeleportInfo| { + // Log teleport to message log + let teleport_msg = ChatMessage { + message_id: Uuid::new_v4(), + user_id: None, + guest_session_id: None, + display_name: "[SYSTEM]".to_string(), + content: format!("Teleported to scene: {}", info.scene_slug), + emotion: "neutral".to_string(), + x: 0.0, + y: 0.0, + timestamp: js_sys::Date::now() as i64, + is_whisper: false, + is_same_scene: true, + is_system: true, + }; + message_log.update_value(|log| log.push(teleport_msg)); + let scene_id = info.scene_id; let scene_slug = info.scene_slug.clone(); let realm_slug = slug.get_untracked(); @@ -431,6 +447,23 @@ pub fn RealmPage() -> impl IntoView { // Callback for being summoned by a moderator - show notification and teleport #[cfg(feature = "hydrate")] let on_summoned = Callback::new(move |info: SummonInfo| { + // Log summon to message log + let summon_msg = ChatMessage { + message_id: Uuid::new_v4(), + user_id: None, + guest_session_id: None, + display_name: "[MOD]".to_string(), + content: format!("Summoned by {} to scene: {}", info.summoned_by, info.scene_slug), + emotion: "neutral".to_string(), + x: 0.0, + y: 0.0, + timestamp: js_sys::Date::now() as i64, + is_whisper: false, + is_same_scene: true, + is_system: true, + }; + message_log.update_value(|log| log.push(summon_msg)); + // Show notification set_mod_notification.set(Some((true, format!("Summoned by {}", info.summoned_by)))); @@ -503,6 +536,24 @@ pub fn RealmPage() -> impl IntoView { // Callback for mod command result - show notification #[cfg(feature = "hydrate")] let on_mod_command_result = Callback::new(move |info: ModCommandResultInfo| { + // Log mod command result to message log + let status = if info.success { "OK" } else { "FAILED" }; + let mod_msg = ChatMessage { + message_id: Uuid::new_v4(), + user_id: None, + guest_session_id: None, + display_name: "[MOD]".to_string(), + content: format!("[{}] {}", status, info.message), + emotion: "neutral".to_string(), + x: 0.0, + y: 0.0, + timestamp: js_sys::Date::now() as i64, + is_whisper: false, + is_same_scene: true, + is_system: true, + }; + message_log.update_value(|log| log.push(mod_msg)); + set_mod_notification.set(Some((info.success, info.message))); // Auto-dismiss notification after 3 seconds @@ -820,7 +871,6 @@ pub fn RealmPage() -> impl IntoView { || keybindings_open.get_untracked() || avatar_editor_open.get_untracked() || register_modal_open.get_untracked() - || history_modal_open.get_untracked() || conversation_modal_open.get_untracked() { *e_pressed_clone.borrow_mut() = false; @@ -1301,6 +1351,13 @@ pub fn RealmPage() -> impl IntoView { on_close=Callback::new(move |_: ()| { set_log_open.set(false); }) + on_reply=Callback::new(move |name: String| { + whisper_target.set(Some(name)); + }) + on_context=Callback::new(move |name: String| { + set_conversation_partner.set(name); + set_conversation_modal_open.set(true); + }) /> // Keybindings popup @@ -1381,9 +1438,6 @@ pub fn RealmPage() -> impl IntoView { set_conversation_partner.set(name); set_conversation_modal_open.set(true); }) - on_history=Callback::new(move |_: ()| { - set_history_modal_open.set(true); - }) on_dismiss=Callback::new(move |_: Uuid| { set_current_notification.set(None); }) @@ -1443,19 +1497,6 @@ pub fn RealmPage() -> impl IntoView { }} - // Notification history modal - - // Conversation modal { #[cfg(feature = "hydrate")]