feat: private messages.
This commit is contained in:
parent
0492043625
commit
22cc0fdc38
11 changed files with 1135 additions and 44 deletions
|
|
@ -7,6 +7,12 @@ use uuid::Uuid;
|
||||||
|
|
||||||
use crate::models::{AvatarRenderData, ChannelMemberInfo, ChannelMemberWithAvatar, LooseProp};
|
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.
|
/// WebSocket configuration sent to client on connect.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct WsConfig {
|
pub struct WsConfig {
|
||||||
|
|
@ -47,10 +53,13 @@ pub enum ClientMessage {
|
||||||
/// Ping to keep connection alive.
|
/// Ping to keep connection alive.
|
||||||
Ping,
|
Ping,
|
||||||
|
|
||||||
/// Send a chat message to the channel.
|
/// Send a chat message to the channel or directly to a user.
|
||||||
SendChatMessage {
|
SendChatMessage {
|
||||||
/// Message content (max 500 chars).
|
/// Message content (max 500 chars).
|
||||||
content: String,
|
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.
|
/// Drop a prop from inventory to the canvas.
|
||||||
|
|
@ -154,6 +163,16 @@ pub enum ServerMessage {
|
||||||
y: f64,
|
y: f64,
|
||||||
/// Server timestamp (milliseconds since epoch).
|
/// Server timestamp (milliseconds since epoch).
|
||||||
timestamp: i64,
|
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.
|
/// Initial list of loose props when joining channel.
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,25 @@ pub struct ChannelState {
|
||||||
tx: broadcast::Sender<ServerMessage>,
|
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.
|
/// Global state for all WebSocket connections.
|
||||||
pub struct WebSocketState {
|
pub struct WebSocketState {
|
||||||
/// Map of channel_id -> ChannelState.
|
/// Map of channel_id -> ChannelState.
|
||||||
channels: DashMap<Uuid, Arc<ChannelState>>,
|
channels: DashMap<Uuid, Arc<ChannelState>>,
|
||||||
|
/// Map of user_id -> UserConnection for direct message routing.
|
||||||
|
users: DashMap<Uuid, UserConnection>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for WebSocketState {
|
impl Default for WebSocketState {
|
||||||
|
|
@ -53,6 +68,7 @@ impl WebSocketState {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
channels: DashMap::new(),
|
channels: DashMap::new(),
|
||||||
|
users: DashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,6 +82,47 @@ impl WebSocketState {
|
||||||
})
|
})
|
||||||
.clone()
|
.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.
|
/// 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
|
// Broadcast join to others
|
||||||
let avatar = avatars::get_avatar_with_paths_conn(&mut *conn, user.id, realm_id)
|
let avatar = avatars::get_avatar_with_paths_conn(&mut *conn, user.id, realm_id)
|
||||||
.await
|
.await
|
||||||
|
|
@ -295,14 +355,27 @@ async fn handle_socket(
|
||||||
// and pool for cleanup (leave_channel needs user_id match anyway)
|
// and pool for cleanup (leave_channel needs user_id match anyway)
|
||||||
drop(conn);
|
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);
|
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
|
// Create recv timeout from config
|
||||||
let recv_timeout = Duration::from_secs(ws_config.recv_timeout_secs);
|
let recv_timeout = Duration::from_secs(ws_config.recv_timeout_secs);
|
||||||
|
|
||||||
// Spawn task to handle incoming messages from client
|
// Spawn task to handle incoming messages from client
|
||||||
let recv_task = tokio::spawn(async move {
|
let recv_task = tokio::spawn(async move {
|
||||||
|
let ws_state = ws_state_for_recv;
|
||||||
let mut disconnect_reason = DisconnectReason::Graceful;
|
let mut disconnect_reason = DisconnectReason::Graceful;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
|
@ -375,7 +448,7 @@ async fn handle_socket(
|
||||||
// Respond with pong directly (not broadcast)
|
// Respond with pong directly (not broadcast)
|
||||||
let _ = direct_tx.send(ServerMessage::Pong).await;
|
let _ = direct_tx.send(ServerMessage::Pong).await;
|
||||||
}
|
}
|
||||||
ClientMessage::SendChatMessage { content } => {
|
ClientMessage::SendChatMessage { content, target_display_name } => {
|
||||||
// Validate message
|
// Validate message
|
||||||
if content.is_empty() || content.len() > 500 {
|
if content.is_empty() || content.len() > 500 {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -395,6 +468,66 @@ async fn handle_socket(
|
||||||
let emotion_name = EmotionState::from_index(member.current_emotion as u8)
|
let emotion_name = EmotionState::from_index(member.current_emotion as u8)
|
||||||
.map(|e| e.to_string())
|
.map(|e| e.to_string())
|
||||||
.unwrap_or_else(|| "neutral".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 {
|
let msg = ServerMessage::ChatMessageReceived {
|
||||||
message_id: Uuid::new_v4(),
|
message_id: Uuid::new_v4(),
|
||||||
user_id: Some(user_id),
|
user_id: Some(user_id),
|
||||||
|
|
@ -405,10 +538,13 @@ async fn handle_socket(
|
||||||
x: member.position_x,
|
x: member.position_x,
|
||||||
y: member.position_y,
|
y: member.position_y,
|
||||||
timestamp: chrono::Utc::now().timestamp_millis(),
|
timestamp: chrono::Utc::now().timestamp_millis(),
|
||||||
|
is_whisper: false,
|
||||||
|
is_same_scene: true,
|
||||||
};
|
};
|
||||||
let _ = tx.send(msg);
|
let _ = tx.send(msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
ClientMessage::DropProp { inventory_item_id } => {
|
ClientMessage::DropProp { inventory_item_id } => {
|
||||||
// Get user's current position for random offset
|
// Get user's current position for random offset
|
||||||
let member_info = channel_members::get_channel_member(
|
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!(
|
tracing::info!(
|
||||||
"[WS] User {} disconnected from channel {} (reason: {:?})",
|
"[WS] User {} disconnected from channel {} (reason: {:?})",
|
||||||
user_id,
|
user_id,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ pub mod avatar_editor;
|
||||||
pub mod chat;
|
pub mod chat;
|
||||||
pub mod chat_types;
|
pub mod chat_types;
|
||||||
pub mod context_menu;
|
pub mod context_menu;
|
||||||
|
pub mod conversation_modal;
|
||||||
pub mod editor;
|
pub mod editor;
|
||||||
pub mod emotion_picker;
|
pub mod emotion_picker;
|
||||||
pub mod forms;
|
pub mod forms;
|
||||||
|
|
@ -13,6 +14,8 @@ pub mod keybindings;
|
||||||
pub mod keybindings_popup;
|
pub mod keybindings_popup;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod modals;
|
pub mod modals;
|
||||||
|
pub mod notification_history;
|
||||||
|
pub mod notifications;
|
||||||
pub mod scene_viewer;
|
pub mod scene_viewer;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod settings_popup;
|
pub mod settings_popup;
|
||||||
|
|
@ -24,6 +27,7 @@ pub use avatar_editor::*;
|
||||||
pub use chat::*;
|
pub use chat::*;
|
||||||
pub use chat_types::*;
|
pub use chat_types::*;
|
||||||
pub use context_menu::*;
|
pub use context_menu::*;
|
||||||
|
pub use conversation_modal::*;
|
||||||
pub use editor::*;
|
pub use editor::*;
|
||||||
pub use emotion_picker::*;
|
pub use emotion_picker::*;
|
||||||
pub use forms::*;
|
pub use forms::*;
|
||||||
|
|
@ -32,6 +36,8 @@ pub use keybindings::*;
|
||||||
pub use keybindings_popup::*;
|
pub use keybindings_popup::*;
|
||||||
pub use layout::*;
|
pub use layout::*;
|
||||||
pub use modals::*;
|
pub use modals::*;
|
||||||
|
pub use notification_history::*;
|
||||||
|
pub use notifications::*;
|
||||||
pub use scene_viewer::*;
|
pub use scene_viewer::*;
|
||||||
pub use settings::*;
|
pub use settings::*;
|
||||||
pub use settings_popup::*;
|
pub use settings_popup::*;
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,11 @@ impl ContentBounds {
|
||||||
fn empty_bottom_rows(&self) -> usize {
|
fn empty_bottom_rows(&self) -> usize {
|
||||||
2 - self.max_row
|
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).
|
/// Get a unique key for a member (for Leptos For keying).
|
||||||
|
|
@ -386,7 +391,17 @@ pub fn AvatarCanvas(
|
||||||
if let Some(ref b) = bubble {
|
if let Some(ref b) = bubble {
|
||||||
let current_time = js_sys::Date::now() as i64;
|
let current_time = js_sys::Date::now() as i64;
|
||||||
if b.expires_at >= current_time {
|
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,
|
bubble: &ActiveBubble,
|
||||||
center_x: f64,
|
center_x: f64,
|
||||||
top_y: f64,
|
top_y: f64,
|
||||||
_prop_size: f64,
|
content_x_offset: f64,
|
||||||
|
content_top_adjustment: f64,
|
||||||
text_em_size: f64,
|
text_em_size: f64,
|
||||||
) {
|
) {
|
||||||
// Text scale independent of zoom - only affected by user's text_em_size setting
|
// 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);
|
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
|
// 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);
|
let lines = wrap_text(ctx, &bubble.message.content, max_bubble_width - padding * 2.0);
|
||||||
|
|
||||||
// Calculate bubble dimensions
|
// Calculate bubble dimensions
|
||||||
|
|
@ -453,9 +472,13 @@ fn draw_bubble(
|
||||||
let bubble_width = bubble_width.max(60.0 * text_scale);
|
let bubble_width = bubble_width.max(60.0 * text_scale);
|
||||||
let bubble_height = (lines.len() as f64) * line_height + padding * 2.0;
|
let bubble_height = (lines.len() as f64) * line_height + padding * 2.0;
|
||||||
|
|
||||||
// Position bubble above avatar
|
// Center bubble horizontally on content (not grid center)
|
||||||
let bubble_x = center_x - bubble_width / 2.0;
|
let content_center_x = center_x + content_x_offset;
|
||||||
let bubble_y = top_y - bubble_height - tail_size - 5.0 * text_scale;
|
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 bubble background
|
||||||
draw_rounded_rect(ctx, bubble_x, bubble_y, bubble_width, bubble_height, border_radius);
|
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.set_line_width(2.0);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Draw tail
|
// Draw tail pointing to content center
|
||||||
ctx.begin_path();
|
ctx.begin_path();
|
||||||
ctx.move_to(center_x - tail_size, bubble_y + bubble_height);
|
ctx.move_to(content_center_x - tail_size, bubble_y + bubble_height);
|
||||||
ctx.line_to(center_x, bubble_y + bubble_height + tail_size);
|
ctx.line_to(content_center_x, bubble_y + bubble_height + tail_size);
|
||||||
ctx.line_to(center_x + tail_size, bubble_y + bubble_height);
|
ctx.line_to(content_center_x + tail_size, bubble_y + bubble_height);
|
||||||
ctx.close_path();
|
ctx.close_path();
|
||||||
ctx.set_fill_style_str(bg_color);
|
ctx.set_fill_style_str(bg_color);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.set_stroke_style_str(border_color);
|
ctx.set_stroke_style_str(border_color);
|
||||||
ctx.stroke();
|
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_fill_style_str(text_color);
|
||||||
ctx.set_text_align("left");
|
ctx.set_text_align("left");
|
||||||
ctx.set_text_baseline("top");
|
ctx.set_text_baseline("top");
|
||||||
|
|
|
||||||
|
|
@ -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.
|
/// Chat input component with emote command support.
|
||||||
///
|
///
|
||||||
/// Props:
|
/// Props:
|
||||||
|
|
@ -205,12 +233,16 @@ pub fn ChatInput(
|
||||||
let cmd = value[1..].to_lowercase();
|
let cmd = value[1..].to_lowercase();
|
||||||
|
|
||||||
// Show hint for slash commands (don't execute until Enter)
|
// 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()
|
if cmd.is_empty()
|
||||||
|| "setting".starts_with(&cmd)
|
|| "setting".starts_with(&cmd)
|
||||||
|| "inventory".starts_with(&cmd)
|
|| "inventory".starts_with(&cmd)
|
||||||
|
|| "whisper".starts_with(&cmd)
|
||||||
|| cmd == "setting"
|
|| cmd == "setting"
|
||||||
|| cmd == "settings"
|
|| cmd == "settings"
|
||||||
|| cmd == "inventory"
|
|| cmd == "inventory"
|
||||||
|
|| cmd.starts_with("w ")
|
||||||
|
|| cmd.starts_with("whisper ")
|
||||||
{
|
{
|
||||||
set_command_mode.set(CommandMode::ShowingSlashHint);
|
set_command_mode.set(CommandMode::ShowingSlashHint);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -317,9 +349,10 @@ pub fn ChatInput(
|
||||||
if key == "Enter" {
|
if key == "Enter" {
|
||||||
let msg = message.get();
|
let msg = message.get();
|
||||||
|
|
||||||
// Handle slash commands - NEVER send as message
|
// Handle slash commands
|
||||||
if msg.starts_with('/') {
|
if msg.starts_with('/') {
|
||||||
let cmd = msg[1..].to_lowercase();
|
let cmd = msg[1..].to_lowercase();
|
||||||
|
|
||||||
// /s, /se, /set, /sett, /setti, /settin, /setting, /settings
|
// /s, /se, /set, /sett, /setti, /settin, /setting, /settings
|
||||||
if !cmd.is_empty() && ("setting".starts_with(&cmd) || cmd == "settings") {
|
if !cmd.is_empty() && ("setting".starts_with(&cmd) || cmd == "settings") {
|
||||||
if let Some(ref callback) = on_open_settings {
|
if let Some(ref callback) = on_open_settings {
|
||||||
|
|
@ -331,9 +364,12 @@ pub fn ChatInput(
|
||||||
input.set_value("");
|
input.set_value("");
|
||||||
let _ = input.blur();
|
let _ = input.blur();
|
||||||
}
|
}
|
||||||
|
ev.prevent_default();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// /i, /in, /inv, /inve, /inven, /invent, /invento, /inventor, /inventory
|
// /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 {
|
if let Some(ref callback) = on_open_inventory {
|
||||||
callback.run(());
|
callback.run(());
|
||||||
}
|
}
|
||||||
|
|
@ -343,7 +379,31 @@ pub fn ChatInput(
|
||||||
input.set_value("");
|
input.set_value("");
|
||||||
let _ = input.blur();
|
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
|
// Invalid slash command - just ignore, don't send
|
||||||
ev.prevent_default();
|
ev.prevent_default();
|
||||||
return;
|
return;
|
||||||
|
|
@ -380,6 +440,7 @@ pub fn ChatInput(
|
||||||
if let Some(send_fn) = sender {
|
if let Some(send_fn) = sender {
|
||||||
send_fn(ClientMessage::SendChatMessage {
|
send_fn(ClientMessage::SendChatMessage {
|
||||||
content: msg.trim().to_string(),
|
content: msg.trim().to_string(),
|
||||||
|
target_display_name: None, // Broadcast to scene
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -440,7 +501,7 @@ pub fn ChatInput(
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</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>
|
<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">
|
<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>
|
<span class="text-gray-400">"/"</span>
|
||||||
|
|
@ -450,6 +511,10 @@ pub fn ChatInput(
|
||||||
<span class="text-gray-400">"/"</span>
|
<span class="text-gray-400">"/"</span>
|
||||||
<span class="text-blue-400">"i"</span>
|
<span class="text-blue-400">"i"</span>
|
||||||
<span class="text-gray-500">"[nventory]"</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>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,18 @@ pub struct ChatMessage {
|
||||||
pub y: f64,
|
pub y: f64,
|
||||||
/// Timestamp in milliseconds since epoch.
|
/// Timestamp in milliseconds since epoch.
|
||||||
pub timestamp: i64,
|
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.
|
/// Message log with bounded capacity for future replay support.
|
||||||
|
|
|
||||||
173
crates/chattyness-user-ui/src/components/conversation_modal.rs
Normal file
173
crates/chattyness-user-ui/src/components/conversation_modal.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
241
crates/chattyness-user-ui/src/components/notification_history.rs
Normal file
241
crates/chattyness-user-ui/src/components/notification_history.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
232
crates/chattyness-user-ui/src/components/notifications.rs
Normal file
232
crates/chattyness-user-ui/src/components/notifications.rs
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
//! Notification components for whispers and system messages.
|
||||||
|
//!
|
||||||
|
//! Provides toast notifications for cross-scene whispers with keybindings.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use uuid::Uuid;
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
|
||||||
|
use super::chat_types::ChatMessage;
|
||||||
|
|
||||||
|
/// A notification message for display.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct NotificationMessage {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub chat_message: ChatMessage,
|
||||||
|
/// When the notification was created (milliseconds since epoch).
|
||||||
|
pub created_at: i64,
|
||||||
|
/// Whether the notification has been dismissed.
|
||||||
|
pub dismissed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NotificationMessage {
|
||||||
|
/// Create a new notification from a chat message.
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
pub fn from_chat_message(msg: ChatMessage) -> Self {
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
chat_message: msg,
|
||||||
|
created_at: js_sys::Date::now() as i64,
|
||||||
|
dismissed: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SSR stub.
|
||||||
|
#[cfg(not(feature = "hydrate"))]
|
||||||
|
pub fn from_chat_message(msg: ChatMessage) -> Self {
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
chat_message: msg,
|
||||||
|
created_at: 0,
|
||||||
|
dismissed: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toast notification component for cross-scene whispers.
|
||||||
|
///
|
||||||
|
/// Displays in the top-right corner with auto-dismiss and keybindings.
|
||||||
|
#[component]
|
||||||
|
pub fn NotificationToast(
|
||||||
|
/// The notification to display.
|
||||||
|
#[prop(into)]
|
||||||
|
notification: Signal<Option<NotificationMessage>>,
|
||||||
|
/// Callback when user wants to reply (press 'r').
|
||||||
|
on_reply: Callback<String>,
|
||||||
|
/// Callback when user wants to see context (press 'c').
|
||||||
|
on_context: Callback<String>,
|
||||||
|
/// Callback when user wants to see history (press 'h').
|
||||||
|
on_history: Callback<()>,
|
||||||
|
/// Callback when notification is dismissed.
|
||||||
|
on_dismiss: Callback<Uuid>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
// Auto-dismiss timer
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
let timer_handle = Rc::new(RefCell::new(None::<i32>));
|
||||||
|
let timer_handle_effect = timer_handle.clone();
|
||||||
|
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let notif = notification.get();
|
||||||
|
|
||||||
|
// Clear any existing timer
|
||||||
|
if let Some(handle) = timer_handle_effect.borrow_mut().take() {
|
||||||
|
web_sys::window()
|
||||||
|
.unwrap()
|
||||||
|
.clear_timeout_with_handle(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start new timer if notification is present
|
||||||
|
if let Some(ref n) = notif {
|
||||||
|
let id = n.id;
|
||||||
|
let on_dismiss = on_dismiss.clone();
|
||||||
|
let timer_handle_inner = timer_handle_effect.clone();
|
||||||
|
|
||||||
|
let closure = wasm_bindgen::closure::Closure::once(Box::new(move || {
|
||||||
|
on_dismiss.run(id);
|
||||||
|
timer_handle_inner.borrow_mut().take();
|
||||||
|
}) as Box<dyn FnOnce()>);
|
||||||
|
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let Ok(handle) = window.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||||
|
closure.as_ref().unchecked_ref(),
|
||||||
|
5000, // 5 second auto-dismiss
|
||||||
|
) {
|
||||||
|
*timer_handle_effect.borrow_mut() = Some(handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
closure.forget();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keybinding handler with proper cleanup
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
let closure_holder: Rc<RefCell<Option<wasm_bindgen::closure::Closure<dyn Fn(web_sys::KeyboardEvent)>>>> =
|
||||||
|
Rc::new(RefCell::new(None));
|
||||||
|
let closure_holder_clone = closure_holder.clone();
|
||||||
|
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let notif = notification.get();
|
||||||
|
|
||||||
|
// Cleanup previous listener
|
||||||
|
if let Some(old_closure) = closure_holder_clone.borrow_mut().take() {
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
let _ = window.remove_event_listener_with_callback(
|
||||||
|
"keydown",
|
||||||
|
old_closure.as_ref().unchecked_ref(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only add listener if notification is present
|
||||||
|
let Some(ref n) = notif else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let display_name = n.chat_message.display_name.clone();
|
||||||
|
let notif_id = n.id;
|
||||||
|
|
||||||
|
let on_reply = on_reply.clone();
|
||||||
|
let on_context = on_context.clone();
|
||||||
|
let on_history = on_history.clone();
|
||||||
|
let on_dismiss = on_dismiss.clone();
|
||||||
|
|
||||||
|
let closure = wasm_bindgen::closure::Closure::<dyn Fn(web_sys::KeyboardEvent)>::new(move |ev: web_sys::KeyboardEvent| {
|
||||||
|
let key = ev.key();
|
||||||
|
match key.as_str() {
|
||||||
|
"r" | "R" => {
|
||||||
|
ev.prevent_default();
|
||||||
|
on_reply.run(display_name.clone());
|
||||||
|
on_dismiss.run(notif_id);
|
||||||
|
}
|
||||||
|
"c" | "C" => {
|
||||||
|
ev.prevent_default();
|
||||||
|
on_context.run(display_name.clone());
|
||||||
|
on_dismiss.run(notif_id);
|
||||||
|
}
|
||||||
|
"h" | "H" => {
|
||||||
|
ev.prevent_default();
|
||||||
|
on_history.run(());
|
||||||
|
on_dismiss.run(notif_id);
|
||||||
|
}
|
||||||
|
"Escape" => {
|
||||||
|
ev.prevent_default();
|
||||||
|
on_dismiss.run(notif_id);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
let _ = window.add_event_listener_with_callback(
|
||||||
|
"keydown",
|
||||||
|
closure.as_ref().unchecked_ref(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store closure for cleanup on next change
|
||||||
|
*closure_holder_clone.borrow_mut() = Some(closure);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Show when=move || notification.get().is_some()>
|
||||||
|
{move || {
|
||||||
|
let notif = notification.get();
|
||||||
|
if let Some(n) = notif {
|
||||||
|
let content_preview = if n.chat_message.content.len() > 50 {
|
||||||
|
format!("\"{}...\"", &n.chat_message.content[..47])
|
||||||
|
} else {
|
||||||
|
format!("\"{}\"", n.chat_message.content)
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="fixed top-4 right-4 z-50 max-w-sm w-full animate-slide-in-right">
|
||||||
|
<div class="bg-gray-800 border border-purple-500/50 rounded-lg shadow-lg p-4">
|
||||||
|
// Header
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<span class="text-lg">"💬"</span>
|
||||||
|
<span class="text-purple-300 font-medium">
|
||||||
|
{n.chat_message.display_name.clone()} " whispered to you"
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Content
|
||||||
|
<p class="text-gray-300 italic mb-3">
|
||||||
|
{content_preview}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
// Keybinding hints
|
||||||
|
<div class="flex gap-3 text-xs text-gray-500">
|
||||||
|
<span>
|
||||||
|
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-gray-400">"r"</kbd>
|
||||||
|
" reply"
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-gray-400">"c"</kbd>
|
||||||
|
" context"
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-gray-400">"h"</kbd>
|
||||||
|
" history"
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
().into_any()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -62,6 +62,15 @@ pub struct ChannelMemberInfo {
|
||||||
pub display_name: String,
|
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.
|
/// Hook to manage WebSocket connection for a channel.
|
||||||
///
|
///
|
||||||
/// Returns a tuple of:
|
/// Returns a tuple of:
|
||||||
|
|
@ -78,6 +87,7 @@ pub fn use_channel_websocket(
|
||||||
on_prop_picked_up: Callback<uuid::Uuid>,
|
on_prop_picked_up: Callback<uuid::Uuid>,
|
||||||
on_member_fading: Callback<FadingMember>,
|
on_member_fading: Callback<FadingMember>,
|
||||||
on_welcome: Option<Callback<ChannelMemberInfo>>,
|
on_welcome: Option<Callback<ChannelMemberInfo>>,
|
||||||
|
on_error: Option<Callback<WsError>>,
|
||||||
) -> (Signal<WsState>, WsSenderStorage) {
|
) -> (Signal<WsState>, WsSenderStorage) {
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
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_prop_picked_up_clone = on_prop_picked_up.clone();
|
||||||
let on_member_fading_clone = on_member_fading.clone();
|
let on_member_fading_clone = on_member_fading.clone();
|
||||||
let on_welcome_clone = on_welcome.clone();
|
let on_welcome_clone = on_welcome.clone();
|
||||||
|
let on_error_clone = on_error.clone();
|
||||||
// For starting heartbeat on Welcome
|
// For starting heartbeat on Welcome
|
||||||
let ws_ref_for_heartbeat = ws_ref.clone();
|
let ws_ref_for_heartbeat = ws_ref.clone();
|
||||||
let heartbeat_started: Rc<RefCell<bool>> = Rc::new(RefCell::new(false));
|
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_dropped_clone,
|
||||||
&on_prop_picked_up_clone,
|
&on_prop_picked_up_clone,
|
||||||
&on_member_fading_clone,
|
&on_member_fading_clone,
|
||||||
|
&on_error_clone,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -272,6 +284,7 @@ fn handle_server_message(
|
||||||
on_prop_dropped: &Callback<LooseProp>,
|
on_prop_dropped: &Callback<LooseProp>,
|
||||||
on_prop_picked_up: &Callback<uuid::Uuid>,
|
on_prop_picked_up: &Callback<uuid::Uuid>,
|
||||||
on_member_fading: &Callback<FadingMember>,
|
on_member_fading: &Callback<FadingMember>,
|
||||||
|
on_error: &Option<Callback<WsError>>,
|
||||||
) {
|
) {
|
||||||
let mut members_vec = members.borrow_mut();
|
let mut members_vec = members.borrow_mut();
|
||||||
|
|
||||||
|
|
@ -360,6 +373,10 @@ fn handle_server_message(
|
||||||
ServerMessage::Error { code, message } => {
|
ServerMessage::Error { code, message } => {
|
||||||
// Always log errors to console (not just debug mode)
|
// Always log errors to console (not just debug mode)
|
||||||
web_sys::console::error_1(&format!("[WS] Server error: {} - {}", code, message).into());
|
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 {
|
ServerMessage::ChatMessageReceived {
|
||||||
message_id,
|
message_id,
|
||||||
|
|
@ -371,6 +388,8 @@ fn handle_server_message(
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
timestamp,
|
timestamp,
|
||||||
|
is_whisper,
|
||||||
|
is_same_scene,
|
||||||
} => {
|
} => {
|
||||||
let chat_msg = ChatMessage {
|
let chat_msg = ChatMessage {
|
||||||
message_id,
|
message_id,
|
||||||
|
|
@ -382,6 +401,8 @@ fn handle_server_message(
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
timestamp,
|
timestamp,
|
||||||
|
is_whisper,
|
||||||
|
is_same_scene,
|
||||||
};
|
};
|
||||||
on_chat_message.run(chat_msg);
|
on_chat_message.run(chat_msg);
|
||||||
}
|
}
|
||||||
|
|
@ -429,6 +450,7 @@ pub fn use_channel_websocket(
|
||||||
_on_prop_picked_up: Callback<uuid::Uuid>,
|
_on_prop_picked_up: Callback<uuid::Uuid>,
|
||||||
_on_member_fading: Callback<FadingMember>,
|
_on_member_fading: Callback<FadingMember>,
|
||||||
_on_welcome: Option<Callback<ChannelMemberInfo>>,
|
_on_welcome: Option<Callback<ChannelMemberInfo>>,
|
||||||
|
_on_error: Option<Callback<WsError>>,
|
||||||
) -> (Signal<WsState>, WsSenderStorage) {
|
) -> (Signal<WsState>, WsSenderStorage) {
|
||||||
let (ws_state, _) = signal(WsState::Disconnected);
|
let (ws_state, _) = signal(WsState::Disconnected);
|
||||||
let sender: WsSenderStorage = StoredValue::new_local(None);
|
let sender: WsSenderStorage = StoredValue::new_local(None);
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,16 @@ use leptos_router::hooks::use_params_map;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::components::{
|
use crate::components::{
|
||||||
ActiveBubble, AvatarEditorPopup, Card, ChatInput, EmotionKeybindings, FadingMember,
|
ActiveBubble, AvatarEditorPopup, Card, ChatInput, ConversationModal, EmotionKeybindings,
|
||||||
InventoryPopup, KeybindingsPopup, MessageLog, RealmHeader, RealmSceneViewer, SettingsPopup,
|
FadingMember, InventoryPopup, KeybindingsPopup, MessageLog, NotificationHistoryModal,
|
||||||
|
NotificationMessage, NotificationToast, RealmHeader, RealmSceneViewer, SettingsPopup,
|
||||||
ViewerSettings,
|
ViewerSettings,
|
||||||
};
|
};
|
||||||
#[cfg(feature = "hydrate")]
|
#[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;
|
use crate::utils::LocalStoragePersist;
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
use crate::utils::parse_bounds_dimensions;
|
use crate::utils::parse_bounds_dimensions;
|
||||||
|
|
@ -97,6 +101,21 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
// Whisper target - when set, triggers pre-fill in ChatInput
|
// Whisper target - when set, triggers pre-fill in ChatInput
|
||||||
let (whisper_target, set_whisper_target) = signal(Option::<String>::None);
|
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 realm_data = LocalResource::new(move || {
|
||||||
let slug = slug.get();
|
let slug = slug.get();
|
||||||
async move {
|
async move {
|
||||||
|
|
@ -203,10 +222,24 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
// Add to message log
|
// Add to message log
|
||||||
message_log.update_value(|log| log.push(msg.clone()));
|
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 key = (msg.user_id, msg.guest_session_id);
|
||||||
let expires_at = msg.timestamp + DEFAULT_BUBBLE_TIMEOUT_MS;
|
let expires_at = msg.timestamp + DEFAULT_BUBBLE_TIMEOUT_MS;
|
||||||
|
|
||||||
set_active_bubbles.update(|bubbles| {
|
set_active_bubbles.update(|bubbles| {
|
||||||
bubbles.insert(
|
bubbles.insert(
|
||||||
key,
|
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
|
// Loose props callbacks
|
||||||
|
|
@ -256,6 +307,24 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
let on_welcome = Callback::new(move |info: ChannelMemberInfo| {
|
let on_welcome = Callback::new(move |info: ChannelMemberInfo| {
|
||||||
set_current_user_id.set(info.user_id);
|
set_current_user_id.set(info.user_id);
|
||||||
set_current_guest_session_id.set(info.guest_session_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")]
|
#[cfg(feature = "hydrate")]
|
||||||
|
|
@ -269,6 +338,7 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
on_prop_picked_up,
|
on_prop_picked_up,
|
||||||
on_member_fading,
|
on_member_fading,
|
||||||
Some(on_welcome),
|
Some(on_welcome),
|
||||||
|
Some(on_ws_error),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set channel ID and scene dimensions when scene loads
|
// 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()
|
.into_any()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue