fix some emotion bugs

This commit is contained in:
Evan Carroll 2026-01-13 14:08:38 -06:00
parent bd28e201a2
commit 989e20757b
11 changed files with 1203 additions and 190 deletions

View file

@ -1,5 +1,7 @@
//! Realm landing page after login.
use std::collections::HashMap;
use leptos::prelude::*;
use leptos::reactive::owner::LocalStorage;
#[cfg(feature = "hydrate")]
@ -7,12 +9,17 @@ 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::{Card, ChatInput, RealmHeader, RealmSceneViewer};
use crate::components::{
ActiveBubble, Card, ChatInput, ChatMessage, MessageLog, RealmHeader, RealmSceneViewer,
DEFAULT_BUBBLE_TIMEOUT_MS,
};
#[cfg(feature = "hydrate")]
use crate::components::use_channel_websocket;
use chattyness_db::models::{
ChannelMemberWithAvatar, EmotionAvailability, RealmRole, RealmWithUserRole, Scene,
AvatarWithPaths, ChannelMemberWithAvatar, EmotionAvailability, RealmRole, RealmWithUserRole,
Scene,
};
#[cfg(feature = "hydrate")]
use chattyness_db::ws_messages::ClientMessage;
@ -42,6 +49,12 @@ pub fn RealmPage() -> impl IntoView {
// 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());
let realm_data = LocalResource::new(move || {
let slug = slug.get();
async move {
@ -93,45 +106,31 @@ pub fn RealmPage() -> impl IntoView {
}
});
// Fetch emotion availability and avatar render data for emote picker
// Fetch full avatar with paths for client-side emotion computation
#[cfg(feature = "hydrate")]
{
let slug_for_emotions = slug.clone();
let slug_for_avatar = slug.clone();
Effect::new(move |_| {
use gloo_net::http::Request;
let current_slug = slug_for_emotions.get();
let current_slug = slug_for_avatar.get();
if current_slug.is_empty() {
return;
}
// Fetch emotion availability
let slug_clone = current_slug.clone();
// Fetch full avatar with all paths resolved
spawn_local(async move {
let response = Request::get(&format!("/api/realms/{}/avatar/emotions", slug_clone))
let response = Request::get(&format!("/api/realms/{}/avatar", current_slug))
.send()
.await;
if let Ok(resp) = response {
if resp.ok() {
if let Ok(avail) = resp.json::<EmotionAvailability>().await {
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));
}
}
}
});
// Fetch avatar render data for skin preview
let slug_clone2 = current_slug.clone();
spawn_local(async move {
use chattyness_db::models::AvatarRenderData;
let response = Request::get(&format!("/api/realms/{}/avatar/current", slug_clone2))
.send()
.await;
if let Ok(resp) = response {
if resp.ok() {
if let Ok(render_data) = resp.json::<AvatarRenderData>().await {
// Get skin layer position 4 (center)
set_skin_preview_path.set(render_data.skin_layer[4].clone());
// Get skin layer position 4 (center) for preview
set_skin_preview_path.set(avatar.skin_layer[4].clone());
}
}
}
@ -145,11 +144,33 @@ pub fn RealmPage() -> impl IntoView {
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()));
// Update active bubbles
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,
},
);
});
});
#[cfg(feature = "hydrate")]
let (_ws_state, ws_sender) = use_channel_websocket(
slug,
Signal::derive(move || channel_id.get()),
on_members_update,
on_chat_message,
);
// Set channel ID when scene loads (triggers WebSocket connection)
@ -163,6 +184,21 @@ pub fn RealmPage() -> impl IntoView {
});
}
// Cleanup expired speech bubbles every 5 seconds
#[cfg(feature = "hydrate")]
{
use gloo_timers::callback::Interval;
let cleanup_interval = Interval::new(5000, move || {
let now = js_sys::Date::now() as i64;
set_active_bubbles.update(|bubbles| {
bubbles.retain(|_, bubble| bubble.expires_at > now);
});
});
// 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)| {
@ -211,7 +247,8 @@ pub fn RealmPage() -> impl IntoView {
let key = ev.key();
// If chat is focused, let it handle all keys
if chat_focused.get() {
// 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;
}
@ -374,12 +411,14 @@ pub fn RealmPage() -> impl IntoView {
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());
view! {
<div class="relative w-full">
<RealmSceneViewer
scene=scene
realm_slug=realm_slug_for_viewer.clone()
members=members_signal
active_bubbles=active_bubbles_signal
on_move=on_move.clone()
/>
<div class="absolute bottom-0 left-0 right-0 z-10 pb-4 px-4 pointer-events-none">