feat: private messages.

This commit is contained in:
Evan Carroll 2026-01-18 15:28:13 -06:00
parent 0492043625
commit 22cc0fdc38
11 changed files with 1135 additions and 44 deletions

View file

@ -0,0 +1,232 @@
//! 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 user wants to see history (press 'h').
on_history: Callback<()>,
/// 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_history = on_history.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);
}
"h" | "H" => {
ev.prevent_default();
on_history.run(());
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>
<span>
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-gray-400">"h"</kbd>
" history"
</span>
</div>
</div>
</div>
}.into_any()
} else {
().into_any()
}
}}
</Show>
}
}