222 lines
7.9 KiB
Rust
222 lines
7.9 KiB
Rust
//! 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<Option<NotificationMessage>>,
|
|
/// Callback when user wants to reply (press 'r').
|
|
on_reply: Callback<String>,
|
|
/// Callback when user wants to see context (press 'c').
|
|
on_context: Callback<String>,
|
|
/// Callback when notification is dismissed.
|
|
on_dismiss: Callback<Uuid>,
|
|
) -> impl IntoView {
|
|
// Auto-dismiss timer
|
|
#[cfg(feature = "hydrate")]
|
|
{
|
|
use std::cell::RefCell;
|
|
use std::rc::Rc;
|
|
|
|
let timer_handle = Rc::new(RefCell::new(None::<i32>));
|
|
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<dyn FnOnce()>);
|
|
|
|
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<Option<wasm_bindgen::closure::Closure<dyn Fn(web_sys::KeyboardEvent)>>>,
|
|
> = 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::<dyn Fn(web_sys::KeyboardEvent)>::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! {
|
|
<Show when=move || notification.get().is_some()>
|
|
{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! {
|
|
<div class="fixed top-4 right-4 z-50 max-w-sm w-full animate-slide-in-right">
|
|
<div class="bg-gray-800 border border-purple-500/50 rounded-lg shadow-lg p-4">
|
|
// Header
|
|
<div class="flex items-center gap-2 mb-2">
|
|
<span class="text-lg">"💬"</span>
|
|
<span class="text-purple-300 font-medium">
|
|
{n.chat_message.display_name.clone()} " whispered to you"
|
|
</span>
|
|
</div>
|
|
|
|
// Content
|
|
<p class="text-gray-300 italic mb-3">
|
|
{content_preview}
|
|
</p>
|
|
|
|
// Keybinding hints
|
|
<div class="flex gap-3 text-xs text-gray-500">
|
|
<span>
|
|
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-gray-400">"r"</kbd>
|
|
" reply"
|
|
</span>
|
|
<span>
|
|
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-gray-400">"c"</kbd>
|
|
" context"
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}.into_any()
|
|
} else {
|
|
().into_any()
|
|
}
|
|
}}
|
|
</Show>
|
|
}
|
|
}
|