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

@ -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,19 +222,51 @@ pub fn RealmPage() -> impl IntoView {
// Add to message log
message_log.update_value(|log| log.push(msg.clone()));
// Update active bubbles
let key = (msg.user_id, msg.guest_session_id);
let expires_at = msg.timestamp + DEFAULT_BUBBLE_TIMEOUT_MS;
// Handle whispers
if msg.is_whisper {
// Track whisper for conversation view
whisper_messages.update_value(|msgs| {
msgs.push(msg.clone());
// Keep last 100 whisper messages
if msgs.len() > 100 {
msgs.remove(0);
}
});
set_active_bubbles.update(|bubbles| {
bubbles.insert(
key,
ActiveBubble {
message: msg,
expires_at,
},
);
});
// Add to notification history for persistence
add_to_history(HistoryEntry::from_whisper(&msg));
if msg.is_same_scene {
// Same scene whisper: show as italic bubble (handled by bubble rendering)
let key = (msg.user_id, msg.guest_session_id);
let expires_at = msg.timestamp + DEFAULT_BUBBLE_TIMEOUT_MS;
set_active_bubbles.update(|bubbles| {
bubbles.insert(
key,
ActiveBubble {
message: msg,
expires_at,
},
);
});
} else {
// Cross-scene whisper: show as notification toast
set_current_notification.set(Some(NotificationMessage::from_chat_message(msg)));
}
} else {
// Regular broadcast: show as bubble
let key = (msg.user_id, msg.guest_session_id);
let expires_at = msg.timestamp + DEFAULT_BUBBLE_TIMEOUT_MS;
set_active_bubbles.update(|bubbles| {
bubbles.insert(
key,
ActiveBubble {
message: msg,
expires_at,
},
);
});
}
});
// Loose props callbacks
@ -256,6 +307,24 @@ pub fn RealmPage() -> impl IntoView {
let on_welcome = Callback::new(move |info: ChannelMemberInfo| {
set_current_user_id.set(info.user_id);
set_current_guest_session_id.set(info.guest_session_id);
set_current_display_name.set(info.display_name.clone());
});
// Callback for WebSocket errors (whisper failures, etc.)
#[cfg(feature = "hydrate")]
let on_ws_error = Callback::new(move |error: WsError| {
// Display user-friendly error message
let msg = match error.code.as_str() {
"WHISPER_TARGET_NOT_FOUND" => error.message,
_ => format!("Error: {}", error.message),
};
set_error_message.set(Some(msg));
// Auto-dismiss after 5 seconds
use gloo_timers::callback::Timeout;
Timeout::new(5000, move || {
set_error_message.set(None);
})
.forget();
});
#[cfg(feature = "hydrate")]
@ -269,6 +338,7 @@ pub fn RealmPage() -> impl IntoView {
on_prop_picked_up,
on_member_fading,
Some(on_welcome),
Some(on_ws_error),
);
// Set channel ID and scene dimensions when scene loads
@ -791,6 +861,94 @@ pub fn RealmPage() -> impl IntoView {
/>
}
}
// Notification toast for cross-scene whispers
<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()
}