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

@ -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<String>,
},
/// 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.

View file

@ -36,10 +36,25 @@ pub struct ChannelState {
tx: broadcast::Sender<ServerMessage>,
}
/// Connection info for a connected user.
#[derive(Clone)]
pub struct UserConnection {
/// Direct message sender for this user.
pub direct_tx: mpsc::Sender<ServerMessage>,
/// 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<Uuid, Arc<ChannelState>>,
/// Map of user_id -> UserConnection for direct message routing.
users: DashMap<Uuid, UserConnection>,
}
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<ServerMessage>,
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<UserConnection> {
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::<ServerMessage>(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,6 +468,66 @@ 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());
// 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),
@ -405,10 +538,13 @@ async fn handle_socket(
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 } => {
// Get user's current position for random offset
let member_info = channel_members::get_channel_member(
@ -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,

View file

@ -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::*;

View file

@ -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");

View file

@ -44,6 +44,34 @@ fn parse_emote_command(cmd: &str) -> Option<String> {
})
}
/// 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 <name> <message>` or `whisper <name> <message>`
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(
</div>
</Show>
// Slash command hint bar (/s[etting], /i[nventory])
// Slash command hint bar (/s[etting], /i[nventory], /w[hisper])
<Show when=move || command_mode.get() == CommandMode::ShowingSlashHint>
<div class="absolute bottom-full left-0 mb-1 px-3 py-1 bg-gray-700/90 backdrop-blur-sm rounded text-sm">
<span class="text-gray-400">"/"</span>
@ -450,6 +511,10 @@ pub fn ChatInput(
<span class="text-gray-400">"/"</span>
<span class="text-blue-400">"i"</span>
<span class="text-gray-500">"[nventory]"</span>
<span class="text-gray-600 mx-2">"|"</span>
<span class="text-gray-400">"/"</span>
<span class="text-blue-400">"w"</span>
<span class="text-gray-500">"[hisper] name"</span>
</div>
</Show>

View file

@ -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.

View file

@ -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<bool>,
on_close: Callback<()>,
/// The display name of the conversation partner.
#[prop(into)]
partner_name: Signal<String>,
/// All whisper messages involving this partner.
#[prop(into)]
messages: Signal<Vec<ChatMessage>>,
/// Current user's display name for determining message direction.
#[prop(into)]
current_user_name: Signal<String>,
/// WebSocket sender for sending replies.
ws_sender: StoredValue<Option<WsSender>, LocalStorage>,
) -> impl IntoView {
let (reply_text, set_reply_text) = signal(String::new());
let input_ref = NodeRef::<leptos::html::Input>::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<dyn FnOnce()>);
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! {
<Modal
open=open
on_close=on_close.clone()
title="Conversation"
title_id="conversation-modal-title"
max_width="max-w-lg"
>
// Partner name header
<div class="mb-4 pb-2 border-b border-gray-600">
<h3 class="text-lg text-purple-300">
"with " {move || partner_name.get()}
</h3>
</div>
// Messages list
<div class="max-h-64 overflow-y-auto mb-4 space-y-2">
<Show
when=move || !messages.get().is_empty()
fallback=|| view! {
<p class="text-gray-400 text-center py-4">
"No messages yet"
</p>
}
>
<For
each=move || messages.get()
key=|msg| msg.message_id
children=move |msg: ChatMessage| {
let is_from_me = msg.display_name == current_user_name.get();
let align_class = if is_from_me { "text-right" } else { "text-left" };
let bubble_class = if is_from_me {
"bg-purple-600/50 ml-8"
} else {
"bg-gray-700 mr-8"
};
let sender = if is_from_me { "you" } else { &msg.display_name };
view! {
<div class=align_class>
<div class=format!("inline-block p-2 rounded-lg {}", bubble_class)>
<span class="text-xs text-gray-400">
{sender.to_string()} ": "
</span>
<span class="text-gray-200 italic">
{msg.content.clone()}
</span>
</div>
</div>
}
}
/>
</Show>
</div>
// Reply input
<div class="border-t border-gray-600 pt-4">
<div class="flex gap-2">
<input
type="text"
node_ref=input_ref
class="flex-1 bg-gray-700 text-white rounded px-3 py-2 outline-none focus:ring-2 focus:ring-purple-500"
placeholder=move || format!("/whisper {} ", partner_name.get())
prop:value=move || reply_text.get()
on:input=move |ev| set_reply_text.set(event_target_value(&ev))
on:keydown=on_keydown
/>
<button
class="px-4 py-2 bg-purple-600 hover:bg-purple-500 rounded text-white"
on:click=move |_| send_reply()
>
"Send"
</button>
</div>
<p class="text-xs text-gray-500 mt-2">
"Press " <kbd class="px-1 py-0.5 bg-gray-700 rounded">"Enter"</kbd> " to send, "
<kbd class="px-1 py-0.5 bg-gray-700 rounded">"Esc"</kbd> " to close"
</p>
</div>
</Modal>
}
}

View file

@ -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<HistoryEntry> {
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<HistoryEntry> {
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<bool>,
on_close: Callback<()>,
/// Callback when user wants to reply to a message.
on_reply: Callback<String>,
/// Callback when user wants to see conversation context.
on_context: Callback<String>,
) -> impl IntoView {
// Load history when modal opens
let history = Signal::derive(move || {
if open.get() {
load_history()
} else {
Vec::new()
}
});
view! {
<Modal
open=open
on_close=on_close.clone()
title="Notification History"
title_id="notification-history-title"
max_width="max-w-2xl"
>
<div class="max-h-96 overflow-y-auto">
<Show
when=move || !history.get().is_empty()
fallback=|| view! {
<p class="text-gray-400 text-center py-8">
"No notifications yet"
</p>
}
>
<ul class="space-y-2">
<For
each=move || history.get()
key=|entry| entry.id
children={
let on_reply = on_reply.clone();
let on_context = on_context.clone();
let on_close = on_close.clone();
move |entry: HistoryEntry| {
let sender_name = entry.sender_name.clone();
let sender_for_reply = sender_name.clone();
let sender_for_context = sender_name.clone();
let on_reply = on_reply.clone();
let on_context = on_context.clone();
let on_close = on_close.clone();
let on_close2 = on_close.clone();
view! {
<li class="p-3 bg-gray-700/50 rounded-lg border border-gray-600">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<span class="text-purple-300 font-medium">
{sender_name}
</span>
<span class="text-xs text-gray-500">
{format_timestamp(entry.timestamp)}
</span>
<Show when=move || entry.is_whisper>
<span class="text-xs bg-purple-500/30 text-purple-300 px-1.5 py-0.5 rounded">
"whisper"
</span>
</Show>
</div>
<p class="text-gray-300 text-sm">
{entry.content.clone()}
</p>
</div>
<div class="flex gap-1 ml-2">
<button
class="px-2 py-1 text-xs bg-gray-600 hover:bg-gray-500 rounded text-gray-300"
title="Reply"
on:click={
let on_reply = on_reply.clone();
let sender = sender_for_reply.clone();
let on_close = on_close.clone();
move |_| {
on_reply.run(sender.clone());
on_close.run(());
}
}
>
"Reply"
</button>
<button
class="px-2 py-1 text-xs bg-gray-600 hover:bg-gray-500 rounded text-gray-300"
title="View conversation"
on:click={
let on_context = on_context.clone();
let sender = sender_for_context.clone();
let on_close = on_close2.clone();
move |_| {
on_context.run(sender.clone());
on_close.run(());
}
}
>
"Context"
</button>
</div>
</div>
</li>
}
}
}
/>
</ul>
</Show>
</div>
// Footer hint
<div class="mt-4 pt-4 border-t border-gray-600 text-xs text-gray-500 text-center">
"Press " <kbd class="px-1.5 py-0.5 bg-gray-700 rounded">"Esc"</kbd> " to close"
</div>
</Modal>
}
}
/// 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()
}
}

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>
}
}

