//! Notification components for whispers and system messages. //! //! Provides toast notifications for cross-scene whispers with keybindings. use leptos::prelude::*; use uuid::Uuid; #[cfg(feature = "hydrate")] use wasm_bindgen::JsCast; use super::chat_types::ChatMessage; /// A notification message for display. #[derive(Debug, Clone, PartialEq)] pub struct NotificationMessage { pub id: Uuid, pub chat_message: ChatMessage, /// When the notification was created (milliseconds since epoch). pub created_at: i64, /// Whether the notification has been dismissed. pub dismissed: bool, } impl NotificationMessage { /// Create a new notification from a chat message. #[cfg(feature = "hydrate")] pub fn from_chat_message(msg: ChatMessage) -> Self { Self { id: Uuid::new_v4(), chat_message: msg, created_at: js_sys::Date::now() as i64, dismissed: false, } } /// SSR stub. #[cfg(not(feature = "hydrate"))] pub fn from_chat_message(msg: ChatMessage) -> Self { Self { id: Uuid::new_v4(), chat_message: msg, created_at: 0, dismissed: false, } } } /// Toast notification component for cross-scene whispers. /// /// Displays in the top-right corner with auto-dismiss and keybindings. #[component] pub fn NotificationToast( /// The notification to display. #[prop(into)] notification: Signal>, /// Callback when user wants to reply (press 'r'). on_reply: Callback, /// Callback when user wants to see context (press 'c'). on_context: Callback, /// Callback when notification is dismissed. on_dismiss: Callback, ) -> impl IntoView { // Auto-dismiss timer #[cfg(feature = "hydrate")] { use std::cell::RefCell; use std::rc::Rc; let timer_handle = Rc::new(RefCell::new(None::)); let timer_handle_effect = timer_handle.clone(); Effect::new(move |_| { let notif = notification.get(); // Clear any existing timer if let Some(handle) = timer_handle_effect.borrow_mut().take() { web_sys::window().unwrap().clear_timeout_with_handle(handle); } // Start new timer if notification is present if let Some(ref n) = notif { let id = n.id; let on_dismiss = on_dismiss.clone(); let timer_handle_inner = timer_handle_effect.clone(); let closure = wasm_bindgen::closure::Closure::once(Box::new(move || { on_dismiss.run(id); timer_handle_inner.borrow_mut().take(); }) as Box); if let Some(window) = web_sys::window() { if let Ok(handle) = window .set_timeout_with_callback_and_timeout_and_arguments_0( closure.as_ref().unchecked_ref(), 5000, // 5 second auto-dismiss ) { *timer_handle_effect.borrow_mut() = Some(handle); } } closure.forget(); } }); } // Keybinding handler with proper cleanup #[cfg(feature = "hydrate")] { use std::cell::RefCell; use std::rc::Rc; let closure_holder: Rc< RefCell>>, > = Rc::new(RefCell::new(None)); let closure_holder_clone = closure_holder.clone(); Effect::new(move |_| { let notif = notification.get(); // Cleanup previous listener if let Some(old_closure) = closure_holder_clone.borrow_mut().take() { if let Some(window) = web_sys::window() { let _ = window.remove_event_listener_with_callback( "keydown", old_closure.as_ref().unchecked_ref(), ); } } // Only add listener if notification is present let Some(ref n) = notif else { return; }; let display_name = n.chat_message.display_name.clone(); let notif_id = n.id; let on_reply = on_reply.clone(); let on_context = on_context.clone(); let on_dismiss = on_dismiss.clone(); let closure = wasm_bindgen::closure::Closure::::new( move |ev: web_sys::KeyboardEvent| { let key = ev.key(); match key.as_str() { "r" | "R" => { ev.prevent_default(); on_reply.run(display_name.clone()); on_dismiss.run(notif_id); } "c" | "C" => { ev.prevent_default(); on_context.run(display_name.clone()); on_dismiss.run(notif_id); } "Escape" => { ev.prevent_default(); on_dismiss.run(notif_id); } _ => {} } }, ); if let Some(window) = web_sys::window() { let _ = window .add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref()); } // Store closure for cleanup on next change *closure_holder_clone.borrow_mut() = Some(closure); }); } view! { {move || { let notif = notification.get(); if let Some(n) = notif { let content_preview = if n.chat_message.content.len() > 50 { format!("\"{}...\"", &n.chat_message.content[..47]) } else { format!("\"{}\"", n.chat_message.content) }; view! {
// Header
"💬" {n.chat_message.display_name.clone()} " whispered to you"
// Content

{content_preview}

// Keybinding hints
"r" " reply" "c" " context"
}.into_any() } else { ().into_any() } }}
} }