1319 lines
66 KiB
Rust
1319 lines
66 KiB
Rust
//! Realm landing page after login.
|
||
|
||
use std::collections::HashMap;
|
||
|
||
use leptos::prelude::*;
|
||
use leptos::reactive::owner::LocalStorage;
|
||
#[cfg(feature = "hydrate")]
|
||
use leptos::task::spawn_local;
|
||
#[cfg(feature = "hydrate")]
|
||
use leptos_router::hooks::use_navigate;
|
||
use leptos_router::hooks::use_params_map;
|
||
use uuid::Uuid;
|
||
|
||
use crate::components::{
|
||
ActiveBubble, AvatarEditorPopup, Card, ChatInput, ConversationModal, EmotionKeybindings,
|
||
FadingMember, HotkeyHelp, InventoryPopup, KeybindingsPopup, LogPopup, MessageLog,
|
||
NotificationHistoryModal, NotificationMessage, NotificationToast, RealmHeader,
|
||
RealmSceneViewer, ReconnectionOverlay, SettingsPopup, ViewerSettings,
|
||
};
|
||
#[cfg(feature = "hydrate")]
|
||
use crate::components::{
|
||
ChannelMemberInfo, ChatMessage, DEFAULT_BUBBLE_TIMEOUT_MS, FADE_DURATION_MS, HistoryEntry,
|
||
TeleportInfo, WsError, add_to_history, use_channel_websocket,
|
||
};
|
||
use crate::utils::LocalStoragePersist;
|
||
#[cfg(feature = "hydrate")]
|
||
use crate::utils::parse_bounds_dimensions;
|
||
use chattyness_db::models::{
|
||
AvatarWithPaths, ChannelMemberWithAvatar, EmotionAvailability, LooseProp, RealmRole,
|
||
RealmWithUserRole, Scene, SceneSummary,
|
||
};
|
||
#[cfg(feature = "hydrate")]
|
||
use chattyness_db::ws_messages::ClientMessage;
|
||
|
||
#[cfg(not(feature = "hydrate"))]
|
||
use crate::components::ws_client::WsSender;
|
||
|
||
/// Realm landing page component.
|
||
#[component]
|
||
pub fn RealmPage() -> impl IntoView {
|
||
let params = use_params_map();
|
||
#[cfg(feature = "hydrate")]
|
||
let navigate = use_navigate();
|
||
|
||
let slug = Signal::derive(move || params.read().get("slug").unwrap_or_default());
|
||
|
||
// Scene slug from URL (for direct scene navigation)
|
||
let scene_slug_param = Signal::derive(move || params.read().get("scene_slug"));
|
||
|
||
// Channel member state
|
||
let (members, set_members) = signal(Vec::<ChannelMemberWithAvatar>::new());
|
||
let (channel_id, set_channel_id) = signal(Option::<uuid::Uuid>::None);
|
||
|
||
// Chat focus coordination
|
||
let (chat_focused, set_chat_focused) = signal(false);
|
||
let (focus_chat_trigger, set_focus_chat_trigger) = signal(false);
|
||
|
||
// Emotion availability for emote picker
|
||
let (emotion_availability, set_emotion_availability) =
|
||
signal(Option::<EmotionAvailability>::None);
|
||
// Skin preview path for emote picker (position 4 of skin layer)
|
||
let (skin_preview_path, set_skin_preview_path) = signal(Option::<String>::None);
|
||
|
||
// Chat message state - use StoredValue for WASM compatibility (single-threaded)
|
||
let message_log: StoredValue<MessageLog, LocalStorage> =
|
||
StoredValue::new_local(MessageLog::new());
|
||
let (active_bubbles, set_active_bubbles) =
|
||
signal(HashMap::<(Option<Uuid>, Option<Uuid>), ActiveBubble>::new());
|
||
|
||
// Inventory popup state
|
||
let (inventory_open, set_inventory_open) = signal(false);
|
||
|
||
// Settings popup state
|
||
let (settings_open, set_settings_open) = signal(false);
|
||
let viewer_settings = RwSignal::new(ViewerSettings::load());
|
||
|
||
// Log popup state
|
||
let (log_open, set_log_open) = signal(false);
|
||
|
||
// Hotkey help overlay state (shown while ? is held)
|
||
let (hotkey_help_visible, set_hotkey_help_visible) = signal(false);
|
||
|
||
// Keybindings popup state
|
||
let keybindings = RwSignal::new(EmotionKeybindings::load());
|
||
let (keybindings_open, set_keybindings_open) = signal(false);
|
||
|
||
// Avatar editor popup state
|
||
let (avatar_editor_open, set_avatar_editor_open) = signal(false);
|
||
// Store full avatar data for the editor
|
||
let (full_avatar, set_full_avatar) = signal(Option::<AvatarWithPaths>::None);
|
||
|
||
// Scene dimensions (extracted from bounds_wkt when scene loads)
|
||
let (scene_dimensions, set_scene_dimensions) = signal((800.0_f64, 600.0_f64));
|
||
|
||
// Chat focus prefix (: or /)
|
||
let (focus_prefix, set_focus_prefix) = signal(':');
|
||
|
||
// Loose props state
|
||
let (loose_props, set_loose_props) = signal(Vec::<LooseProp>::new());
|
||
|
||
// Fading members state (members that are fading out after timeout disconnect)
|
||
let (fading_members, set_fading_members) = signal(Vec::<FadingMember>::new());
|
||
|
||
// Track user's current position for saving on beforeunload
|
||
let (current_position, set_current_position) = signal((400.0_f64, 300.0_f64));
|
||
|
||
// Current user identity (received from WebSocket Welcome message)
|
||
let (current_user_id, set_current_user_id) = signal(Option::<Uuid>::None);
|
||
let (current_guest_session_id, set_current_guest_session_id) = signal(Option::<Uuid>::None);
|
||
// Whether the current user is a guest (has the 'guest' tag)
|
||
let (is_guest, set_is_guest) = signal(false);
|
||
|
||
// 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);
|
||
|
||
// Reconnection trigger - increment to force WebSocket reconnection
|
||
let reconnect_trigger = RwSignal::new(0u32);
|
||
|
||
// Current scene (changes when teleporting)
|
||
let (current_scene, set_current_scene) = signal(Option::<Scene>::None);
|
||
|
||
// Available scenes for teleportation (cached on load)
|
||
let (available_scenes, set_available_scenes) = signal(Vec::<SceneSummary>::new());
|
||
|
||
// Whether teleportation is allowed in this realm
|
||
let (allow_user_teleport, set_allow_user_teleport) = signal(false);
|
||
|
||
let realm_data = LocalResource::new(move || {
|
||
let slug = slug.get();
|
||
async move {
|
||
if slug.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
#[cfg(feature = "hydrate")]
|
||
{
|
||
use gloo_net::http::Request;
|
||
let response = Request::get(&format!("/api/realms/{}", slug)).send().await;
|
||
match response {
|
||
Ok(resp) if resp.ok() => resp.json::<RealmWithUserRole>().await.ok(),
|
||
_ => None,
|
||
}
|
||
}
|
||
#[cfg(not(feature = "hydrate"))]
|
||
{
|
||
let _ = slug;
|
||
None::<RealmWithUserRole>
|
||
}
|
||
}
|
||
});
|
||
|
||
// Fetch entry scene for the realm
|
||
let entry_scene = LocalResource::new(move || {
|
||
let slug = slug.get();
|
||
async move {
|
||
if slug.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
#[cfg(feature = "hydrate")]
|
||
{
|
||
use gloo_net::http::Request;
|
||
let response = Request::get(&format!("/api/realms/{}/entry-scene", slug))
|
||
.send()
|
||
.await;
|
||
match response {
|
||
Ok(resp) if resp.ok() => resp.json::<Scene>().await.ok(),
|
||
_ => None,
|
||
}
|
||
}
|
||
#[cfg(not(feature = "hydrate"))]
|
||
{
|
||
let _ = slug;
|
||
None::<Scene>
|
||
}
|
||
}
|
||
});
|
||
|
||
// Fetch full avatar with paths for client-side emotion computation
|
||
#[cfg(feature = "hydrate")]
|
||
{
|
||
let slug_for_avatar = slug.clone();
|
||
Effect::new(move |_| {
|
||
use gloo_net::http::Request;
|
||
|
||
let current_slug = slug_for_avatar.get();
|
||
if current_slug.is_empty() {
|
||
return;
|
||
}
|
||
|
||
// Fetch full avatar with all paths resolved
|
||
spawn_local(async move {
|
||
let response = Request::get(&format!("/api/realms/{}/avatar", current_slug))
|
||
.send()
|
||
.await;
|
||
if let Ok(resp) = response {
|
||
if resp.ok() {
|
||
if let Ok(avatar) = resp.json::<AvatarWithPaths>().await {
|
||
// Compute emotion availability client-side
|
||
let avail = avatar.compute_emotion_availability();
|
||
set_emotion_availability.set(Some(avail));
|
||
// Get skin layer position 4 (center) for preview
|
||
set_skin_preview_path.set(avatar.skin_layer[4].clone());
|
||
// Store full avatar for the editor
|
||
set_full_avatar.set(Some(avatar));
|
||
}
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// WebSocket connection for real-time updates
|
||
#[cfg(feature = "hydrate")]
|
||
let on_members_update = Callback::new(move |new_members: Vec<ChannelMemberWithAvatar>| {
|
||
// When members are updated (including rejoins), remove any matching fading members
|
||
set_fading_members.update(|fading| {
|
||
fading.retain(|f| {
|
||
!new_members.iter().any(|m| {
|
||
m.member.user_id == f.member.member.user_id
|
||
&& m.member.guest_session_id == f.member.member.guest_session_id
|
||
})
|
||
});
|
||
});
|
||
set_members.set(new_members);
|
||
});
|
||
|
||
// Chat message callback
|
||
#[cfg(feature = "hydrate")]
|
||
let on_chat_message = Callback::new(move |msg: ChatMessage| {
|
||
// Add to message log
|
||
message_log.update_value(|log| log.push(msg.clone()));
|
||
|
||
// Handle whispers
|
||
if msg.is_whisper {
|
||
// Track whisper for conversation view
|
||
whisper_messages.update_value(|msgs| {
|
||
msgs.push(msg.clone());
|
||
// Keep last 100 whisper messages
|
||
if msgs.len() > 100 {
|
||
msgs.remove(0);
|
||
}
|
||
});
|
||
|
||
// Add to notification history for persistence
|
||
add_to_history(HistoryEntry::from_whisper(&msg));
|
||
|
||
if msg.is_same_scene {
|
||
// Same scene whisper: show as italic bubble (handled by bubble rendering)
|
||
let key = (msg.user_id, msg.guest_session_id);
|
||
let expires_at = msg.timestamp + DEFAULT_BUBBLE_TIMEOUT_MS;
|
||
set_active_bubbles.update(|bubbles| {
|
||
bubbles.insert(
|
||
key,
|
||
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
|
||
#[cfg(feature = "hydrate")]
|
||
let on_loose_props_sync = Callback::new(move |props: Vec<LooseProp>| {
|
||
set_loose_props.set(props);
|
||
});
|
||
|
||
#[cfg(feature = "hydrate")]
|
||
let on_prop_dropped = Callback::new(move |prop: LooseProp| {
|
||
set_loose_props.update(|props| {
|
||
props.push(prop);
|
||
});
|
||
});
|
||
|
||
#[cfg(feature = "hydrate")]
|
||
let on_prop_picked_up = Callback::new(move |prop_id: Uuid| {
|
||
set_loose_props.update(|props| {
|
||
props.retain(|p| p.id != prop_id);
|
||
});
|
||
});
|
||
|
||
// Callback when a member starts fading (timeout disconnect)
|
||
#[cfg(feature = "hydrate")]
|
||
let on_member_fading = Callback::new(move |fading: FadingMember| {
|
||
set_fading_members.update(|members| {
|
||
// Remove any existing entry for this user (shouldn't happen, but be safe)
|
||
members.retain(|m| {
|
||
m.member.member.user_id != fading.member.member.user_id
|
||
|| m.member.member.guest_session_id != fading.member.member.guest_session_id
|
||
});
|
||
members.push(fading);
|
||
});
|
||
});
|
||
|
||
// Callback to capture current user identity from Welcome message
|
||
#[cfg(feature = "hydrate")]
|
||
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());
|
||
set_is_guest.set(info.is_guest);
|
||
});
|
||
|
||
// 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,
|
||
"TELEPORT_DISABLED" => error.message,
|
||
"SCENE_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();
|
||
});
|
||
|
||
// Callback for teleport approval - navigate to new scene
|
||
#[cfg(feature = "hydrate")]
|
||
let on_teleport_approved = Callback::new(move |info: TeleportInfo| {
|
||
let scene_id = info.scene_id;
|
||
let scene_slug = info.scene_slug.clone();
|
||
let realm_slug = slug.get_untracked();
|
||
|
||
// Fetch the new scene data to update the canvas background
|
||
let scene_slug_for_url = scene_slug.clone();
|
||
let realm_slug_for_url = realm_slug.clone();
|
||
spawn_local(async move {
|
||
use gloo_net::http::Request;
|
||
let response = Request::get(&format!(
|
||
"/api/realms/{}/scenes/{}",
|
||
realm_slug, scene_slug
|
||
))
|
||
.send()
|
||
.await;
|
||
|
||
if let Ok(resp) = response {
|
||
if resp.ok() {
|
||
if let Ok(scene) = resp.json::<Scene>().await {
|
||
// Update scene dimensions from the new scene
|
||
if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) {
|
||
set_scene_dimensions.set((w as f64, h as f64));
|
||
}
|
||
|
||
// Update URL to reflect new scene
|
||
if let Some(window) = web_sys::window() {
|
||
if let Ok(history) = window.history() {
|
||
let new_url = if scene.is_entry_point {
|
||
format!("/realms/{}", realm_slug_for_url)
|
||
} else {
|
||
format!(
|
||
"/realms/{}/scenes/{}",
|
||
realm_slug_for_url, scene_slug_for_url
|
||
)
|
||
};
|
||
let _ = history.replace_state_with_url(
|
||
&wasm_bindgen::JsValue::NULL,
|
||
"",
|
||
Some(&new_url),
|
||
);
|
||
}
|
||
}
|
||
|
||
// Update the current scene for the viewer
|
||
set_current_scene.set(Some(scene));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Update channel_id to trigger WebSocket reconnection
|
||
set_channel_id.set(Some(scene_id));
|
||
|
||
// Clear members since we're switching scenes
|
||
set_members.set(Vec::new());
|
||
|
||
// Trigger a reconnect to ensure fresh connection
|
||
reconnect_trigger.update(|t| *t += 1);
|
||
});
|
||
});
|
||
|
||
#[cfg(feature = "hydrate")]
|
||
let (ws_state, ws_sender) = use_channel_websocket(
|
||
slug,
|
||
Signal::derive(move || channel_id.get()),
|
||
reconnect_trigger,
|
||
on_members_update,
|
||
on_chat_message,
|
||
on_loose_props_sync,
|
||
on_prop_dropped,
|
||
on_prop_picked_up,
|
||
on_member_fading,
|
||
Some(on_welcome),
|
||
Some(on_ws_error),
|
||
Some(on_teleport_approved),
|
||
);
|
||
|
||
// Set channel ID, current scene, and scene dimensions when entry scene loads
|
||
// Note: Currently using scene.id as the channel_id since channel_members
|
||
// uses scenes directly. Proper channel infrastructure can be added later.
|
||
#[cfg(feature = "hydrate")]
|
||
{
|
||
// Track whether we've handled initial scene load to prevent double-loading
|
||
let initial_scene_handled = StoredValue::new_local(false);
|
||
|
||
Effect::new(move |_| {
|
||
// Skip if already handled
|
||
if initial_scene_handled.get_value() {
|
||
return;
|
||
}
|
||
|
||
let url_scene_slug = scene_slug_param.get();
|
||
let has_url_scene = url_scene_slug
|
||
.as_ref()
|
||
.is_some_and(|s| !s.is_empty());
|
||
|
||
if has_url_scene {
|
||
// URL has a scene slug - wait for realm data to check if teleport is allowed
|
||
let Some(realm_with_role) = realm_data.get().flatten() else {
|
||
return;
|
||
};
|
||
|
||
let realm_slug_val = slug.get();
|
||
let scene_slug_val = url_scene_slug.unwrap();
|
||
|
||
if !realm_with_role.realm.allow_user_teleport {
|
||
// Teleport disabled - redirect to base realm URL and show error
|
||
initial_scene_handled.set_value(true);
|
||
set_error_message.set(Some(
|
||
"Direct scene access is disabled for this realm".to_string(),
|
||
));
|
||
|
||
// Redirect to base realm URL
|
||
let navigate = use_navigate();
|
||
navigate(
|
||
&format!("/realms/{}", realm_slug_val),
|
||
leptos_router::NavigateOptions {
|
||
replace: true,
|
||
..Default::default()
|
||
},
|
||
);
|
||
|
||
// Auto-dismiss error after 5 seconds
|
||
use gloo_timers::callback::Timeout;
|
||
Timeout::new(5000, move || {
|
||
set_error_message.set(None);
|
||
})
|
||
.forget();
|
||
|
||
// Fall back to entry scene
|
||
if let Some(scene) = entry_scene.get().flatten() {
|
||
set_channel_id.set(Some(scene.id));
|
||
set_current_scene.set(Some(scene.clone()));
|
||
if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) {
|
||
set_scene_dimensions.set((w as f64, h as f64));
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Teleport allowed - fetch the specific scene
|
||
initial_scene_handled.set_value(true);
|
||
spawn_local(async move {
|
||
use gloo_net::http::Request;
|
||
let response = Request::get(&format!(
|
||
"/api/realms/{}/scenes/{}",
|
||
realm_slug_val, scene_slug_val
|
||
))
|
||
.send()
|
||
.await;
|
||
|
||
if let Ok(resp) = response {
|
||
if resp.ok() {
|
||
if let Ok(scene) = resp.json::<Scene>().await {
|
||
set_channel_id.set(Some(scene.id));
|
||
if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) {
|
||
set_scene_dimensions.set((w as f64, h as f64));
|
||
}
|
||
set_current_scene.set(Some(scene));
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Scene not found - show error and fall back to entry scene
|
||
set_error_message.set(Some(format!(
|
||
"Scene '{}' not found",
|
||
scene_slug_val
|
||
)));
|
||
|
||
// Update URL to base realm URL
|
||
if let Some(window) = web_sys::window() {
|
||
if let Ok(history) = window.history() {
|
||
let _ = history.replace_state_with_url(
|
||
&wasm_bindgen::JsValue::NULL,
|
||
"",
|
||
Some(&format!("/realms/{}", realm_slug_val)),
|
||
);
|
||
}
|
||
}
|
||
|
||
// Auto-dismiss error after 5 seconds
|
||
use gloo_timers::callback::Timeout;
|
||
Timeout::new(5000, move || {
|
||
set_error_message.set(None);
|
||
})
|
||
.forget();
|
||
});
|
||
} else {
|
||
// No URL scene slug - use entry scene
|
||
let Some(scene) = entry_scene.get().flatten() else {
|
||
return;
|
||
};
|
||
initial_scene_handled.set_value(true);
|
||
set_channel_id.set(Some(scene.id));
|
||
set_current_scene.set(Some(scene.clone()));
|
||
|
||
// Extract scene dimensions from bounds_wkt
|
||
if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) {
|
||
set_scene_dimensions.set((w as f64, h as f64));
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// Fetch available scenes and realm settings when realm loads
|
||
#[cfg(feature = "hydrate")]
|
||
{
|
||
Effect::new(move |_| {
|
||
let Some(realm_with_role) = realm_data.get().flatten() else {
|
||
return;
|
||
};
|
||
|
||
// Set allow_user_teleport from realm settings
|
||
set_allow_user_teleport.set(realm_with_role.realm.allow_user_teleport);
|
||
|
||
// Fetch scenes list for teleport command
|
||
let current_slug = slug.get();
|
||
if current_slug.is_empty() {
|
||
return;
|
||
}
|
||
|
||
spawn_local(async move {
|
||
use gloo_net::http::Request;
|
||
let response = Request::get(&format!("/api/realms/{}/scenes", current_slug))
|
||
.send()
|
||
.await;
|
||
if let Ok(resp) = response {
|
||
if resp.ok() {
|
||
if let Ok(scenes) = resp.json::<Vec<SceneSummary>>().await {
|
||
// Filter out hidden scenes
|
||
let visible_scenes: Vec<SceneSummary> = scenes
|
||
.into_iter()
|
||
.filter(|s| !s.is_hidden)
|
||
.collect();
|
||
set_available_scenes.set(visible_scenes);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// Cleanup expired speech bubbles and fading members every second
|
||
#[cfg(feature = "hydrate")]
|
||
{
|
||
use gloo_timers::callback::Interval;
|
||
|
||
let cleanup_interval = Interval::new(1000, move || {
|
||
let now = js_sys::Date::now() as i64;
|
||
// Clean up expired bubbles
|
||
set_active_bubbles.update(|bubbles| {
|
||
bubbles.retain(|_, bubble| bubble.expires_at > now);
|
||
});
|
||
// Clean up completed fading members
|
||
set_fading_members.update(|members| {
|
||
members.retain(|m| now - m.fade_start < FADE_DURATION_MS);
|
||
});
|
||
});
|
||
// Keep interval alive
|
||
std::mem::forget(cleanup_interval);
|
||
}
|
||
|
||
// Handle position update via WebSocket
|
||
#[cfg(feature = "hydrate")]
|
||
let on_move = Callback::new(move |(x, y): (f64, f64)| {
|
||
// Track position for saving on beforeunload
|
||
set_current_position.set((x, y));
|
||
ws_sender.with_value(|sender| {
|
||
if let Some(send_fn) = sender {
|
||
send_fn(ClientMessage::UpdatePosition { x, y });
|
||
}
|
||
});
|
||
});
|
||
|
||
#[cfg(not(feature = "hydrate"))]
|
||
let on_move = Callback::new(move |(_x, _y): (f64, f64)| {});
|
||
|
||
// Handle prop click (pickup) via WebSocket
|
||
#[cfg(feature = "hydrate")]
|
||
let on_prop_click = Callback::new(move |prop_id: Uuid| {
|
||
ws_sender.with_value(|sender| {
|
||
if let Some(send_fn) = sender {
|
||
send_fn(ClientMessage::PickUpProp {
|
||
loose_prop_id: prop_id,
|
||
});
|
||
}
|
||
});
|
||
});
|
||
|
||
#[cfg(not(feature = "hydrate"))]
|
||
let on_prop_click = Callback::new(move |_prop_id: Uuid| {});
|
||
|
||
// Handle global keyboard shortcuts (e+0-9 for emotions, : for chat focus)
|
||
#[cfg(feature = "hydrate")]
|
||
{
|
||
use std::cell::RefCell;
|
||
use std::rc::Rc;
|
||
use wasm_bindgen::{JsCast, closure::Closure};
|
||
|
||
let closure_holder: Rc<RefCell<Option<Closure<dyn Fn(web_sys::KeyboardEvent)>>>> =
|
||
Rc::new(RefCell::new(None));
|
||
let closure_holder_clone = closure_holder.clone();
|
||
|
||
Effect::new(move |_| {
|
||
// Cleanup previous closure if any
|
||
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(),
|
||
);
|
||
}
|
||
}
|
||
|
||
let current_slug = slug.get();
|
||
if current_slug.is_empty() {
|
||
return;
|
||
}
|
||
|
||
// Track if 'e' was pressed (for e+0-9 emotion sequence)
|
||
let e_pressed: Rc<RefCell<bool>> = Rc::new(RefCell::new(false));
|
||
let e_pressed_clone = e_pressed.clone();
|
||
|
||
let closure = Closure::new(move |ev: web_sys::KeyboardEvent| {
|
||
let key = ev.key();
|
||
|
||
// If chat is focused, let it handle all keys
|
||
// Use get_untracked() since we're in a JS event handler, not a reactive context
|
||
if chat_focused.get_untracked() {
|
||
*e_pressed_clone.borrow_mut() = false;
|
||
return;
|
||
}
|
||
|
||
// Handle space to focus chat input (no prefix)
|
||
if key == " " {
|
||
set_focus_prefix.set(' ');
|
||
set_focus_chat_trigger.set(true);
|
||
use gloo_timers::callback::Timeout;
|
||
Timeout::new(100, move || {
|
||
set_focus_chat_trigger.set(false);
|
||
})
|
||
.forget();
|
||
ev.prevent_default();
|
||
return;
|
||
}
|
||
|
||
// Handle ':' to focus chat input with colon prefix
|
||
if key == ":" {
|
||
set_focus_prefix.set(':');
|
||
set_focus_chat_trigger.set(true);
|
||
// Reset trigger after a short delay so it can be triggered again
|
||
use gloo_timers::callback::Timeout;
|
||
Timeout::new(100, move || {
|
||
set_focus_chat_trigger.set(false);
|
||
})
|
||
.forget();
|
||
ev.prevent_default();
|
||
return;
|
||
}
|
||
|
||
// Handle '/' to focus chat input with slash prefix
|
||
if key == "/" {
|
||
set_focus_prefix.set('/');
|
||
set_focus_chat_trigger.set(true);
|
||
use gloo_timers::callback::Timeout;
|
||
Timeout::new(100, move || {
|
||
set_focus_chat_trigger.set(false);
|
||
})
|
||
.forget();
|
||
ev.prevent_default();
|
||
return;
|
||
}
|
||
|
||
// Handle 's' to toggle settings
|
||
if key == "s" || key == "S" {
|
||
set_settings_open.update(|v| *v = !*v);
|
||
ev.prevent_default();
|
||
return;
|
||
}
|
||
|
||
// Handle arrow keys for panning (only in pan mode)
|
||
let settings = viewer_settings.get_untracked();
|
||
if settings.panning_enabled {
|
||
let pan_step = 50.0;
|
||
let scroll_delta = match key.as_str() {
|
||
"ArrowLeft" => Some((-pan_step, 0.0)),
|
||
"ArrowRight" => Some((pan_step, 0.0)),
|
||
"ArrowUp" => Some((0.0, -pan_step)),
|
||
"ArrowDown" => Some((0.0, pan_step)),
|
||
_ => None,
|
||
};
|
||
|
||
if let Some((dx, dy)) = scroll_delta {
|
||
// Find the scene container and scroll it
|
||
if let Some(document) = web_sys::window().and_then(|w| w.document()) {
|
||
if let Some(container) =
|
||
document.query_selector(".scene-container").ok().flatten()
|
||
{
|
||
let container_el: web_sys::Element = container;
|
||
container_el.scroll_by_with_x_and_y(dx, dy);
|
||
}
|
||
}
|
||
ev.prevent_default();
|
||
return;
|
||
}
|
||
|
||
// Handle +/- for zoom
|
||
let zoom_delta = match key.as_str() {
|
||
"+" | "=" => Some(0.25),
|
||
"-" | "_" => Some(-0.25),
|
||
_ => None,
|
||
};
|
||
|
||
if let Some(delta) = zoom_delta {
|
||
viewer_settings.update(|s| s.adjust_zoom(delta));
|
||
viewer_settings.get_untracked().save();
|
||
ev.prevent_default();
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Handle 'i' to toggle inventory
|
||
if key == "i" || key == "I" {
|
||
set_inventory_open.update(|v| *v = !*v);
|
||
ev.prevent_default();
|
||
return;
|
||
}
|
||
|
||
// Handle 'k' to toggle keybindings
|
||
if key == "k" || key == "K" {
|
||
set_keybindings_open.update(|v| *v = !*v);
|
||
ev.prevent_default();
|
||
return;
|
||
}
|
||
|
||
// Handle 'a' to toggle avatar editor
|
||
if key == "a" || key == "A" {
|
||
set_avatar_editor_open.update(|v| *v = !*v);
|
||
ev.prevent_default();
|
||
return;
|
||
}
|
||
|
||
// Handle 'l' to toggle message log
|
||
if key == "l" || key == "L" {
|
||
set_log_open.update(|v| *v = !*v);
|
||
ev.prevent_default();
|
||
return;
|
||
}
|
||
|
||
// Handle '?' to show hotkey help (while held)
|
||
if key == "?" {
|
||
set_hotkey_help_visible.set(true);
|
||
ev.prevent_default();
|
||
return;
|
||
}
|
||
|
||
// Check if 'e' key was pressed
|
||
if key == "e" || key == "E" {
|
||
*e_pressed_clone.borrow_mut() = true;
|
||
return;
|
||
}
|
||
|
||
// Check for 0-9, q, w after 'e' was pressed (emotion keybindings)
|
||
if *e_pressed_clone.borrow() {
|
||
*e_pressed_clone.borrow_mut() = false; // Reset regardless of outcome
|
||
let bindings = keybindings.get_untracked();
|
||
if let Some(emotion_state) = bindings.get_emotion_for_key(&key) {
|
||
let emotion = emotion_state.to_string();
|
||
#[cfg(debug_assertions)]
|
||
web_sys::console::log_1(
|
||
&format!("[Emotion] Sending emotion {}", emotion).into(),
|
||
);
|
||
ws_sender.with_value(|sender| {
|
||
if let Some(send_fn) = sender {
|
||
send_fn(ClientMessage::UpdateEmotion { emotion });
|
||
}
|
||
});
|
||
ev.prevent_default();
|
||
}
|
||
} else {
|
||
// Any other key resets the 'e' state
|
||
*e_pressed_clone.borrow_mut() = false;
|
||
}
|
||
});
|
||
|
||
if let Some(window) = web_sys::window() {
|
||
let _ = window
|
||
.add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref());
|
||
}
|
||
|
||
// Store the closure for cleanup
|
||
*closure_holder_clone.borrow_mut() = Some(closure);
|
||
|
||
// Add keyup handler for releasing '?' (hotkey help)
|
||
let keyup_closure = Closure::<dyn Fn(web_sys::KeyboardEvent)>::new(
|
||
move |ev: web_sys::KeyboardEvent| {
|
||
if ev.key() == "?" {
|
||
set_hotkey_help_visible.set(false);
|
||
}
|
||
},
|
||
);
|
||
|
||
if let Some(window) = web_sys::window() {
|
||
let _ = window.add_event_listener_with_callback(
|
||
"keyup",
|
||
keyup_closure.as_ref().unchecked_ref(),
|
||
);
|
||
}
|
||
|
||
// Forget the keyup closure (it lives for the duration of the page)
|
||
keyup_closure.forget();
|
||
});
|
||
|
||
// Save position on page unload (beforeunload event)
|
||
Effect::new({
|
||
let ws_sender = ws_sender.clone();
|
||
move |_| {
|
||
let Some(window) = web_sys::window() else {
|
||
return;
|
||
};
|
||
|
||
let handler = Closure::<dyn Fn(web_sys::BeforeUnloadEvent)>::new({
|
||
let ws_sender = ws_sender.clone();
|
||
move |_: web_sys::BeforeUnloadEvent| {
|
||
let (x, y) = current_position.get_untracked();
|
||
ws_sender.with_value(|sender| {
|
||
if let Some(send_fn) = sender {
|
||
send_fn(ClientMessage::UpdatePosition { x, y });
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
let _ = window.add_event_listener_with_callback(
|
||
"beforeunload",
|
||
handler.as_ref().unchecked_ref(),
|
||
);
|
||
handler.forget();
|
||
}
|
||
});
|
||
}
|
||
|
||
// Callback for chat focus changes
|
||
let on_chat_focus_change = Callback::new(move |focused: bool| {
|
||
set_chat_focused.set(focused);
|
||
});
|
||
|
||
// Create logout callback (WebSocket disconnects automatically)
|
||
let on_logout = Callback::new(move |_: ()| {
|
||
#[cfg(feature = "hydrate")]
|
||
{
|
||
use gloo_net::http::Request;
|
||
let navigate = navigate.clone();
|
||
|
||
spawn_local(async move {
|
||
// WebSocket close handles channel leave automatically
|
||
let _: Result<gloo_net::http::Response, gloo_net::Error> =
|
||
Request::post("/api/auth/logout").send().await;
|
||
navigate("/", Default::default());
|
||
});
|
||
}
|
||
});
|
||
|
||
view! {
|
||
<div class="h-screen bg-gray-900 text-white flex flex-col overflow-hidden">
|
||
<Suspense fallback=move || {
|
||
view! {
|
||
<div class="flex items-center justify-center min-h-screen">
|
||
<p class="text-gray-400">"Loading realm..."</p>
|
||
</div>
|
||
}
|
||
}>
|
||
{move || {
|
||
let on_logout = on_logout.clone();
|
||
let on_move = on_move.clone();
|
||
realm_data
|
||
.get()
|
||
.map(|maybe_data| {
|
||
match maybe_data {
|
||
Some(data) => {
|
||
let realm = data.realm;
|
||
let user_role = data.user_role;
|
||
|
||
// Determine if user can access admin
|
||
// Admin visible for: Owner, Moderator, or staff
|
||
let can_admin = matches!(
|
||
user_role,
|
||
Some(RealmRole::Owner) | Some(RealmRole::Moderator)
|
||
);
|
||
|
||
// Get scene name and description for header
|
||
let scene_info = entry_scene
|
||
.get()
|
||
.flatten()
|
||
.map(|s| (s.name.clone(), s.description.clone()))
|
||
.unwrap_or_else(|| ("Loading...".to_string(), None));
|
||
|
||
let realm_name = realm.name.clone();
|
||
let realm_slug_val = realm.slug.clone();
|
||
let realm_description = realm.tagline.clone();
|
||
// Derive online count reactively from members signal
|
||
let online_count = Signal::derive(move || members.get().len() as i32);
|
||
let total_members = realm.member_count;
|
||
let max_capacity = realm.max_users;
|
||
let scene_name = scene_info.0;
|
||
let scene_description = scene_info.1;
|
||
|
||
view! {
|
||
<RealmHeader
|
||
realm_name=realm_name
|
||
realm_slug=realm_slug_val.clone()
|
||
realm_description=realm_description
|
||
scene_name=scene_name
|
||
scene_description=scene_description
|
||
online_count=online_count
|
||
total_members=total_members
|
||
max_capacity=max_capacity
|
||
can_admin=can_admin
|
||
on_logout=on_logout.clone()
|
||
/>
|
||
<main class="flex-1 w-full">
|
||
// Scene viewer - full width
|
||
<Suspense fallback=move || {
|
||
view! {
|
||
<div class="flex items-center justify-center py-12">
|
||
<p class="text-gray-400">"Loading scene..."</p>
|
||
</div>
|
||
}
|
||
}>
|
||
{move || {
|
||
let on_move = on_move.clone();
|
||
let on_prop_click = on_prop_click.clone();
|
||
let on_chat_focus_change = on_chat_focus_change.clone();
|
||
let realm_slug_for_viewer = realm_slug_val.clone();
|
||
#[cfg(feature = "hydrate")]
|
||
let ws_sender_clone = ws_sender.clone();
|
||
// Read current_scene in reactive context (before .map())
|
||
// so changes trigger re-render
|
||
let current_scene_val = current_scene.get();
|
||
entry_scene
|
||
.get()
|
||
.map(|maybe_scene| {
|
||
match maybe_scene {
|
||
Some(entry_scene_data) => {
|
||
// Use current_scene if set (after teleport), otherwise use entry scene
|
||
let display_scene = current_scene_val.clone().unwrap_or_else(|| entry_scene_data.clone());
|
||
let members_signal = Signal::derive(move || members.get());
|
||
let emotion_avail_signal = Signal::derive(move || emotion_availability.get());
|
||
let skin_path_signal = Signal::derive(move || skin_preview_path.get());
|
||
let focus_trigger_signal = Signal::derive(move || focus_chat_trigger.get());
|
||
#[cfg(feature = "hydrate")]
|
||
let ws_for_chat = ws_sender_clone.clone();
|
||
#[cfg(not(feature = "hydrate"))]
|
||
let ws_for_chat: StoredValue<Option<WsSender>, LocalStorage> = StoredValue::new_local(None);
|
||
let active_bubbles_signal = Signal::derive(move || active_bubbles.get());
|
||
let loose_props_signal = Signal::derive(move || loose_props.get());
|
||
let focus_prefix_signal = Signal::derive(move || focus_prefix.get());
|
||
let on_open_settings_cb = Callback::new(move |_: ()| {
|
||
set_settings_open.set(true);
|
||
});
|
||
let on_open_inventory_cb = Callback::new(move |_: ()| {
|
||
set_inventory_open.set(true);
|
||
});
|
||
let on_open_log_cb = Callback::new(move |_: ()| {
|
||
set_log_open.set(true);
|
||
});
|
||
let whisper_target_signal = Signal::derive(move || whisper_target.get());
|
||
let on_whisper_request_cb = Callback::new(move |target: String| {
|
||
set_whisper_target.set(Some(target));
|
||
});
|
||
let scenes_signal = Signal::derive(move || available_scenes.get());
|
||
let teleport_enabled_signal = Signal::derive(move || allow_user_teleport.get());
|
||
#[cfg(feature = "hydrate")]
|
||
let ws_for_teleport = ws_sender_clone.clone();
|
||
let on_teleport_cb = Callback::new(move |scene_id: Uuid| {
|
||
#[cfg(feature = "hydrate")]
|
||
ws_for_teleport.with_value(|sender| {
|
||
if let Some(send_fn) = sender {
|
||
send_fn(ClientMessage::Teleport { scene_id });
|
||
}
|
||
});
|
||
});
|
||
view! {
|
||
<div class="relative w-full">
|
||
<RealmSceneViewer
|
||
scene=display_scene
|
||
realm_slug=realm_slug_for_viewer.clone()
|
||
members=members_signal
|
||
active_bubbles=active_bubbles_signal
|
||
loose_props=loose_props_signal
|
||
on_move=on_move.clone()
|
||
on_prop_click=on_prop_click.clone()
|
||
settings=Signal::derive(move || viewer_settings.get())
|
||
on_zoom_change=Callback::new(move |delta: f64| {
|
||
viewer_settings.update(|s| {
|
||
s.adjust_zoom(delta);
|
||
s.save();
|
||
});
|
||
})
|
||
fading_members=Signal::derive(move || fading_members.get())
|
||
current_user_id=Signal::derive(move || current_user_id.get())
|
||
current_guest_session_id=Signal::derive(move || current_guest_session_id.get())
|
||
is_guest=Signal::derive(move || is_guest.get())
|
||
on_whisper_request=on_whisper_request_cb
|
||
/>
|
||
<div class="absolute bottom-0 left-0 right-0 z-10 pb-4 px-4 pointer-events-none">
|
||
<ChatInput
|
||
ws_sender=ws_for_chat
|
||
emotion_availability=emotion_avail_signal
|
||
skin_preview_path=skin_path_signal
|
||
focus_trigger=focus_trigger_signal
|
||
focus_prefix=focus_prefix_signal
|
||
on_focus_change=on_chat_focus_change.clone()
|
||
on_open_settings=on_open_settings_cb
|
||
on_open_inventory=on_open_inventory_cb
|
||
on_open_log=on_open_log_cb
|
||
whisper_target=whisper_target_signal
|
||
scenes=scenes_signal
|
||
allow_user_teleport=teleport_enabled_signal
|
||
on_teleport=on_teleport_cb
|
||
/>
|
||
</div>
|
||
</div>
|
||
}
|
||
.into_any()
|
||
}
|
||
None => {
|
||
view! {
|
||
<div class="max-w-4xl mx-auto px-4 py-8">
|
||
<Card class="p-8 text-center">
|
||
<p class="text-gray-400">
|
||
"No scenes have been created for this realm yet."
|
||
</p>
|
||
</Card>
|
||
</div>
|
||
}
|
||
.into_any()
|
||
}
|
||
}
|
||
})
|
||
}}
|
||
</Suspense>
|
||
</main>
|
||
|
||
// Inventory popup
|
||
{
|
||
#[cfg(feature = "hydrate")]
|
||
let ws_sender_for_inv = ws_sender.clone();
|
||
#[cfg(not(feature = "hydrate"))]
|
||
let ws_sender_for_inv: StoredValue<Option<WsSender>, LocalStorage> = StoredValue::new_local(None);
|
||
view! {
|
||
<InventoryPopup
|
||
open=Signal::derive(move || inventory_open.get())
|
||
on_close=Callback::new(move |_: ()| {
|
||
set_inventory_open.set(false);
|
||
})
|
||
ws_sender=ws_sender_for_inv
|
||
realm_slug=Signal::derive(move || slug.get())
|
||
is_guest=Signal::derive(move || is_guest.get())
|
||
/>
|
||
}
|
||
}
|
||
|
||
// Settings popup
|
||
<SettingsPopup
|
||
open=Signal::derive(move || settings_open.get())
|
||
settings=viewer_settings
|
||
on_close=Callback::new(move |_: ()| {
|
||
set_settings_open.set(false);
|
||
})
|
||
scene_dimensions=scene_dimensions.get()
|
||
/>
|
||
|
||
// Log popup
|
||
<LogPopup
|
||
open=Signal::derive(move || log_open.get())
|
||
message_log=message_log
|
||
on_close=Callback::new(move |_: ()| {
|
||
set_log_open.set(false);
|
||
})
|
||
/>
|
||
|
||
// Keybindings popup
|
||
<KeybindingsPopup
|
||
open=Signal::derive(move || keybindings_open.get())
|
||
keybindings=keybindings
|
||
emotion_availability=Signal::derive(move || emotion_availability.get())
|
||
skin_preview_path=Signal::derive(move || skin_preview_path.get())
|
||
on_close=Callback::new(move |_: ()| set_keybindings_open.set(false))
|
||
/>
|
||
|
||
// Avatar editor popup
|
||
{
|
||
#[cfg(feature = "hydrate")]
|
||
let ws_sender_for_avatar = ws_sender.clone();
|
||
#[cfg(not(feature = "hydrate"))]
|
||
let ws_sender_for_avatar: StoredValue<Option<WsSender>, LocalStorage> = StoredValue::new_local(None);
|
||
view! {
|
||
<AvatarEditorPopup
|
||
open=Signal::derive(move || avatar_editor_open.get())
|
||
on_close=Callback::new(move |_: ()| set_avatar_editor_open.set(false))
|
||
avatar=Signal::derive(move || full_avatar.get())
|
||
realm_slug=Signal::derive(move || slug.get())
|
||
on_avatar_update=Callback::new(move |updated: AvatarWithPaths| {
|
||
set_full_avatar.set(Some(updated.clone()));
|
||
// Update emotion availability for the emotion picker
|
||
let avail = updated.compute_emotion_availability();
|
||
set_emotion_availability.set(Some(avail));
|
||
// Update skin preview
|
||
set_skin_preview_path.set(updated.skin_layer[4].clone());
|
||
})
|
||
ws_sender=ws_sender_for_avatar
|
||
is_guest=Signal::derive(move || is_guest.get())
|
||
/>
|
||
}
|
||
}
|
||
|
||
// 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
|
||
/>
|
||
}
|
||
}
|
||
|
||
// Reconnection overlay - shown when WebSocket disconnects
|
||
{
|
||
#[cfg(feature = "hydrate")]
|
||
let ws_state_for_overlay = ws_state;
|
||
#[cfg(not(feature = "hydrate"))]
|
||
let ws_state_for_overlay = Signal::derive(|| crate::components::ws_client::WsState::Disconnected);
|
||
view! {
|
||
<ReconnectionOverlay
|
||
ws_state=ws_state_for_overlay
|
||
on_reconnect=Callback::new(move |_: ()| {
|
||
reconnect_trigger.update(|t| *t += 1);
|
||
})
|
||
/>
|
||
}
|
||
}
|
||
|
||
// Hotkey help overlay (shown while ? is held)
|
||
<HotkeyHelp visible=Signal::derive(move || hotkey_help_visible.get()) />
|
||
}
|
||
.into_any()
|
||
}
|
||
None => {
|
||
view! {
|
||
<div class="flex items-center justify-center min-h-screen">
|
||
<Card class="p-8 text-center max-w-md">
|
||
<div class="mx-auto w-20 h-20 rounded-full bg-red-900/20 flex items-center justify-center mb-4">
|
||
<img
|
||
src="/icons/x.svg"
|
||
alt=""
|
||
class="w-10 h-10"
|
||
aria-hidden="true"
|
||
/>
|
||
</div>
|
||
<h2 class="text-xl font-semibold text-white mb-2">
|
||
"Realm Not Found"
|
||
</h2>
|
||
<p class="text-gray-400 mb-6">
|
||
"The realm you're looking for doesn't exist or you don't have access."
|
||
</p>
|
||
<a href="/" class="btn-primary inline-block">
|
||
"Back to Home"
|
||
</a>
|
||
</Card>
|
||
</div>
|
||
}
|
||
.into_any()
|
||
}
|
||
}
|
||
})
|
||
}}
|
||
</Suspense>
|
||
</div>
|
||
}
|
||
}
|