chattyness/crates/chattyness-user-ui/src/pages/realm.rs

1586 lines
80 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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,
NotificationMessage, NotificationToast, RealmHeader, RealmSceneViewer, ReconnectionOverlay,
RegisterModal, SettingsPopup, ViewerSettings,
};
#[cfg(feature = "hydrate")]
use crate::components::{
ChannelMemberInfo, ChatMessage, DEFAULT_BUBBLE_TIMEOUT_MS, FADE_DURATION_MS,
MemberIdentityInfo, ModCommandResultInfo, SummonInfo, TeleportInfo, WsError,
add_whisper_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);
// Register modal state (for guest-to-user conversion)
let (register_modal_open, set_register_modal_open) = signal(false);
// 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 = RwSignal::new(Option::<String>::None);
// Notification state for cross-scene whispers
let (current_notification, set_current_notification) =
signal(Option::<NotificationMessage>::None);
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);
// Whether the current user is a moderator (set from Welcome message or membership)
let (is_moderator, set_is_moderator) = signal(false);
// Mod notification state (for summon notifications, command results)
let (mod_notification, set_mod_notification) = signal(Option::<(bool, String)>::None);
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 persistent whisper history in LocalStorage
add_whisper_to_history(msg.clone());
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| {
// Log teleport to message log
let teleport_msg = ChatMessage {
message_id: Uuid::new_v4(),
user_id: None,
guest_session_id: None,
display_name: "[SYSTEM]".to_string(),
content: format!("Teleported to scene: {}", info.scene_slug),
emotion: "neutral".to_string(),
x: 0.0,
y: 0.0,
timestamp: js_sys::Date::now() as i64,
is_whisper: false,
is_same_scene: true,
is_system: true,
};
message_log.update_value(|log| log.push(teleport_msg));
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);
});
});
// Callback for being summoned by a moderator - show notification and teleport
#[cfg(feature = "hydrate")]
let on_summoned = Callback::new(move |info: SummonInfo| {
// Log summon to message log
let summon_msg = ChatMessage {
message_id: Uuid::new_v4(),
user_id: None,
guest_session_id: None,
display_name: "[MOD]".to_string(),
content: format!("Summoned by {} to scene: {}", info.summoned_by, info.scene_slug),
emotion: "neutral".to_string(),
x: 0.0,
y: 0.0,
timestamp: js_sys::Date::now() as i64,
is_whisper: false,
is_same_scene: true,
is_system: true,
};
message_log.update_value(|log| log.push(summon_msg));
// Show notification
set_mod_notification.set(Some((true, format!("Summoned by {}", info.summoned_by))));
// Auto-dismiss notification after 3 seconds
let timeout = gloo_timers::callback::Timeout::new(3000, move || {
set_mod_notification.set(None);
});
timeout.forget();
let scene_id = info.scene_id;
let scene_slug = info.scene_slug.clone();
let realm_slug = slug.get_untracked();
// Fetch the new scene data (same as teleport approval)
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);
});
});
// Callback for mod command result - show notification
#[cfg(feature = "hydrate")]
let on_mod_command_result = Callback::new(move |info: ModCommandResultInfo| {
// Log mod command result to message log
let status = if info.success { "OK" } else { "FAILED" };
let mod_msg = ChatMessage {
message_id: Uuid::new_v4(),
user_id: None,
guest_session_id: None,
display_name: "[MOD]".to_string(),
content: format!("[{}] {}", status, info.message),
emotion: "neutral".to_string(),
x: 0.0,
y: 0.0,
timestamp: js_sys::Date::now() as i64,
is_whisper: false,
is_same_scene: true,
is_system: true,
};
message_log.update_value(|log| log.push(mod_msg));
set_mod_notification.set(Some((info.success, info.message)));
// Auto-dismiss notification after 3 seconds
let timeout = gloo_timers::callback::Timeout::new(3000, move || {
set_mod_notification.set(None);
});
timeout.forget();
});
// Callback for member identity updates (e.g., guest registered as user)
#[cfg(feature = "hydrate")]
let on_member_identity_updated = Callback::new(move |info: MemberIdentityInfo| {
// Update the member's display name in the members list
set_members.update(|members| {
if let Some(member) = members
.iter_mut()
.find(|m| m.member.user_id == Some(info.user_id))
{
member.member.display_name = info.display_name.clone();
}
});
});
#[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),
Some(on_summoned),
Some(on_mod_command_result),
Some(on_member_identity_updated),
);
// 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;
}
// If any input or textarea is focused (e.g., modal forms), skip hotkeys
if let Some(document) = web_sys::window().and_then(|w| w.document()) {
if let Some(active) = document.active_element() {
let tag = active.tag_name().to_lowercase();
if tag == "input" || tag == "textarea" {
*e_pressed_clone.borrow_mut() = false;
return;
}
}
}
// If any modal is open, skip hotkeys (modals handle their own Escape key)
if settings_open.get_untracked()
|| inventory_open.get_untracked()
|| log_open.get_untracked()
|| keybindings_open.get_untracked()
|| avatar_editor_open.get_untracked()
|| register_modal_open.get_untracked()
|| conversation_modal_open.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)
// We hide on any keyup when visible, since ? = Shift+/ and releasing
// either key means the user is no longer holding '?'
let keyup_closure = Closure::<dyn Fn(web_sys::KeyboardEvent)>::new(
move |ev: web_sys::KeyboardEvent| {
let key = ev.key();
// Hide if releasing ?, /, or Shift while help is visible
if hotkey_help_visible.get_untracked()
&& (key == "?" || key == "/" || key == "Shift")
{
set_hotkey_help_visible.set(false);
ev.prevent_default();
}
},
);
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)
);
// Update is_moderator signal for mod commands
set_is_moderator.set(can_admin);
// 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 on_open_register_cb = Callback::new(move |_: ()| {
set_register_modal_open.set(true);
});
let on_whisper_request_cb = Callback::new(move |target: String| {
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 });
}
});
});
#[cfg(feature = "hydrate")]
let ws_for_mod = ws_sender_clone.clone();
let on_mod_command_cb = Callback::new(move |(subcommand, args): (String, Vec<String>)| {
#[cfg(feature = "hydrate")]
ws_for_mod.with_value(|sender| {
if let Some(send_fn) = sender {
send_fn(ClientMessage::ModCommand { subcommand, args });
}
});
});
let is_moderator_signal = Signal::derive(move || is_moderator.get());
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
scenes=scenes_signal
allow_user_teleport=teleport_enabled_signal
on_teleport=on_teleport_cb
is_moderator=is_moderator_signal
on_mod_command=on_mod_command_cb
is_guest=Signal::derive(move || is_guest.get())
on_open_register=on_open_register_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);
})
on_reply=Callback::new(move |name: String| {
whisper_target.set(Some(name));
})
on_context=Callback::new(move |name: String| {
set_conversation_partner.set(name);
set_conversation_modal_open.set(true);
})
/>
// 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())
/>
}
}
// Registration modal for guest-to-user conversion
{
#[cfg(feature = "hydrate")]
let ws_sender_for_register = ws_sender.clone();
view! {
<RegisterModal
open=Signal::derive(move || register_modal_open.get())
on_close=Callback::new(move |_: ()| set_register_modal_open.set(false))
on_success=Callback::new(move |username: String| {
// Update is_guest to false since they're now a registered user
set_is_guest.set(false);
// Send RefreshIdentity to update display name for all channel members
#[cfg(feature = "hydrate")]
ws_sender_for_register.with_value(|sender| {
if let Some(send_fn) = sender {
send_fn(ClientMessage::RefreshIdentity);
}
});
// Show success notification
set_mod_notification.set(Some((true, format!("Welcome, {}! Your account has been created.", username))));
// Auto-dismiss after 5 seconds
#[cfg(feature = "hydrate")]
{
let timeout = gloo_timers::callback::Timeout::new(5000, move || {
set_mod_notification.set(None);
});
timeout.forget();
}
})
/>
}
}
// Notification toast for cross-scene whispers
<NotificationToast
notification=Signal::derive(move || current_notification.get())
on_reply=Callback::new(move |name: String| {
whisper_target.set(Some(name));
})
on_context=Callback::new(move |name: String| {
set_conversation_partner.set(name);
set_conversation_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>
// Mod command notification toast (summon, command results)
<Show when=move || mod_notification.get().is_some()>
{move || {
if let Some((success, msg)) = mod_notification.get() {
let (bg_class, border_class, icon_class, icon) = if success {
("bg-purple-900/90", "border-purple-500/50", "text-purple-300", "")
} else {
("bg-yellow-900/90", "border-yellow-500/50", "text-yellow-300", "")
};
view! {
<div class="fixed top-4 left-1/2 -translate-x-1/2 z-50 animate-slide-in-down">
<div class=format!("{} border {} rounded-lg shadow-lg px-6 py-3 flex items-center gap-3", bg_class, border_class)>
<span class=format!("{} text-lg font-bold", icon_class)>"[MOD]"</span>
<span class=format!("{} text-lg", icon_class)>{icon}</span>
<span class="text-gray-200">{msg}</span>
<button
class="text-gray-400 hover:text-white ml-2"
on:click=move |_| set_mod_notification.set(None)
>
"×"
</button>
</div>
</div>
}.into_any()
} else {
().into_any()
}
}}
</Show>
// 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>
}
}