View file

@ -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<uuid::Uuid>,
on_member_fading: Callback<FadingMember>,
on_welcome: Option<Callback<ChannelMemberInfo>>,
on_error: Option<Callback<WsError>>,
) -> (Signal<WsState>, 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<RefCell<bool>> = 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<LooseProp>,
on_prop_picked_up: &Callback<uuid::Uuid>,
on_member_fading: &Callback<FadingMember>,
on_error: &Option<Callback<WsError>>,
) {
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<uuid::Uuid>,
_on_member_fading: Callback<FadingMember>,
_on_welcome: Option<Callback<ChannelMemberInfo>>,
_on_error: Option<Callback<WsError>>,
) -> (Signal<WsState>, WsSenderStorage) {
let (ws_state, _) = signal(WsState::Disconnected);
let sender: WsSenderStorage = StoredValue::new_local(None);

View file

@ -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::<String>::None);
// Notification state for cross-scene whispers
let (current_notification, set_current_notification) = signal(Option::<NotificationMessage>::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<Vec<ChatMessage>, 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::<String>::None);
let realm_data = LocalResource::new(move || {
let slug = slug.get();
async move {
@ -203,10 +222,24 @@ pub fn RealmPage() -> impl IntoView {
// Add to message log
message_log.update_value(|log| log.push(msg.clone()));
// Update active bubbles
// 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);
}
});
// 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,
@ -216,6 +249,24 @@ pub fn RealmPage() -> impl IntoView {
},
);
});
} 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
<NotificationToast
notification=Signal::derive(move || current_notification.get())
on_reply=Callback::new(move |name: String| {
set_whisper_target.set(Some(name));
})
on_context=Callback::new(move |name: String| {
set_conversation_partner.set(name);
set_conversation_modal_open.set(true);
})
on_history=Callback::new(move |_: ()| {
set_history_modal_open.set(true);
})
on_dismiss=Callback::new(move |_: Uuid| {
set_current_notification.set(None);
})
/>
// Error toast (whisper failures, etc.)
<Show when=move || error_message.get().is_some()>
{move || {
if let Some(msg) = error_message.get() {
view! {
<div class="fixed top-4 left-1/2 -translate-x-1/2 z-50 animate-slide-in-down">
<div class="bg-red-900/90 border border-red-500/50 rounded-lg shadow-lg px-6 py-3 flex items-center gap-3">
<span class="text-red-300 text-lg">""</span>
<span class="text-gray-200">{msg}</span>
<button
class="text-gray-400 hover:text-white ml-2"
on:click=move |_| set_error_message.set(None)
>
"×"
</button>
</div>
</div>
}.into_any()
} else {
().into_any()
}
}}
</Show>
// Notification history modal
<NotificationHistoryModal
open=Signal::derive(move || history_modal_open.get())
on_close=Callback::new(move |_: ()| set_history_modal_open.set(false))
on_reply=Callback::new(move |name: String| {
set_whisper_target.set(Some(name));
})
on_context=Callback::new(move |name: String| {
set_conversation_partner.set(name);
set_conversation_modal_open.set(true);
})
/>
// Conversation modal
{
#[cfg(feature = "hydrate")]
let ws_sender_for_convo = ws_sender.clone();
#[cfg(not(feature = "hydrate"))]
let ws_sender_for_convo: StoredValue<Option<WsSender>, 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::<Vec<_>>()
})
});
#[cfg(not(feature = "hydrate"))]
let filtered_whispers: Signal<Vec<crate::components::ChatMessage>> = Signal::derive(|| Vec::new());
view! {
<ConversationModal
open=Signal::derive(move || conversation_modal_open.get())
on_close=Callback::new(move |_: ()| set_conversation_modal_open.set(false))
partner_name=Signal::derive(move || conversation_partner.get())
messages=filtered_whispers
current_user_name=Signal::derive(move || current_display_name.get())
ws_sender=ws_sender_for_convo
/>
}
}
}
.into_any()
}