//! 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::::new()); let (channel_id, set_channel_id) = signal(Option::::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::::None); // Skin preview path for emote picker (position 4 of skin layer) let (skin_preview_path, set_skin_preview_path) = signal(Option::::None); // Chat message state - use StoredValue for WASM compatibility (single-threaded) let message_log: StoredValue = StoredValue::new_local(MessageLog::new()); let (active_bubbles, set_active_bubbles) = signal(HashMap::<(Option, Option), 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::::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::::new()); // Fading members state (members that are fading out after timeout disconnect) let (fading_members, set_fading_members) = signal(Vec::::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::::None); let (current_guest_session_id, set_current_guest_session_id) = signal(Option::::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::::None); // Notification state for cross-scene whispers let (current_notification, set_current_notification) = signal(Option::::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, 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::::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::::None); // Available scenes for teleportation (cached on load) let (available_scenes, set_available_scenes) = signal(Vec::::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::().await.ok(), _ => None, } } #[cfg(not(feature = "hydrate"))] { let _ = slug; None:: } } }); // 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::().await.ok(), _ => None, } } #[cfg(not(feature = "hydrate"))] { let _ = slug; None:: } } }); // 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::().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| { // 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| { 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::().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::().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::>().await { // Filter out hidden scenes let visible_scenes: Vec = 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>>> = 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> = 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::::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::::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 = Request::post("/api/auth/logout").send().await; navigate("/", Default::default()); }); } }); view! {

"Loading realm..."

} }> {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! {
// Scene viewer - full width

"Loading scene..."

} }> {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, 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! {
} .into_any() } None => { view! {

"No scenes have been created for this realm yet."

} .into_any() } } }) }}
// Inventory popup { #[cfg(feature = "hydrate")] let ws_sender_for_inv = ws_sender.clone(); #[cfg(not(feature = "hydrate"))] let ws_sender_for_inv: StoredValue, LocalStorage> = StoredValue::new_local(None); view! { } } // Settings popup // Log popup // Keybindings popup // Avatar editor popup { #[cfg(feature = "hydrate")] let ws_sender_for_avatar = ws_sender.clone(); #[cfg(not(feature = "hydrate"))] let ws_sender_for_avatar: StoredValue, LocalStorage> = StoredValue::new_local(None); view! { } } // Notification toast for cross-scene whispers // Error toast (whisper failures, etc.) {move || { if let Some(msg) = error_message.get() { view! {
"⚠" {msg}
}.into_any() } else { ().into_any() } }}
// Notification history modal // Conversation modal { #[cfg(feature = "hydrate")] let ws_sender_for_convo = ws_sender.clone(); #[cfg(not(feature = "hydrate"))] let ws_sender_for_convo: StoredValue, 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::>() }) }); #[cfg(not(feature = "hydrate"))] let filtered_whispers: Signal> = Signal::derive(|| Vec::new()); view! { } } // 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! { } } // Hotkey help overlay (shown while ? is held) } .into_any() } None => { view! {

"Realm Not Found"

"The realm you're looking for doesn't exist or you don't have access."

"Back to Home"
} .into_any() } } }) }} } }