From bd28e201a2cd825c16e974b111e1721b6e2c90d88f5dc8ee097c5dbfc439902b Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Mon, 12 Jan 2026 17:23:41 -0600 Subject: [PATCH] add :emote and :list to chat --- crates/chattyness-db/src/models.rs | 27 ++ crates/chattyness-db/src/queries/avatars.rs | 174 +++++++- crates/chattyness-user-ui/src/api/avatars.rs | 24 +- crates/chattyness-user-ui/src/api/routes.rs | 4 + .../chattyness-user-ui/src/api/websocket.rs | 3 +- .../chattyness-user-ui/src/components/chat.rs | 422 +++++++++++++++++- crates/chattyness-user-ui/src/pages/realm.rs | 109 ++++- 7 files changed, 741 insertions(+), 22 deletions(-) diff --git a/crates/chattyness-db/src/models.rs b/crates/chattyness-db/src/models.rs index 39c08f6..d462ac7 100644 --- a/crates/chattyness-db/src/models.rs +++ b/crates/chattyness-db/src/models.rs @@ -1695,3 +1695,30 @@ pub struct JoinChannelResponse { pub member: ChannelMemberInfo, pub members: Vec, } + +// ============================================================================= +// Emotion Availability +// ============================================================================= + +/// Emotion availability data for the emote command UI. +/// +/// Indicates which of the 12 emotions have assets configured for the user's avatar, +/// and provides preview paths (position 4/center) for rendering in the emotion picker. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EmotionAvailability { + /// Which emotions have at least one non-null asset slot (positions 0-8). + /// Index corresponds to emotion: 0=neutral, 1=happy, 2=sad, etc. + pub available: [bool; 12], + /// Center position (4) asset path for each emotion, used for preview rendering. + /// None if that emotion has no center asset. + pub preview_paths: [Option; 12], +} + +impl Default for EmotionAvailability { + fn default() -> Self { + Self { + available: [false; 12], + preview_paths: Default::default(), + } + } +} diff --git a/crates/chattyness-db/src/queries/avatars.rs b/crates/chattyness-db/src/queries/avatars.rs index b43b308..e4bda9f 100644 --- a/crates/chattyness-db/src/queries/avatars.rs +++ b/crates/chattyness-db/src/queries/avatars.rs @@ -3,7 +3,7 @@ use sqlx::PgExecutor; use uuid::Uuid; -use crate::models::{ActiveAvatar, AvatarRenderData}; +use crate::models::{ActiveAvatar, AvatarRenderData, EmotionAvailability}; use chattyness_error::AppError; /// Get the active avatar for a user in a realm. @@ -35,8 +35,8 @@ pub async fn set_emotion<'e>( realm_id: Uuid, emotion: i16, ) -> Result<[Option; 9], AppError> { - if emotion < 0 || emotion > 9 { - return Err(AppError::Validation("Emotion must be 0-9".to_string())); + if emotion < 0 || emotion > 11 { + return Err(AppError::Validation("Emotion must be 0-11".to_string())); } // Map emotion index to column prefix @@ -51,7 +51,9 @@ pub async fn set_emotion<'e>( 7 => "e_crying", 8 => "e_love", 9 => "e_confused", - _ => return Err(AppError::Validation("Emotion must be 0-9".to_string())), + 10 => "e_sleeping", + 11 => "e_wink", + _ => return Err(AppError::Validation("Emotion must be 0-11".to_string())), }; // Build dynamic query for the specific emotion's 9 positions @@ -199,3 +201,167 @@ impl From for AvatarRenderData { } } } + +/// Get emotion availability for a user's avatar in a realm. +/// +/// Returns which emotions have assets configured (any of positions 0-8 non-null) +/// and the center position (4) preview path for each emotion. +pub async fn get_emotion_availability<'e>( + executor: impl PgExecutor<'e>, + user_id: Uuid, + realm_id: Uuid, +) -> Result { + let row = sqlx::query_as::<_, EmotionAvailabilityRow>( + r#" + SELECT + -- Neutral (0): check if any position has asset + (a.e_neutral_0 IS NOT NULL OR a.e_neutral_1 IS NOT NULL OR a.e_neutral_2 IS NOT NULL OR + a.e_neutral_3 IS NOT NULL OR a.e_neutral_4 IS NOT NULL OR a.e_neutral_5 IS NOT NULL OR + a.e_neutral_6 IS NOT NULL OR a.e_neutral_7 IS NOT NULL OR a.e_neutral_8 IS NOT NULL) as avail_0, + (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_neutral_4) as preview_0, + + -- Happy (1) + (a.e_happy_0 IS NOT NULL OR a.e_happy_1 IS NOT NULL OR a.e_happy_2 IS NOT NULL OR + a.e_happy_3 IS NOT NULL OR a.e_happy_4 IS NOT NULL OR a.e_happy_5 IS NOT NULL OR + a.e_happy_6 IS NOT NULL OR a.e_happy_7 IS NOT NULL OR a.e_happy_8 IS NOT NULL) as avail_1, + (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_happy_4) as preview_1, + + -- Sad (2) + (a.e_sad_0 IS NOT NULL OR a.e_sad_1 IS NOT NULL OR a.e_sad_2 IS NOT NULL OR + a.e_sad_3 IS NOT NULL OR a.e_sad_4 IS NOT NULL OR a.e_sad_5 IS NOT NULL OR + a.e_sad_6 IS NOT NULL OR a.e_sad_7 IS NOT NULL OR a.e_sad_8 IS NOT NULL) as avail_2, + (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_sad_4) as preview_2, + + -- Angry (3) + (a.e_angry_0 IS NOT NULL OR a.e_angry_1 IS NOT NULL OR a.e_angry_2 IS NOT NULL OR + a.e_angry_3 IS NOT NULL OR a.e_angry_4 IS NOT NULL OR a.e_angry_5 IS NOT NULL OR + a.e_angry_6 IS NOT NULL OR a.e_angry_7 IS NOT NULL OR a.e_angry_8 IS NOT NULL) as avail_3, + (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_angry_4) as preview_3, + + -- Surprised (4) + (a.e_surprised_0 IS NOT NULL OR a.e_surprised_1 IS NOT NULL OR a.e_surprised_2 IS NOT NULL OR + a.e_surprised_3 IS NOT NULL OR a.e_surprised_4 IS NOT NULL OR a.e_surprised_5 IS NOT NULL OR + a.e_surprised_6 IS NOT NULL OR a.e_surprised_7 IS NOT NULL OR a.e_surprised_8 IS NOT NULL) as avail_4, + (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_surprised_4) as preview_4, + + -- Thinking (5) + (a.e_thinking_0 IS NOT NULL OR a.e_thinking_1 IS NOT NULL OR a.e_thinking_2 IS NOT NULL OR + a.e_thinking_3 IS NOT NULL OR a.e_thinking_4 IS NOT NULL OR a.e_thinking_5 IS NOT NULL OR + a.e_thinking_6 IS NOT NULL OR a.e_thinking_7 IS NOT NULL OR a.e_thinking_8 IS NOT NULL) as avail_5, + (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_thinking_4) as preview_5, + + -- Laughing (6) + (a.e_laughing_0 IS NOT NULL OR a.e_laughing_1 IS NOT NULL OR a.e_laughing_2 IS NOT NULL OR + a.e_laughing_3 IS NOT NULL OR a.e_laughing_4 IS NOT NULL OR a.e_laughing_5 IS NOT NULL OR + a.e_laughing_6 IS NOT NULL OR a.e_laughing_7 IS NOT NULL OR a.e_laughing_8 IS NOT NULL) as avail_6, + (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_laughing_4) as preview_6, + + -- Crying (7) + (a.e_crying_0 IS NOT NULL OR a.e_crying_1 IS NOT NULL OR a.e_crying_2 IS NOT NULL OR + a.e_crying_3 IS NOT NULL OR a.e_crying_4 IS NOT NULL OR a.e_crying_5 IS NOT NULL OR + a.e_crying_6 IS NOT NULL OR a.e_crying_7 IS NOT NULL OR a.e_crying_8 IS NOT NULL) as avail_7, + (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_crying_4) as preview_7, + + -- Love (8) + (a.e_love_0 IS NOT NULL OR a.e_love_1 IS NOT NULL OR a.e_love_2 IS NOT NULL OR + a.e_love_3 IS NOT NULL OR a.e_love_4 IS NOT NULL OR a.e_love_5 IS NOT NULL OR + a.e_love_6 IS NOT NULL OR a.e_love_7 IS NOT NULL OR a.e_love_8 IS NOT NULL) as avail_8, + (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_love_4) as preview_8, + + -- Confused (9) + (a.e_confused_0 IS NOT NULL OR a.e_confused_1 IS NOT NULL OR a.e_confused_2 IS NOT NULL OR + a.e_confused_3 IS NOT NULL OR a.e_confused_4 IS NOT NULL OR a.e_confused_5 IS NOT NULL OR + a.e_confused_6 IS NOT NULL OR a.e_confused_7 IS NOT NULL OR a.e_confused_8 IS NOT NULL) as avail_9, + (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_confused_4) as preview_9, + + -- Sleeping (10) + (a.e_sleeping_0 IS NOT NULL OR a.e_sleeping_1 IS NOT NULL OR a.e_sleeping_2 IS NOT NULL OR + a.e_sleeping_3 IS NOT NULL OR a.e_sleeping_4 IS NOT NULL OR a.e_sleeping_5 IS NOT NULL OR + a.e_sleeping_6 IS NOT NULL OR a.e_sleeping_7 IS NOT NULL OR a.e_sleeping_8 IS NOT NULL) as avail_10, + (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_sleeping_4) as preview_10, + + -- Wink (11) + (a.e_wink_0 IS NOT NULL OR a.e_wink_1 IS NOT NULL OR a.e_wink_2 IS NOT NULL OR + a.e_wink_3 IS NOT NULL OR a.e_wink_4 IS NOT NULL OR a.e_wink_5 IS NOT NULL OR + a.e_wink_6 IS NOT NULL OR a.e_wink_7 IS NOT NULL OR a.e_wink_8 IS NOT NULL) as avail_11, + (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_wink_4) as preview_11 + + FROM props.active_avatars aa + JOIN props.avatars a ON aa.avatar_id = a.id + WHERE aa.user_id = $1 AND aa.realm_id = $2 + "#, + ) + .bind(user_id) + .bind(realm_id) + .fetch_optional(executor) + .await?; + + match row { + Some(r) => Ok(r.into()), + None => Ok(EmotionAvailability::default()), + } +} + +/// Row type for emotion availability query. +#[derive(Debug, sqlx::FromRow)] +struct EmotionAvailabilityRow { + avail_0: Option, + preview_0: Option, + avail_1: Option, + preview_1: Option, + avail_2: Option, + preview_2: Option, + avail_3: Option, + preview_3: Option, + avail_4: Option, + preview_4: Option, + avail_5: Option, + preview_5: Option, + avail_6: Option, + preview_6: Option, + avail_7: Option, + preview_7: Option, + avail_8: Option, + preview_8: Option, + avail_9: Option, + preview_9: Option, + avail_10: Option, + preview_10: Option, + avail_11: Option, + preview_11: Option, +} + +impl From for EmotionAvailability { + fn from(row: EmotionAvailabilityRow) -> Self { + Self { + available: [ + row.avail_0.unwrap_or(false), + row.avail_1.unwrap_or(false), + row.avail_2.unwrap_or(false), + row.avail_3.unwrap_or(false), + row.avail_4.unwrap_or(false), + row.avail_5.unwrap_or(false), + row.avail_6.unwrap_or(false), + row.avail_7.unwrap_or(false), + row.avail_8.unwrap_or(false), + row.avail_9.unwrap_or(false), + row.avail_10.unwrap_or(false), + row.avail_11.unwrap_or(false), + ], + preview_paths: [ + row.preview_0, + row.preview_1, + row.preview_2, + row.preview_3, + row.preview_4, + row.preview_5, + row.preview_6, + row.preview_7, + row.preview_8, + row.preview_9, + row.preview_10, + row.preview_11, + ], + } + } +} diff --git a/crates/chattyness-user-ui/src/api/avatars.rs b/crates/chattyness-user-ui/src/api/avatars.rs index 9146784..06c4f02 100644 --- a/crates/chattyness-user-ui/src/api/avatars.rs +++ b/crates/chattyness-user-ui/src/api/avatars.rs @@ -10,7 +10,7 @@ use axum::{ use sqlx::PgPool; use chattyness_db::{ - models::AvatarRenderData, + models::{AvatarRenderData, EmotionAvailability}, queries::{avatars, realms}, }; use chattyness_error::AppError; @@ -37,3 +37,25 @@ pub async fn get_current_avatar( 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, + AuthUser(user): AuthUser, + Path(slug): Path, +) -> Result, AppError> { + // Get realm + let realm = realms::get_realm_by_slug(&pool, &slug) + .await? + .ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?; + + // Get emotion availability + let availability = avatars::get_emotion_availability(&pool, user.id, realm.id).await?; + + Ok(Json(availability)) +} diff --git a/crates/chattyness-user-ui/src/api/routes.rs b/crates/chattyness-user-ui/src/api/routes.rs index 49609d4..c74d5e1 100644 --- a/crates/chattyness-user-ui/src/api/routes.rs +++ b/crates/chattyness-user-ui/src/api/routes.rs @@ -54,4 +54,8 @@ pub fn api_router() -> Router { "/realms/{slug}/avatar/current", get(avatars::get_current_avatar), ) + .route( + "/realms/{slug}/avatar/emotions", + get(avatars::get_emotion_availability), + ) } diff --git a/crates/chattyness-user-ui/src/api/websocket.rs b/crates/chattyness-user-ui/src/api/websocket.rs index 45819c2..f73ddad 100644 --- a/crates/chattyness-user-ui/src/api/websocket.rs +++ b/crates/chattyness-user-ui/src/api/websocket.rs @@ -292,7 +292,8 @@ async fn handle_socket( }); } ClientMessage::UpdateEmotion { emotion } => { - if emotion > 9 { + // We have 12 emotions (0-11) + if emotion > 11 { continue; } let emotion_layer = match avatars::set_emotion( diff --git a/crates/chattyness-user-ui/src/components/chat.rs b/crates/chattyness-user-ui/src/components/chat.rs index 0733da8..1870820 100644 --- a/crates/chattyness-user-ui/src/components/chat.rs +++ b/crates/chattyness-user-ui/src/components/chat.rs @@ -2,25 +2,238 @@ use leptos::prelude::*; -/// Chat input component (placeholder UI). +use chattyness_db::models::EmotionAvailability; +use chattyness_db::ws_messages::ClientMessage; + +use super::ws_client::WsSenderStorage; + +/// Emotion names indexed by emotion slot (0-11). +const EMOTIONS: &[&str] = &[ + "neutral", // 0 + "happy", // 1 + "sad", // 2 + "angry", // 3 + "surprised", // 4 + "thinking", // 5 + "laughing", // 6 + "crying", // 7 + "love", // 8 + "confused", // 9 + "sleeping", // 10 + "wink", // 11 +]; + +/// Command mode state for the chat input. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum CommandMode { + /// Normal chat mode, no command active. + None, + /// Showing command hint (`:e[mote], :l[ist]`). + ShowingHint, + /// Showing emotion list popup. + ShowingList, +} + +/// Parse an emote command and return the emotion index if valid. /// -/// Displays a text input field for typing messages. -/// Currently non-functional - just UI placeholder. +/// Supports `:e name`, `:emote name` with partial matching. +fn parse_emote_command(cmd: &str) -> Option { + let cmd = cmd.trim().to_lowercase(); + + // Strip the leading colon if present + let cmd = cmd.strip_prefix(':').unwrap_or(&cmd); + + // Check for `:e ` or `:emote ` + let name = cmd + .strip_prefix("emote ") + .or_else(|| cmd.strip_prefix("e ")) + .map(str::trim); + + name.and_then(|n| { + EMOTIONS + .iter() + .enumerate() + .find(|(_, ename)| ename.starts_with(n) || n.starts_with(**ename)) + .map(|(idx, _)| idx as u8) + }) +} + +/// Chat input component with emote command support. +/// +/// Props: +/// - `ws_sender`: WebSocket sender for emotion updates +/// - `emotion_availability`: Which emotions are available for the user's avatar +/// - `skin_preview_path`: Path to the user's skin layer center asset (for previews) +/// - `focus_trigger`: Signal that triggers focus when set to true +/// - `on_focus_change`: Callback when focus state changes #[component] -pub fn ChatInput() -> impl IntoView { +pub fn ChatInput( + ws_sender: WsSenderStorage, + emotion_availability: Signal>, + skin_preview_path: Signal>, + focus_trigger: Signal, + on_focus_change: Callback, +) -> impl IntoView { let (message, set_message) = signal(String::new()); + let (command_mode, set_command_mode) = signal(CommandMode::None); + let input_ref = NodeRef::::new(); + + // Handle focus trigger from parent (when ':' is pressed globally) + #[cfg(feature = "hydrate")] + { + Effect::new(move |_| { + if focus_trigger.get() { + if let Some(input) = input_ref.get() { + let _ = input.focus(); + // Also set the message to ':' and show the hint + set_message.set(":".to_string()); + set_command_mode.set(CommandMode::ShowingHint); + // Update the input value directly + input.set_value(":"); + } + } + }); + } + + // Apply emotion via WebSocket + let apply_emotion = { + move |emotion_idx: u8| { + ws_sender.with_value(|sender| { + if let Some(send_fn) = sender { + send_fn(ClientMessage::UpdateEmotion { + emotion: emotion_idx, + }); + } + }); + // Clear input and close popup + set_message.set(String::new()); + set_command_mode.set(CommandMode::None); + } + }; + + // Handle input changes to detect commands + let on_input = move |ev| { + let value = event_target_value(&ev); + set_message.set(value.clone()); + + if value.starts_with(':') { + let cmd = value[1..].to_lowercase(); + + // Check for list command + if cmd == "l" || cmd == "list" { + set_command_mode.set(CommandMode::ShowingList); + } else if cmd.is_empty() + || cmd.starts_with('e') + || cmd.starts_with('l') + || cmd.starts_with("em") + || cmd.starts_with("li") + { + // Show hint for incomplete commands + set_command_mode.set(CommandMode::ShowingHint); + } else if cmd.starts_with("e ") || cmd.starts_with("emote ") { + // Typing an emote command - keep hint visible + set_command_mode.set(CommandMode::ShowingHint); + } else { + set_command_mode.set(CommandMode::None); + } + } else { + set_command_mode.set(CommandMode::None); + } + }; + + // Handle key presses (Enter to execute, Escape to close) + #[cfg(feature = "hydrate")] + let on_keydown = { + let apply_emotion = apply_emotion.clone(); + move |ev: web_sys::KeyboardEvent| { + let key = ev.key(); + + if key == "Escape" { + set_command_mode.set(CommandMode::None); + set_message.set(String::new()); + ev.prevent_default(); + return; + } + + if key == "Enter" { + let msg = message.get(); + if msg.starts_with(':') { + // Try to parse as emote command + if let Some(emotion_idx) = parse_emote_command(&msg) { + apply_emotion(emotion_idx); + ev.prevent_default(); + } + } + } + } + }; + + #[cfg(not(feature = "hydrate"))] + let on_keydown = move |_ev| {}; + + // Focus/blur handlers + let on_focus = { + let on_focus_change = on_focus_change.clone(); + move |_ev| { + on_focus_change.run(true); + } + }; + + let on_blur = { + move |_ev| { + on_focus_change.run(false); + // Note: We don't close the popup on blur to allow click events on popup items to fire + // The popup is closed when an item is selected or Escape is pressed + } + }; + + // Popup select handler + let on_popup_select = Callback::new(move |emotion_idx: u8| { + apply_emotion(emotion_idx); + }); + + let on_popup_close = Callback::new(move |_: ()| { + set_command_mode.set(CommandMode::None); + }); view! { -
+
+ // Command hint bar + +
+ ":" + "e" + "[mote] name" + "|" + ":" + "l" + "[ist]" +
+
+ + // Emotion list popup + + + +
-

- "Chat functionality coming soon" -

} } + +/// Emote list popup component. +/// +/// Shows available emotions in a 2-column grid with avatar previews. +#[component] +fn EmoteListPopup( + emotion_availability: Signal>, + skin_preview_path: Signal>, + on_select: Callback, + on_close: Callback<()>, +) -> impl IntoView { + // Get list of available emotions + let available_emotions = move || { + emotion_availability + .get() + .map(|avail| { + EMOTIONS + .iter() + .enumerate() + .filter(|(idx, _)| avail.available.get(*idx).copied().unwrap_or(false)) + .map(|(idx, name)| { + let preview = avail.preview_paths.get(idx).cloned().flatten(); + (idx as u8, *name, preview) + }) + .collect::>() + }) + .unwrap_or_default() + }; + + view! { +
+
"Select an emotion:"
+
+ )| *idx + children=move |(emotion_idx, emotion_name, preview_path): (u8, &str, Option)| { + let on_select = on_select.clone(); + let skin_path = skin_preview_path.get(); + view! { + + } + } + /> +
+ +
+ "No emotions configured for your avatar" +
+
+
+ } +} + +/// Emotion preview component. +/// +/// Renders a small canvas with the avatar skin and emotion overlay. +#[component] +fn EmotionPreview(skin_path: Option, emotion_path: Option) -> impl IntoView { + let canvas_ref = NodeRef::::new(); + + #[cfg(feature = "hydrate")] + { + use wasm_bindgen::{closure::Closure, JsCast}; + + let skin_path_clone = skin_path.clone(); + let emotion_path_clone = emotion_path.clone(); + + Effect::new(move |_| { + let Some(canvas) = canvas_ref.get() else { + return; + }; + + let canvas_el: &web_sys::HtmlCanvasElement = &canvas; + canvas_el.set_width(32); + canvas_el.set_height(32); + + let Ok(Some(ctx)) = canvas_el.get_context("2d") else { + return; + }; + let ctx: web_sys::CanvasRenderingContext2d = ctx.dyn_into().unwrap(); + + // Clear canvas + ctx.clear_rect(0.0, 0.0, 32.0, 32.0); + + // Helper to normalize asset paths + fn normalize_path(path: &str) -> String { + if path.starts_with('/') { + path.to_string() + } else { + format!("/static/{}", path) + } + } + + // Draw skin layer first + if let Some(ref skin) = skin_path_clone { + let skin_url = normalize_path(skin); + let ctx_clone = ctx.clone(); + + let img = web_sys::HtmlImageElement::new().unwrap(); + let img_clone = img.clone(); + + // Load and draw emotion on top after skin loads + let emotion_path_for_closure = emotion_path_clone.clone(); + + let onload = Closure::once(Box::new(move || { + ctx_clone.draw_image_with_html_image_element_and_dw_and_dh( + &img_clone, 0.0, 0.0, 32.0, 32.0, + ).ok(); + + // Now draw emotion overlay + if let Some(ref emotion) = emotion_path_for_closure { + let emotion_url = normalize_path(emotion); + let ctx_emotion = ctx_clone.clone(); + + let emotion_img = web_sys::HtmlImageElement::new().unwrap(); + let emotion_img_clone = emotion_img.clone(); + + let emotion_onload = Closure::once(Box::new(move || { + ctx_emotion.draw_image_with_html_image_element_and_dw_and_dh( + &emotion_img_clone, 0.0, 0.0, 32.0, 32.0, + ).ok(); + }) as Box); + + emotion_img.set_onload(Some(emotion_onload.as_ref().unchecked_ref())); + emotion_onload.forget(); + emotion_img.set_src(&emotion_url); + } + }) as Box); + + img.set_onload(Some(onload.as_ref().unchecked_ref())); + onload.forget(); + img.set_src(&skin_url); + } else if let Some(ref emotion) = emotion_path_clone { + // No skin, just draw emotion + let emotion_url = normalize_path(emotion); + let ctx_clone = ctx.clone(); + + let img = web_sys::HtmlImageElement::new().unwrap(); + let img_clone = img.clone(); + + let onload = Closure::once(Box::new(move || { + ctx_clone.draw_image_with_html_image_element_and_dw_and_dh( + &img_clone, 0.0, 0.0, 32.0, 32.0, + ).ok(); + }) as Box); + + img.set_onload(Some(onload.as_ref().unchecked_ref())); + onload.forget(); + img.set_src(&emotion_url); + } else { + // No assets - draw placeholder circle + ctx.set_fill_style_str("#4B5563"); + ctx.begin_path(); + ctx.arc(16.0, 16.0, 14.0, 0.0, std::f64::consts::PI * 2.0).ok(); + ctx.fill(); + } + }); + } + + view! { +