From 22cc0fdc3897102d9734c90f1f88cd6864e5fdd058ffc72acebd0514591427d5 Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Sun, 18 Jan 2026 15:28:13 -0600 Subject: [PATCH] feat: private messages. --- crates/chattyness-db/src/ws_messages.rs | 21 +- .../chattyness-user-ui/src/api/websocket.rs | 167 +++++++++++- crates/chattyness-user-ui/src/components.rs | 6 + .../src/components/avatar_canvas.rs | 46 +++- .../chattyness-user-ui/src/components/chat.rs | 71 +++++- .../src/components/chat_types.rs | 12 + .../src/components/conversation_modal.rs | 173 +++++++++++++ .../src/components/notification_history.rs | 241 ++++++++++++++++++ .../src/components/notifications.rs | 232 +++++++++++++++++ .../src/components/ws_client.rs | 22 ++ crates/chattyness-user-ui/src/pages/realm.rs | 188 ++++++++++++-- 11 files changed, 1135 insertions(+), 44 deletions(-) create mode 100644 crates/chattyness-user-ui/src/components/conversation_modal.rs create mode 100644 crates/chattyness-user-ui/src/components/notification_history.rs create mode 100644 crates/chattyness-user-ui/src/components/notifications.rs diff --git a/crates/chattyness-db/src/ws_messages.rs b/crates/chattyness-db/src/ws_messages.rs index 4a54832..80ad935 100644 --- a/crates/chattyness-db/src/ws_messages.rs +++ b/crates/chattyness-db/src/ws_messages.rs @@ -7,6 +7,12 @@ use uuid::Uuid; use crate::models::{AvatarRenderData, ChannelMemberInfo, ChannelMemberWithAvatar, LooseProp}; +/// Default function for serde that returns true (for is_same_scene field). +/// Must be pub for serde derive macro to access via full path. +pub fn default_is_same_scene() -> bool { + true +} + /// WebSocket configuration sent to client on connect. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WsConfig { @@ -47,10 +53,13 @@ pub enum ClientMessage { /// Ping to keep connection alive. Ping, - /// Send a chat message to the channel. + /// Send a chat message to the channel or directly to a user. SendChatMessage { /// Message content (max 500 chars). content: String, + /// Target display name for direct whisper. None = broadcast to scene. + #[serde(default)] + target_display_name: Option, }, /// Drop a prop from inventory to the canvas. @@ -154,6 +163,16 @@ pub enum ServerMessage { y: f64, /// Server timestamp (milliseconds since epoch). timestamp: i64, + /// Whether this is a whisper (direct message). + /// Default: false (broadcast message). + #[serde(default)] + is_whisper: bool, + /// Whether sender is in the same scene as recipient. + /// For whispers: true = same scene (show as bubble), false = cross-scene (show as notification). + /// For broadcasts: always true. + /// Default: true (same scene). + #[serde(default = "self::default_is_same_scene")] + is_same_scene: bool, }, /// Initial list of loose props when joining channel. diff --git a/crates/chattyness-user-ui/src/api/websocket.rs b/crates/chattyness-user-ui/src/api/websocket.rs index 17b62ba..0f7d558 100644 --- a/crates/chattyness-user-ui/src/api/websocket.rs +++ b/crates/chattyness-user-ui/src/api/websocket.rs @@ -36,10 +36,25 @@ pub struct ChannelState { tx: broadcast::Sender, } +/// Connection info for a connected user. +#[derive(Clone)] +pub struct UserConnection { + /// Direct message sender for this user. + pub direct_tx: mpsc::Sender, + /// Realm the user is in. + pub realm_id: Uuid, + /// Channel (scene) the user is in. + pub channel_id: Uuid, + /// User's display name. + pub display_name: String, +} + /// Global state for all WebSocket connections. pub struct WebSocketState { /// Map of channel_id -> ChannelState. channels: DashMap>, + /// Map of user_id -> UserConnection for direct message routing. + users: DashMap, } impl Default for WebSocketState { @@ -53,6 +68,7 @@ impl WebSocketState { pub fn new() -> Self { Self { channels: DashMap::new(), + users: DashMap::new(), } } @@ -66,6 +82,47 @@ impl WebSocketState { }) .clone() } + + /// Register a user connection for direct messaging. + pub fn register_user( + &self, + user_id: Uuid, + direct_tx: mpsc::Sender, + realm_id: Uuid, + channel_id: Uuid, + display_name: String, + ) { + self.users.insert( + user_id, + UserConnection { + direct_tx, + realm_id, + channel_id, + display_name, + }, + ); + } + + /// Unregister a user connection. + pub fn unregister_user(&self, user_id: Uuid) { + self.users.remove(&user_id); + } + + /// Find a user by display name within a realm. + pub fn find_user_by_display_name(&self, realm_id: Uuid, display_name: &str) -> Option<(Uuid, UserConnection)> { + for entry in self.users.iter() { + let (user_id, conn) = entry.pair(); + if conn.realm_id == realm_id && conn.display_name.eq_ignore_ascii_case(display_name) { + return Some((*user_id, conn.clone())); + } + } + None + } + + /// Get a user's connection info. + pub fn get_user(&self, user_id: Uuid) -> Option { + self.users.get(&user_id).map(|r| r.clone()) + } } /// WebSocket upgrade handler. @@ -260,6 +317,9 @@ async fn handle_socket( } } + // Save member display_name for user registration (before member is moved) + let member_display_name = member.display_name.clone(); + // Broadcast join to others let avatar = avatars::get_avatar_with_paths_conn(&mut *conn, user.id, realm_id) .await @@ -295,14 +355,27 @@ async fn handle_socket( // and pool for cleanup (leave_channel needs user_id match anyway) drop(conn); - // Channel for sending direct messages (Pong) to client + // Channel for sending direct messages (Pong, whispers) to client let (direct_tx, mut direct_rx) = mpsc::channel::(16); + // Register user for direct message routing + ws_state.register_user( + user_id, + direct_tx.clone(), + realm_id, + channel_id, + member_display_name, + ); + + // Clone ws_state for use in recv_task + let ws_state_for_recv = ws_state.clone(); + // Create recv timeout from config let recv_timeout = Duration::from_secs(ws_config.recv_timeout_secs); // Spawn task to handle incoming messages from client let recv_task = tokio::spawn(async move { + let ws_state = ws_state_for_recv; let mut disconnect_reason = DisconnectReason::Graceful; loop { @@ -375,7 +448,7 @@ async fn handle_socket( // Respond with pong directly (not broadcast) let _ = direct_tx.send(ServerMessage::Pong).await; } - ClientMessage::SendChatMessage { content } => { + ClientMessage::SendChatMessage { content, target_display_name } => { // Validate message if content.is_empty() || content.len() > 500 { continue; @@ -395,18 +468,81 @@ async fn handle_socket( let emotion_name = EmotionState::from_index(member.current_emotion as u8) .map(|e| e.to_string()) .unwrap_or_else(|| "neutral".to_string()); - let msg = ServerMessage::ChatMessageReceived { - message_id: Uuid::new_v4(), - user_id: Some(user_id), - guest_session_id: None, - display_name: member.display_name.clone(), - content, - emotion: emotion_name, - x: member.position_x, - y: member.position_y, - timestamp: chrono::Utc::now().timestamp_millis(), - }; - let _ = tx.send(msg); + + // Handle whisper (direct message) vs broadcast + if let Some(target_name) = target_display_name { + // Whisper: send directly to target user + if let Some((_target_user_id, target_conn)) = + ws_state.find_user_by_display_name(realm_id, &target_name) + { + // Determine if same scene + let is_same_scene = target_conn.channel_id == channel_id; + + let msg = ServerMessage::ChatMessageReceived { + message_id: Uuid::new_v4(), + user_id: Some(user_id), + guest_session_id: None, + display_name: member.display_name.clone(), + content: content.clone(), + emotion: emotion_name.clone(), + x: member.position_x, + y: member.position_y, + timestamp: chrono::Utc::now().timestamp_millis(), + is_whisper: true, + is_same_scene, + }; + + // Send to target user + let _ = target_conn.direct_tx.send(msg.clone()).await; + + // Also send back to sender (so they see their own whisper) + // For sender, is_same_scene is always true (they see it as a bubble) + let sender_msg = ServerMessage::ChatMessageReceived { + message_id: Uuid::new_v4(), + user_id: Some(user_id), + guest_session_id: None, + display_name: member.display_name.clone(), + content, + emotion: emotion_name, + x: member.position_x, + y: member.position_y, + timestamp: chrono::Utc::now().timestamp_millis(), + is_whisper: true, + is_same_scene: true, // Sender always sees as bubble + }; + let _ = direct_tx.send(sender_msg).await; + + #[cfg(debug_assertions)] + tracing::debug!( + "[WS] Whisper from {} to {} (same_scene={})", + member.display_name, + target_name, + is_same_scene + ); + } else { + // Target user not found - send error + let _ = direct_tx.send(ServerMessage::Error { + code: "WHISPER_TARGET_NOT_FOUND".to_string(), + message: format!("User '{}' is not online or not in this realm", target_name), + }).await; + } + } else { + // Broadcast: send to all users in the channel + let msg = ServerMessage::ChatMessageReceived { + message_id: Uuid::new_v4(), + user_id: Some(user_id), + guest_session_id: None, + display_name: member.display_name.clone(), + content, + emotion: emotion_name, + x: member.position_x, + y: member.position_y, + timestamp: chrono::Utc::now().timestamp_millis(), + is_whisper: false, + is_same_scene: true, + }; + let _ = tx.send(msg); + } } } ClientMessage::DropProp { inventory_item_id } => { @@ -616,6 +752,9 @@ async fn handle_socket( } }; + // Unregister user from direct message routing + ws_state.unregister_user(user_id); + tracing::info!( "[WS] User {} disconnected from channel {} (reason: {:?})", user_id, diff --git a/crates/chattyness-user-ui/src/components.rs b/crates/chattyness-user-ui/src/components.rs index 093f10d..5c365db 100644 --- a/crates/chattyness-user-ui/src/components.rs +++ b/crates/chattyness-user-ui/src/components.rs @@ -5,6 +5,7 @@ pub mod avatar_editor; pub mod chat; pub mod chat_types; pub mod context_menu; +pub mod conversation_modal; pub mod editor; pub mod emotion_picker; pub mod forms; @@ -13,6 +14,8 @@ pub mod keybindings; pub mod keybindings_popup; pub mod layout; pub mod modals; +pub mod notification_history; +pub mod notifications; pub mod scene_viewer; pub mod settings; pub mod settings_popup; @@ -24,6 +27,7 @@ pub use avatar_editor::*; pub use chat::*; pub use chat_types::*; pub use context_menu::*; +pub use conversation_modal::*; pub use editor::*; pub use emotion_picker::*; pub use forms::*; @@ -32,6 +36,8 @@ pub use keybindings::*; pub use keybindings_popup::*; pub use layout::*; pub use modals::*; +pub use notification_history::*; +pub use notifications::*; pub use scene_viewer::*; pub use settings::*; pub use settings_popup::*; diff --git a/crates/chattyness-user-ui/src/components/avatar_canvas.rs b/crates/chattyness-user-ui/src/components/avatar_canvas.rs index 27e6b70..ab5a0a9 100644 --- a/crates/chattyness-user-ui/src/components/avatar_canvas.rs +++ b/crates/chattyness-user-ui/src/components/avatar_canvas.rs @@ -81,6 +81,11 @@ impl ContentBounds { fn empty_bottom_rows(&self) -> usize { 2 - self.max_row } + + /// Number of empty rows at the top (for bubble positioning). + fn empty_top_rows(&self) -> usize { + self.min_row + } } /// Get a unique key for a member (for Leptos For keying). @@ -386,7 +391,17 @@ pub fn AvatarCanvas( if let Some(ref b) = bubble { let current_time = js_sys::Date::now() as i64; if b.expires_at >= current_time { - draw_bubble(&ctx, b, avatar_cx, avatar_cy - avatar_size / 2.0, avatar_size, te); + let content_x_offset = content_bounds.x_offset(cell_size); + let content_top_adjustment = content_bounds.empty_top_rows() as f64 * cell_size; + draw_bubble( + &ctx, + b, + avatar_cx, + avatar_cy - avatar_size / 2.0, + content_x_offset, + content_top_adjustment, + te, + ); } } }); @@ -426,7 +441,8 @@ fn draw_bubble( bubble: &ActiveBubble, center_x: f64, top_y: f64, - _prop_size: f64, + content_x_offset: f64, + content_top_adjustment: f64, text_em_size: f64, ) { // Text scale independent of zoom - only affected by user's text_em_size setting @@ -440,8 +456,11 @@ fn draw_bubble( let (bg_color, border_color, text_color) = emotion_bubble_colors(&bubble.message.emotion); + // Use italic font for whispers + let font_style = if bubble.message.is_whisper { "italic " } else { "" }; + // Measure and wrap text - ctx.set_font(&format!("{}px sans-serif", font_size)); + ctx.set_font(&format!("{}{}px sans-serif", font_style, font_size)); let lines = wrap_text(ctx, &bubble.message.content, max_bubble_width - padding * 2.0); // Calculate bubble dimensions @@ -453,9 +472,13 @@ fn draw_bubble( let bubble_width = bubble_width.max(60.0 * text_scale); let bubble_height = (lines.len() as f64) * line_height + padding * 2.0; - // Position bubble above avatar - let bubble_x = center_x - bubble_width / 2.0; - let bubble_y = top_y - bubble_height - tail_size - 5.0 * text_scale; + // Center bubble horizontally on content (not grid center) + let content_center_x = center_x + content_x_offset; + let bubble_x = content_center_x - bubble_width / 2.0; + + // Position vertically closer to content when top rows are empty + let adjusted_top_y = top_y + content_top_adjustment; + let bubble_y = adjusted_top_y - bubble_height - tail_size - 5.0 * text_scale; // Draw bubble background draw_rounded_rect(ctx, bubble_x, bubble_y, bubble_width, bubble_height, border_radius); @@ -465,18 +488,19 @@ fn draw_bubble( ctx.set_line_width(2.0); ctx.stroke(); - // Draw tail + // Draw tail pointing to content center ctx.begin_path(); - ctx.move_to(center_x - tail_size, bubble_y + bubble_height); - ctx.line_to(center_x, bubble_y + bubble_height + tail_size); - ctx.line_to(center_x + tail_size, bubble_y + bubble_height); + ctx.move_to(content_center_x - tail_size, bubble_y + bubble_height); + ctx.line_to(content_center_x, bubble_y + bubble_height + tail_size); + ctx.line_to(content_center_x + tail_size, bubble_y + bubble_height); ctx.close_path(); ctx.set_fill_style_str(bg_color); ctx.fill(); ctx.set_stroke_style_str(border_color); ctx.stroke(); - // Draw text + // Draw text (re-set font in case canvas state changed) + ctx.set_font(&format!("{}{}px sans-serif", font_style, font_size)); ctx.set_fill_style_str(text_color); ctx.set_text_align("left"); ctx.set_text_baseline("top"); diff --git a/crates/chattyness-user-ui/src/components/chat.rs b/crates/chattyness-user-ui/src/components/chat.rs index a5a5be6..e93ecca 100644 --- a/crates/chattyness-user-ui/src/components/chat.rs +++ b/crates/chattyness-user-ui/src/components/chat.rs @@ -44,6 +44,34 @@ fn parse_emote_command(cmd: &str) -> Option { }) } +/// Parse a whisper command and return (target_name, message) if valid. +/// +/// Supports `/w name message` and `/whisper name message`. +#[cfg(feature = "hydrate")] +fn parse_whisper_command(cmd: &str) -> Option<(String, String)> { + let cmd = cmd.trim(); + + // Strip the leading slash if present + let cmd = cmd.strip_prefix('/').unwrap_or(cmd); + + // Check for `w ` or `whisper ` + let rest = cmd + .strip_prefix("whisper ") + .or_else(|| cmd.strip_prefix("w ")) + .map(str::trim)?; + + // Find the first space to separate name from message + let space_idx = rest.find(' ')?; + let name = rest[..space_idx].trim().to_string(); + let message = rest[space_idx + 1..].trim().to_string(); + + if name.is_empty() || message.is_empty() { + return None; + } + + Some((name, message)) +} + /// Chat input component with emote command support. /// /// Props: @@ -205,12 +233,16 @@ pub fn ChatInput( let cmd = value[1..].to_lowercase(); // Show hint for slash commands (don't execute until Enter) + // Match: /s[etting], /i[nventory], /w[hisper], or their full forms with args if cmd.is_empty() || "setting".starts_with(&cmd) || "inventory".starts_with(&cmd) + || "whisper".starts_with(&cmd) || cmd == "setting" || cmd == "settings" || cmd == "inventory" + || cmd.starts_with("w ") + || cmd.starts_with("whisper ") { set_command_mode.set(CommandMode::ShowingSlashHint); } else { @@ -317,9 +349,10 @@ pub fn ChatInput( if key == "Enter" { let msg = message.get(); - // Handle slash commands - NEVER send as message + // Handle slash commands if msg.starts_with('/') { let cmd = msg[1..].to_lowercase(); + // /s, /se, /set, /sett, /setti, /settin, /setting, /settings if !cmd.is_empty() && ("setting".starts_with(&cmd) || cmd == "settings") { if let Some(ref callback) = on_open_settings { @@ -331,9 +364,12 @@ pub fn ChatInput( input.set_value(""); let _ = input.blur(); } + ev.prevent_default(); + return; } + // /i, /in, /inv, /inve, /inven, /invent, /invento, /inventor, /inventory - else if !cmd.is_empty() && "inventory".starts_with(&cmd) { + if !cmd.is_empty() && "inventory".starts_with(&cmd) { if let Some(ref callback) = on_open_inventory { callback.run(()); } @@ -343,7 +379,31 @@ pub fn ChatInput( input.set_value(""); let _ = input.blur(); } + ev.prevent_default(); + return; } + + // /w NAME message or /whisper NAME message + if let Some((target_name, whisper_content)) = parse_whisper_command(&msg) { + if !whisper_content.trim().is_empty() { + ws_sender.with_value(|sender| { + if let Some(send_fn) = sender { + send_fn(ClientMessage::SendChatMessage { + content: whisper_content.trim().to_string(), + target_display_name: Some(target_name), + }); + } + }); + set_message.set(String::new()); + set_command_mode.set(CommandMode::None); + if let Some(input) = input_ref.get() { + input.set_value(""); + } + } + ev.prevent_default(); + return; + } + // Invalid slash command - just ignore, don't send ev.prevent_default(); return; @@ -380,6 +440,7 @@ pub fn ChatInput( if let Some(send_fn) = sender { send_fn(ClientMessage::SendChatMessage { content: msg.trim().to_string(), + target_display_name: None, // Broadcast to scene }); } }); @@ -440,7 +501,7 @@ pub fn ChatInput( - // Slash command hint bar (/s[etting], /i[nventory]) + // Slash command hint bar (/s[etting], /i[nventory], /w[hisper])
"/" @@ -450,6 +511,10 @@ pub fn ChatInput( "/" "i" "[nventory]" + "|" + "/" + "w" + "[hisper] name"
diff --git a/crates/chattyness-user-ui/src/components/chat_types.rs b/crates/chattyness-user-ui/src/components/chat_types.rs index c91c1a7..647d1ee 100644 --- a/crates/chattyness-user-ui/src/components/chat_types.rs +++ b/crates/chattyness-user-ui/src/components/chat_types.rs @@ -24,6 +24,18 @@ pub struct ChatMessage { pub y: f64, /// Timestamp in milliseconds since epoch. pub timestamp: i64, + /// Whether this is a whisper (direct message). + #[serde(default)] + pub is_whisper: bool, + /// Whether sender is in the same scene as recipient. + /// For whispers: true = same scene (show as bubble), false = cross-scene (show as notification). + #[serde(default = "default_true")] + pub is_same_scene: bool, +} + +/// Default function for serde that returns true. +fn default_true() -> bool { + true } /// Message log with bounded capacity for future replay support. diff --git a/crates/chattyness-user-ui/src/components/conversation_modal.rs b/crates/chattyness-user-ui/src/components/conversation_modal.rs new file mode 100644 index 0000000..465e831 --- /dev/null +++ b/crates/chattyness-user-ui/src/components/conversation_modal.rs @@ -0,0 +1,173 @@ +//! Conversation modal for viewing whisper history with a specific user. + +use leptos::prelude::*; +use leptos::reactive::owner::LocalStorage; +#[cfg(feature = "hydrate")] +use wasm_bindgen::JsCast; + +use chattyness_db::ws_messages::ClientMessage; + +use super::chat_types::ChatMessage; +use super::modals::Modal; +use super::ws_client::WsSender; + +/// Conversation modal component for viewing/replying to whispers. +#[component] +pub fn ConversationModal( + #[prop(into)] open: Signal, + on_close: Callback<()>, + /// The display name of the conversation partner. + #[prop(into)] + partner_name: Signal, + /// All whisper messages involving this partner. + #[prop(into)] + messages: Signal>, + /// Current user's display name for determining message direction. + #[prop(into)] + current_user_name: Signal, + /// WebSocket sender for sending replies. + ws_sender: StoredValue, LocalStorage>, +) -> impl IntoView { + let (reply_text, set_reply_text) = signal(String::new()); + let input_ref = NodeRef::::new(); + + // Focus input when modal opens + #[cfg(feature = "hydrate")] + { + Effect::new(move |_| { + if open.get() { + // Small delay to ensure modal is rendered + let input_ref = input_ref; + let closure = wasm_bindgen::closure::Closure::once(Box::new(move || { + if let Some(input) = input_ref.get() { + let _ = input.focus(); + } + }) as Box); + + if let Some(window) = web_sys::window() { + let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0( + closure.as_ref().unchecked_ref(), + 100, + ); + } + closure.forget(); + } + }); + } + + let send_reply = { + let on_close = on_close.clone(); + move || { + let text = reply_text.get(); + let target = partner_name.get(); + if !text.trim().is_empty() && !target.is_empty() { + ws_sender.with_value(|sender| { + if let Some(send_fn) = sender { + send_fn(ClientMessage::SendChatMessage { + content: text.trim().to_string(), + target_display_name: Some(target.clone()), + }); + } + }); + set_reply_text.set(String::new()); + on_close.run(()); + } + } + }; + + #[cfg(feature = "hydrate")] + let on_keydown = { + let send_reply = send_reply.clone(); + move |ev: web_sys::KeyboardEvent| { + if ev.key() == "Enter" && !ev.shift_key() { + ev.prevent_default(); + send_reply(); + } + } + }; + + #[cfg(not(feature = "hydrate"))] + let on_keydown = move |_ev| {}; + + view! { + + // Partner name header +
+

+ "with " {move || partner_name.get()} +

+
+ + // Messages list +
+ + "No messages yet" +

+ } + > + +
+ + {sender.to_string()} ": " + + + {msg.content.clone()} + +
+
+ } + } + /> + + + + // Reply input +
+
+ + +
+

