feat: private messages.
This commit is contained in:
parent
0492043625
commit
22cc0fdc38
11 changed files with 1135 additions and 44 deletions
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue