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,61 +1,42 @@
//! Avatar API handlers for user UI.
//!
//! Handles avatar rendering data retrieval.
//! Note: Emotion switching is now handled via WebSocket.
//! Handles avatar data retrieval.
//! Note: Emotion switching is handled via WebSocket.
use axum::{
extract::{Path, State},
Json,
};
use sqlx::PgPool;
use axum::extract::Path;
use axum::Json;
use chattyness_db::{
models::{AvatarRenderData, EmotionAvailability},
models::AvatarWithPaths,
queries::{avatars, realms},
};
use chattyness_error::AppError;
use crate::auth::AuthUser;
use crate::auth::{AuthUser, RlsConn};
/// Get current avatar render data.
/// Get full avatar with all paths resolved.
///
/// GET /api/realms/{slug}/avatar/current
/// GET /api/realms/{slug}/avatar
///
/// Returns the render data for the user's active avatar in this realm.
pub async fn get_current_avatar(
State(pool): State<PgPool>,
/// Returns the complete avatar data with all inventory UUIDs resolved to asset paths.
/// This enables client-side emotion availability computation and rendering without
/// additional server queries.
pub async fn get_avatar(
rls_conn: RlsConn,
AuthUser(user): AuthUser,
Path(slug): Path<String>,
) -> Result<Json<AvatarRenderData>, AppError> {
) -> Result<Json<AvatarWithPaths>, AppError> {
let mut conn = rls_conn.acquire().await;
// Get realm
let realm = realms::get_realm_by_slug(&pool, &slug)
let realm = realms::get_realm_by_slug(&mut *conn, &slug)
.await?
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
// Get render data
let render_data = avatars::get_avatar_render_data(&pool, user.id, realm.id).await?;
Ok(Json(render_data))
}
/// Get emotion availability for the user's avatar.
///
/// GET /api/realms/{slug}/avatar/emotions
///
/// Returns which emotions are available (have configured assets) for the user's
/// active avatar in this realm, along with preview paths for the emotion picker UI.
pub async fn get_emotion_availability(
State(pool): State<PgPool>,
AuthUser(user): AuthUser,
Path(slug): Path<String>,
) -> Result<Json<EmotionAvailability>, AppError> {
// Get realm
let realm = realms::get_realm_by_slug(&pool, &slug)
// Get full avatar with paths
let avatar = avatars::get_avatar_with_paths_conn(&mut *conn, user.id, realm.id)
.await?
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
.unwrap_or_default();
// Get emotion availability
let availability = avatars::get_emotion_availability(&pool, user.id, realm.id).await?;
Ok(Json(availability))
Ok(Json(avatar))
}

View file

@ -50,12 +50,5 @@ pub fn api_router() -> Router<AppState> {
get(websocket::ws_handler::<AppState>),
)
// Avatar routes (require authentication)
.route(
"/realms/{slug}/avatar/current",
get(avatars::get_current_avatar),
)
.route(
"/realms/{slug}/avatar/emotions",
get(avatars::get_emotion_availability),
)
.route("/realms/{slug}/avatar", get(avatars::get_avatar))
}

View file

@ -191,7 +191,7 @@ async fn handle_socket(
tracing::info!("[WS] Channel joined");
// Get initial state
let members = match get_members_with_avatars(&mut *conn, channel_id, realm_id).await {
let members = match get_members_with_avatars(&mut conn, channel_id, realm_id).await {
Ok(m) => m,
Err(e) => {
tracing::error!("[WS] Failed to get members: {:?}", e);
@ -231,8 +231,11 @@ async fn handle_socket(
}
// Broadcast join to others
let avatar = avatars::get_avatar_render_data(&mut *conn, user.id, realm_id)
let avatar = avatars::get_avatar_with_paths_conn(&mut *conn, user.id, realm_id)
.await
.ok()
.flatten()
.map(|a| a.to_render_data())
.unwrap_or_default();
let join_msg = ServerMessage::MemberJoined {
member: ChannelMemberWithAvatar { member, avatar },
@ -322,6 +325,36 @@ async fn handle_socket(
// Respond with pong directly (not broadcast)
// This is handled in the send task via individual message
}
ClientMessage::SendChatMessage { content } => {
// Validate message
if content.is_empty() || content.len() > 500 {
continue;
}
// Get member's current position and emotion
let member_info = channel_members::get_channel_member(
&mut *recv_conn,
channel_id,
user_id,
realm_id,
)
.await;
if let Ok(Some(member)) = member_info {
let msg = ServerMessage::ChatMessageReceived {
message_id: Uuid::new_v4(),
user_id: Some(user_id),
guest_session_id: None,
display_name: member.display_name.clone(),
content,
emotion: member.current_emotion as u8,
x: member.position_x,
y: member.position_y,
timestamp: chrono::Utc::now().timestamp_millis(),
};
let _ = tx.send(msg);
}
}
}
}
}
@ -376,25 +409,32 @@ async fn handle_socket(
}
/// Helper: Get all channel members with their avatar render data.
async fn get_members_with_avatars<'e>(
executor: impl sqlx::PgExecutor<'e>,
async fn get_members_with_avatars(
conn: &mut sqlx::pool::PoolConnection<sqlx::Postgres>,
channel_id: Uuid,
realm_id: Uuid,
) -> Result<Vec<ChannelMemberWithAvatar>, AppError> {
// Get members first, then we need to get avatars
// But executor is consumed by the first query, so we need the pool
// Actually, let's just inline this to avoid the complexity
let members = channel_members::get_channel_members(executor, channel_id, realm_id).await?;
// Get members first
let members = channel_members::get_channel_members(&mut **conn, channel_id, realm_id).await?;
// For avatar data, we'll just return default for now since the query
// would need another executor
let result: Vec<ChannelMemberWithAvatar> = members
.into_iter()
.map(|member| ChannelMemberWithAvatar {
member,
avatar: AvatarRenderData::default(),
})
.collect();
// Fetch avatar data for each member using full avatar with paths
// This avoids the CASE statement approach and handles all emotions correctly
let mut result = Vec::with_capacity(members.len());
for member in members {
let avatar = if let Some(user_id) = member.user_id {
// Get full avatar and convert to render data for current emotion
avatars::get_avatar_with_paths_conn(&mut **conn, user_id, realm_id)
.await
.ok()
.flatten()
.map(|a| a.to_render_data())
.unwrap_or_default()
} else {
// Guest users don't have avatars
AvatarRenderData::default()
};
result.push(ChannelMemberWithAvatar { member, avatar });
}
Ok(result)
}

View file

@ -1,6 +1,7 @@
//! Reusable UI components.
pub mod chat;
pub mod chat_types;
pub mod editor;
pub mod forms;
pub mod layout;
@ -9,6 +10,7 @@ pub mod scene_viewer;
pub mod ws_client;
pub use chat::*;
pub use chat_types::*;
pub use editor::*;
pub use forms::*;
pub use layout::*;

View file

@ -163,6 +163,17 @@ pub fn ChatInput(
apply_emotion(emotion_idx);
ev.prevent_default();
}
} else if !msg.trim().is_empty() {
// Send regular chat message
ws_sender.with_value(|sender| {
if let Some(send_fn) = sender {
send_fn(ClientMessage::SendChatMessage {
content: msg.trim().to_string(),
});
}
});
set_message.set(String::new());
ev.prevent_default();
}
}
}
@ -282,7 +293,7 @@ fn EmoteListPopup(
aria-label="Available emotions"
>
<div class="text-gray-400 text-xs mb-2 px-1">"Select an emotion:"</div>
<div class="grid grid-cols-2 gap-1 max-h-64 overflow-y-auto">
<div class="grid grid-cols-3 gap-1 max-h-64 overflow-y-auto">
<For
each=move || available_emotions()
key=|(idx, _, _): &(u8, &str, Option<String>)| *idx
@ -301,7 +312,7 @@ fn EmoteListPopup(
emotion_path=preview_path.clone()
/>
<span class="text-white text-sm">
":"
":e "
{emotion_name}
</span>
</button>

View file

@ -4,10 +4,15 @@
//! - Background canvas: Static, drawn once when scene loads
//! - Avatar canvas: Dynamic, redrawn when members change
use std::collections::HashMap;
use leptos::prelude::*;
use uuid::Uuid;
use chattyness_db::models::{ChannelMemberWithAvatar, Scene};
use super::chat_types::{emotion_bubble_colors, ActiveBubble};
/// Parse bounds WKT to extract width and height.
///
/// Expected format: "POLYGON((0 0, WIDTH 0, WIDTH HEIGHT, 0 HEIGHT, 0 0))"
@ -59,6 +64,8 @@ pub fn RealmSceneViewer(
#[prop(into)]
members: Signal<Vec<ChannelMemberWithAvatar>>,
#[prop(into)]
active_bubbles: Signal<HashMap<(Option<Uuid>, Option<Uuid>), ActiveBubble>>,
#[prop(into)]
on_move: Callback<(f64, f64)>,
) -> impl IntoView {
let dimensions = parse_bounds_dimensions(&scene.bounds_wkt);
@ -225,11 +232,12 @@ pub fn RealmSceneViewer(
});
// =========================================================
// Avatar Effect - runs when members change, redraws avatars only
// Avatar Effect - runs when members or bubbles change
// =========================================================
Effect::new(move |_| {
// Track members signal - this Effect reruns when members change
// Track both signals - this Effect reruns when either changes
let current_members = members.get();
let current_bubbles = active_bubbles.get();
let Some(canvas) = avatar_canvas_ref.get() else {
return;
@ -265,8 +273,21 @@ pub fn RealmSceneViewer(
let ox = offset_x.get_value();
let oy = offset_y.get_value();
// Draw avatars
// Draw avatars first
draw_avatars(&ctx, &current_members, sx, sy, ox, oy);
// Draw speech bubbles on top
let current_time = js_sys::Date::now() as i64;
draw_speech_bubbles(
&ctx,
&current_members,
&current_bubbles,
sx,
sy,
ox,
oy,
current_time,
);
}
}) as Box<dyn FnOnce()>);
@ -419,3 +440,166 @@ fn draw_avatars(
let _ = ctx.fill_text(&member.member.display_name, x, y + 10.0 * scale_y);
}
}
/// Draw speech bubbles above avatars.
#[cfg(feature = "hydrate")]
fn draw_speech_bubbles(
ctx: &web_sys::CanvasRenderingContext2d,
members: &[ChannelMemberWithAvatar],
bubbles: &HashMap<(Option<Uuid>, Option<Uuid>), ActiveBubble>,
scale_x: f64,
scale_y: f64,
offset_x: f64,
offset_y: f64,
current_time_ms: i64,
) {
let scale = scale_x.min(scale_y);
let avatar_size = 48.0 * scale;
let max_bubble_width = 200.0 * scale;
let padding = 8.0 * scale;
let font_size = 12.0 * scale;
let line_height = 16.0 * scale;
let tail_size = 8.0 * scale;
let border_radius = 8.0 * scale;
for member in members {
let key = (member.member.user_id, member.member.guest_session_id);
if let Some(bubble) = bubbles.get(&key) {
// Skip expired bubbles
if bubble.expires_at < current_time_ms {
continue;
}
// Use member's CURRENT position, not message position
let x = member.member.position_x * scale_x + offset_x;
let y = member.member.position_y * scale_y + offset_y;
// Get emotion colors
let (bg_color, border_color, text_color) =
emotion_bubble_colors(bubble.message.emotion);
// Measure and wrap text
ctx.set_font(&format!("{}px sans-serif", font_size));
let lines = wrap_text(ctx, &bubble.message.content, max_bubble_width - padding * 2.0);
// Calculate bubble dimensions
let bubble_width = lines
.iter()
.map(|line: &String| -> f64 {
ctx.measure_text(line)
.map(|m: web_sys::TextMetrics| m.width())
.unwrap_or(0.0)
})
.fold(0.0_f64, |a: f64, b: f64| a.max(b))
+ padding * 2.0;
let bubble_width = bubble_width.max(60.0 * scale); // Minimum width
let bubble_height = (lines.len() as f64) * line_height + padding * 2.0;
// Position bubble above avatar
let bubble_x = x - bubble_width / 2.0;
let bubble_y = y - avatar_size - bubble_height - tail_size - 5.0 * scale;
// Draw bubble background with rounded corners
draw_rounded_rect(ctx, bubble_x, bubble_y, bubble_width, bubble_height, border_radius);
ctx.set_fill_style_str(bg_color);
ctx.fill();
ctx.set_stroke_style_str(border_color);
ctx.set_line_width(2.0);
ctx.stroke();
// Draw tail (triangle pointing down)
ctx.begin_path();
ctx.move_to(x - tail_size, bubble_y + bubble_height);
ctx.line_to(x, bubble_y + bubble_height + tail_size);
ctx.line_to(x + tail_size, bubble_y + bubble_height);
ctx.close_path();
ctx.set_fill_style_str(bg_color);
ctx.fill();
ctx.set_stroke_style_str(border_color);
ctx.stroke();
// Draw text
ctx.set_fill_style_str(text_color);
ctx.set_text_align("left");
ctx.set_text_baseline("top");
for (i, line) in lines.iter().enumerate() {
let _ = ctx.fill_text(
line,
bubble_x + padding,
bubble_y + padding + (i as f64) * line_height,
);
}
}
}
}
/// Wrap text to fit within max_width.
#[cfg(feature = "hydrate")]
fn wrap_text(ctx: &web_sys::CanvasRenderingContext2d, text: &str, max_width: f64) -> Vec<String> {
let words: Vec<&str> = text.split_whitespace().collect();
let mut lines = Vec::new();
let mut current_line = String::new();
for word in words {
let test_line = if current_line.is_empty() {
word.to_string()
} else {
format!("{} {}", current_line, word)
};
let width = ctx
.measure_text(&test_line)
.map(|m: web_sys::TextMetrics| m.width())
.unwrap_or(0.0);
if width > max_width && !current_line.is_empty() {
lines.push(current_line);
current_line = word.to_string();
} else {
current_line = test_line;
}
}
if !current_line.is_empty() {
lines.push(current_line);
}
// Limit to 4 lines max
if lines.len() > 4 {
lines.truncate(3);
if let Some(last) = lines.last_mut() {
last.push_str("...");
}
}
// Handle empty text
if lines.is_empty() {
lines.push(text.to_string());
}
lines
}
/// Draw a rounded rectangle path.
#[cfg(feature = "hydrate")]
fn draw_rounded_rect(
ctx: &web_sys::CanvasRenderingContext2d,
x: f64,
y: f64,
width: f64,
height: f64,
radius: f64,
) {
ctx.begin_path();
ctx.move_to(x + radius, y);
ctx.line_to(x + width - radius, y);
ctx.arc_to(x + width, y, x + width, y + radius, radius).ok();
ctx.line_to(x + width, y + height - radius);
ctx.arc_to(x + width, y + height, x + width - radius, y + height, radius).ok();
ctx.line_to(x + radius, y + height);
ctx.arc_to(x, y + height, x, y + height - radius, radius).ok();
ctx.line_to(x, y + radius);
ctx.arc_to(x, y, x + radius, y, radius).ok();
ctx.close_path();
}

View file

@ -9,6 +9,8 @@ use leptos::reactive::owner::LocalStorage;
use chattyness_db::models::ChannelMemberWithAvatar;
use chattyness_db::ws_messages::{ClientMessage, ServerMessage};
use super::chat_types::ChatMessage;
/// WebSocket connection state.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum WsState {
@ -38,6 +40,7 @@ pub fn use_channel_websocket(
realm_slug: Signal<String>,
channel_id: Signal<Option<uuid::Uuid>>,
on_members_update: Callback<Vec<ChannelMemberWithAvatar>>,
on_chat_message: Callback<ChatMessage>,
) -> (Signal<WsState>, WsSenderStorage) {
use std::cell::RefCell;
use std::rc::Rc;
@ -129,6 +132,7 @@ pub fn use_channel_websocket(
// onmessage
let members_for_msg = members_clone.clone();
let on_members_update_clone = on_members_update.clone();
let on_chat_message_clone = on_chat_message.clone();
let onmessage = Closure::wrap(Box::new(move |e: MessageEvent| {
if let Ok(text) = e.data().dyn_into::<js_sys::JsString>() {
let text: String = text.into();
@ -136,7 +140,12 @@ pub fn use_channel_websocket(
web_sys::console::log_1(&format!("[WS<-Server] {}", text).into());
if let Ok(msg) = serde_json::from_str::<ServerMessage>(&text) {
handle_server_message(msg, &members_for_msg, &on_members_update_clone);
handle_server_message(
msg,
&members_for_msg,
&on_members_update_clone,
&on_chat_message_clone,
);
}
}
}) as Box<dyn FnMut(MessageEvent)>);
@ -177,6 +186,7 @@ fn handle_server_message(
msg: ServerMessage,
members: &std::rc::Rc<std::cell::RefCell<Vec<ChannelMemberWithAvatar>>>,
on_update: &Callback<Vec<ChannelMemberWithAvatar>>,
on_chat_message: &Callback<ChatMessage>,
) {
let mut members_vec = members.borrow_mut();
@ -241,6 +251,30 @@ fn handle_server_message(
#[cfg(debug_assertions)]
web_sys::console::error_1(&format!("[WS] Server error: {} - {}", code, message).into());
}
ServerMessage::ChatMessageReceived {
message_id,
user_id,
guest_session_id,
display_name,
content,
emotion,
x,
y,
timestamp,
} => {
let chat_msg = ChatMessage {
message_id,
user_id,
guest_session_id,
display_name,
content,
emotion,
x,
y,
timestamp,
};
on_chat_message.run(chat_msg);
}
}
}
@ -250,6 +284,7 @@ pub fn use_channel_websocket(
_realm_slug: Signal<String>,
_channel_id: Signal<Option<uuid::Uuid>>,
_on_members_update: Callback<Vec<ChannelMemberWithAvatar>>,
_on_chat_message: Callback<ChatMessage>,
) -> (Signal<WsState>, WsSenderStorage) {
let (ws_state, _) = signal(WsState::Disconnected);
let sender: WsSenderStorage = StoredValue::new_local(None);

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">