+ "Press " "Enter" " to send, " + "Esc" " to close" +

+
+
+ } +} diff --git a/crates/chattyness-user-ui/src/components/notification_history.rs b/crates/chattyness-user-ui/src/components/notification_history.rs new file mode 100644 index 0000000..f1c9fdf --- /dev/null +++ b/crates/chattyness-user-ui/src/components/notification_history.rs @@ -0,0 +1,241 @@ +//! 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 new file mode 100644 index 0000000..f7f0287 --- /dev/null +++ b/crates/chattyness-user-ui/src/components/notifications.rs @@ -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>, + /// 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 user wants to see history (press 'h'). + on_history: 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>>> = + 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::::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! { + + {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" + + + "h" + " history" + +
+
+
+ }.into_any() + } else { + ().into_any() + } + }} +
+ } +} diff --git a/crates/chattyness-user-ui/src/components/ws_client.rs b/crates/chattyness-user-ui/src/components/ws_client.rs index ddfed3d..da6f174 100644 --- a/crates/chattyness-user-ui/src/components/ws_client.rs +++ b/crates/chattyness-user-ui/src/components/ws_client.rs @@ -62,6 +62,15 @@ pub struct ChannelMemberInfo { pub display_name: String, } +/// WebSocket error info for UI display. +#[derive(Clone, Debug)] +pub struct WsError { + /// Error code from server. + pub code: String, + /// Human-readable error message. + pub message: String, +} + /// Hook to manage WebSocket connection for a channel. /// /// Returns a tuple of: @@ -78,6 +87,7 @@ pub fn use_channel_websocket( on_prop_picked_up: Callback, on_member_fading: Callback, on_welcome: Option>, + on_error: Option>, ) -> (Signal, WsSenderStorage) { use std::cell::RefCell; use std::rc::Rc; @@ -175,6 +185,7 @@ pub fn use_channel_websocket( let on_prop_picked_up_clone = on_prop_picked_up.clone(); let on_member_fading_clone = on_member_fading.clone(); let on_welcome_clone = on_welcome.clone(); + let on_error_clone = on_error.clone(); // For starting heartbeat on Welcome let ws_ref_for_heartbeat = ws_ref.clone(); let heartbeat_started: Rc> = Rc::new(RefCell::new(false)); @@ -226,6 +237,7 @@ pub fn use_channel_websocket( &on_prop_dropped_clone, &on_prop_picked_up_clone, &on_member_fading_clone, + &on_error_clone, ); } } @@ -272,6 +284,7 @@ fn handle_server_message( on_prop_dropped: &Callback, on_prop_picked_up: &Callback, on_member_fading: &Callback, + on_error: &Option>, ) { let mut members_vec = members.borrow_mut(); @@ -360,6 +373,10 @@ fn handle_server_message( ServerMessage::Error { code, message } => { // Always log errors to console (not just debug mode) web_sys::console::error_1(&format!("[WS] Server error: {} - {}", code, message).into()); + // Call error callback if provided + if let Some(callback) = on_error { + callback.run(WsError { code, message }); + } } ServerMessage::ChatMessageReceived { message_id, @@ -371,6 +388,8 @@ fn handle_server_message( x, y, timestamp, + is_whisper, + is_same_scene, } => { let chat_msg = ChatMessage { message_id, @@ -382,6 +401,8 @@ fn handle_server_message( x, y, timestamp, + is_whisper, + is_same_scene, }; on_chat_message.run(chat_msg); } @@ -429,6 +450,7 @@ pub fn use_channel_websocket( _on_prop_picked_up: Callback, _on_member_fading: Callback, _on_welcome: Option>, + _on_error: Option>, ) -> (Signal, WsSenderStorage) { let (ws_state, _) = signal(WsState::Disconnected); let sender: WsSenderStorage = StoredValue::new_local(None); diff --git a/crates/chattyness-user-ui/src/pages/realm.rs b/crates/chattyness-user-ui/src/pages/realm.rs index 6656c58..5789e54 100644 --- a/crates/chattyness-user-ui/src/pages/realm.rs +++ b/crates/chattyness-user-ui/src/pages/realm.rs @@ -12,12 +12,16 @@ use leptos_router::hooks::use_params_map; use uuid::Uuid; use crate::components::{ - ActiveBubble, AvatarEditorPopup, Card, ChatInput, EmotionKeybindings, FadingMember, - InventoryPopup, KeybindingsPopup, MessageLog, RealmHeader, RealmSceneViewer, SettingsPopup, + ActiveBubble, AvatarEditorPopup, Card, ChatInput, ConversationModal, EmotionKeybindings, + FadingMember, InventoryPopup, KeybindingsPopup, MessageLog, NotificationHistoryModal, + NotificationMessage, NotificationToast, RealmHeader, RealmSceneViewer, SettingsPopup, ViewerSettings, }; #[cfg(feature = "hydrate")] -use crate::components::{use_channel_websocket, ChannelMemberInfo, ChatMessage, DEFAULT_BUBBLE_TIMEOUT_MS, FADE_DURATION_MS}; +use crate::components::{ + add_to_history, use_channel_websocket, ChannelMemberInfo, ChatMessage, + DEFAULT_BUBBLE_TIMEOUT_MS, FADE_DURATION_MS, HistoryEntry, WsError, +}; use crate::utils::LocalStoragePersist; #[cfg(feature = "hydrate")] use crate::utils::parse_bounds_dimensions; @@ -97,6 +101,21 @@ pub fn RealmPage() -> impl IntoView { // Whisper target - when set, triggers pre-fill in ChatInput let (whisper_target, set_whisper_target) = signal(Option::::None); + // 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) + #[cfg(feature = "hydrate")] + let whisper_messages: StoredValue, LocalStorage> = + StoredValue::new_local(Vec::new()); + // Current user's display name (for conversation modal) + let (current_display_name, set_current_display_name) = signal(String::new()); + + // Error notification state (for whisper failures, etc.) + let (error_message, set_error_message) = signal(Option::::None); + let realm_data = LocalResource::new(move || { let slug = slug.get(); async move { @@ -203,19 +222,51 @@ pub fn RealmPage() -> impl IntoView { // Add to message log message_log.update_value(|log| log.push(msg.clone())); - // Update active bubbles - let key = (msg.user_id, msg.guest_session_id); - let expires_at = msg.timestamp + DEFAULT_BUBBLE_TIMEOUT_MS; + // Handle whispers + if msg.is_whisper { + // Track whisper for conversation view + whisper_messages.update_value(|msgs| { + msgs.push(msg.clone()); + // Keep last 100 whisper messages + if msgs.len() > 100 { + msgs.remove(0); + } + }); - set_active_bubbles.update(|bubbles| { - bubbles.insert( - key, - ActiveBubble { - message: msg, - expires_at, - }, - ); - }); + // Add to notification history for persistence + add_to_history(HistoryEntry::from_whisper(&msg)); + + if msg.is_same_scene { + // Same scene whisper: show as italic bubble (handled by bubble rendering) + let key = (msg.user_id, msg.guest_session_id); + let expires_at = msg.timestamp + DEFAULT_BUBBLE_TIMEOUT_MS; + set_active_bubbles.update(|bubbles| { + bubbles.insert( + key, + ActiveBubble { + message: msg, + expires_at, + }, + ); + }); + } else { + // Cross-scene whisper: show as notification toast + set_current_notification.set(Some(NotificationMessage::from_chat_message(msg))); + } + } else { + // Regular broadcast: show as bubble + let key = (msg.user_id, msg.guest_session_id); + let expires_at = msg.timestamp + DEFAULT_BUBBLE_TIMEOUT_MS; + set_active_bubbles.update(|bubbles| { + bubbles.insert( + key, + ActiveBubble { + message: msg, + expires_at, + }, + ); + }); + } }); // Loose props callbacks @@ -256,6 +307,24 @@ pub fn RealmPage() -> impl IntoView { let on_welcome = Callback::new(move |info: ChannelMemberInfo| { set_current_user_id.set(info.user_id); set_current_guest_session_id.set(info.guest_session_id); + set_current_display_name.set(info.display_name.clone()); + }); + + // Callback for WebSocket errors (whisper failures, etc.) + #[cfg(feature = "hydrate")] + let on_ws_error = Callback::new(move |error: WsError| { + // Display user-friendly error message + let msg = match error.code.as_str() { + "WHISPER_TARGET_NOT_FOUND" => error.message, + _ => format!("Error: {}", error.message), + }; + set_error_message.set(Some(msg)); + // Auto-dismiss after 5 seconds + use gloo_timers::callback::Timeout; + Timeout::new(5000, move || { + set_error_message.set(None); + }) + .forget(); }); #[cfg(feature = "hydrate")] @@ -269,6 +338,7 @@ pub fn RealmPage() -> impl IntoView { on_prop_picked_up, on_member_fading, Some(on_welcome), + Some(on_ws_error), ); // Set channel ID and scene dimensions when scene loads @@ -791,6 +861,94 @@ pub fn RealmPage() -> impl IntoView { /> } } + + // Notification toast for cross-scene whispers + + + // Error toast (whisper failures, etc.) + + {move || { + if let Some(msg) = error_message.get() { + view! { +
+
+ "âš " + {msg} + +
+
+ }.into_any() + } else { + ().into_any() + } + }} +
+ + // Notification history modal + + + // Conversation modal + { + #[cfg(feature = "hydrate")] + let ws_sender_for_convo = ws_sender.clone(); + #[cfg(not(feature = "hydrate"))] + let ws_sender_for_convo: StoredValue, LocalStorage> = StoredValue::new_local(None); + #[cfg(feature = "hydrate")] + let filtered_whispers = Signal::derive(move || { + let partner = conversation_partner.get(); + whisper_messages.with_value(|msgs| { + msgs.iter() + .filter(|m| { + m.display_name == partner || + (m.is_whisper && m.display_name == current_display_name.get()) + }) + .cloned() + .collect::>() + }) + }); + #[cfg(not(feature = "hydrate"))] + let filtered_whispers: Signal> = Signal::derive(|| Vec::new()); + view! { + + } + } } .into_any() }