feat: private messages.
This commit is contained in:
parent
0492043625
commit
22cc0fdc38
11 changed files with 1135 additions and 44 deletions
232
crates/chattyness-user-ui/src/components/notifications.rs
Normal file
232
crates/chattyness-user-ui/src/components/notifications.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue