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

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