add :emote and :list to chat

This commit is contained in:
Evan Carroll 2026-01-12 17:23:41 -06:00
parent 1ca300098f
commit bd28e201a2
7 changed files with 741 additions and 22 deletions

View file

@ -1695,3 +1695,30 @@ pub struct JoinChannelResponse {
pub member: ChannelMemberInfo, pub member: ChannelMemberInfo,
pub members: Vec<ChannelMemberWithAvatar>, pub members: Vec<ChannelMemberWithAvatar>,
} }
// =============================================================================
// 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<String>; 12],
}
impl Default for EmotionAvailability {
fn default() -> Self {
Self {
available: [false; 12],
preview_paths: Default::default(),
}
}
}

View file

@ -3,7 +3,7 @@
use sqlx::PgExecutor; use sqlx::PgExecutor;
use uuid::Uuid; use uuid::Uuid;
use crate::models::{ActiveAvatar, AvatarRenderData}; use crate::models::{ActiveAvatar, AvatarRenderData, EmotionAvailability};
use chattyness_error::AppError; use chattyness_error::AppError;
/// Get the active avatar for a user in a realm. /// Get the active avatar for a user in a realm.
@ -35,8 +35,8 @@ pub async fn set_emotion<'e>(
realm_id: Uuid, realm_id: Uuid,
emotion: i16, emotion: i16,
) -> Result<[Option<String>; 9], AppError> { ) -> Result<[Option<String>; 9], AppError> {
if emotion < 0 || emotion > 9 { if emotion < 0 || emotion > 11 {
return Err(AppError::Validation("Emotion must be 0-9".to_string())); return Err(AppError::Validation("Emotion must be 0-11".to_string()));
} }
// Map emotion index to column prefix // Map emotion index to column prefix
@ -51,7 +51,9 @@ pub async fn set_emotion<'e>(
7 => "e_crying", 7 => "e_crying",
8 => "e_love", 8 => "e_love",
9 => "e_confused", 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 // Build dynamic query for the specific emotion's 9 positions
@ -199,3 +201,167 @@ impl From<SimplifiedAvatarRow> 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<EmotionAvailability, AppError> {
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<bool>,
preview_0: Option<String>,
avail_1: Option<bool>,
preview_1: Option<String>,
avail_2: Option<bool>,
preview_2: Option<String>,
avail_3: Option<bool>,
preview_3: Option<String>,
avail_4: Option<bool>,
preview_4: Option<String>,
avail_5: Option<bool>,
preview_5: Option<String>,
avail_6: Option<bool>,
preview_6: Option<String>,
avail_7: Option<bool>,
preview_7: Option<String>,
avail_8: Option<bool>,
preview_8: Option<String>,
avail_9: Option<bool>,
preview_9: Option<String>,
avail_10: Option<bool>,
preview_10: Option<String>,
avail_11: Option<bool>,
preview_11: Option<String>,
}
impl From<EmotionAvailabilityRow> 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,
],
}
}
}

View file

@ -10,7 +10,7 @@ use axum::{
use sqlx::PgPool; use sqlx::PgPool;
use chattyness_db::{ use chattyness_db::{
models::AvatarRenderData, models::{AvatarRenderData, EmotionAvailability},
queries::{avatars, realms}, queries::{avatars, realms},
}; };
use chattyness_error::AppError; use chattyness_error::AppError;
@ -37,3 +37,25 @@ pub async fn get_current_avatar(
Ok(Json(render_data)) 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)
.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))
}

View file

@ -54,4 +54,8 @@ pub fn api_router() -> Router<AppState> {
"/realms/{slug}/avatar/current", "/realms/{slug}/avatar/current",
get(avatars::get_current_avatar), get(avatars::get_current_avatar),
) )
.route(
"/realms/{slug}/avatar/emotions",
get(avatars::get_emotion_availability),
)
} }

View file

@ -292,7 +292,8 @@ async fn handle_socket(
}); });
} }
ClientMessage::UpdateEmotion { emotion } => { ClientMessage::UpdateEmotion { emotion } => {
if emotion > 9 { // We have 12 emotions (0-11)
if emotion > 11 {
continue; continue;
} }
let emotion_layer = match avatars::set_emotion( let emotion_layer = match avatars::set_emotion(

View file

@ -2,25 +2,238 @@
use leptos::prelude::*; 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. /// Supports `:e name`, `:emote name` with partial matching.
/// Currently non-functional - just UI placeholder. fn parse_emote_command(cmd: &str) -> Option<u8> {
let cmd = cmd.trim().to_lowercase();
// Strip the leading colon if present
let cmd = cmd.strip_prefix(':').unwrap_or(&cmd);
// Check for `:e <name>` or `:emote <name>`
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] #[component]
pub fn ChatInput() -> impl IntoView { pub fn ChatInput(
ws_sender: WsSenderStorage,
emotion_availability: Signal<Option<EmotionAvailability>>,
skin_preview_path: Signal<Option<String>>,
focus_trigger: Signal<bool>,
on_focus_change: Callback<bool>,
) -> impl IntoView {
let (message, set_message) = signal(String::new()); let (message, set_message) = signal(String::new());
let (command_mode, set_command_mode) = signal(CommandMode::None);
let input_ref = NodeRef::<leptos::html::Input>::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! { view! {
<div class="chat-input-container w-full max-w-4xl mx-auto"> <div class="chat-input-container w-full max-w-4xl mx-auto pointer-events-auto relative">
// Command hint bar
<Show when=move || command_mode.get() == CommandMode::ShowingHint>
<div class="absolute bottom-full left-0 mb-1 px-3 py-1 bg-gray-700/90 backdrop-blur-sm rounded text-sm">
<span class="text-gray-400">":"</span>
<span class="text-blue-400">"e"</span>
<span class="text-gray-500">"[mote] name"</span>
<span class="text-gray-600 mx-2">"|"</span>
<span class="text-gray-400">":"</span>
<span class="text-blue-400">"l"</span>
<span class="text-gray-500">"[ist]"</span>
</div>
</Show>
// Emotion list popup
<Show when=move || command_mode.get() == CommandMode::ShowingList>
<EmoteListPopup
emotion_availability=emotion_availability
skin_preview_path=skin_preview_path
on_select=on_popup_select
on_close=on_popup_close
/>
</Show>
<div class="flex gap-2 items-center bg-gray-900/80 backdrop-blur-sm rounded-lg p-2 border border-gray-600"> <div class="flex gap-2 items-center bg-gray-900/80 backdrop-blur-sm rounded-lg p-2 border border-gray-600">
<input <input
type="text" type="text"
placeholder="Type a message..." placeholder="Type a message... (: for commands)"
class="flex-1 bg-transparent text-white placeholder-gray-500 px-3 py-2 outline-none" class="flex-1 bg-transparent text-white placeholder-gray-500 px-3 py-2 outline-none"
prop:value=move || message.get() prop:value=move || message.get()
on:input=move |ev| { on:input=on_input
set_message.set(event_target_value(&ev)); on:keydown=on_keydown
} on:focus=on_focus
on:blur=on_blur
node_ref=input_ref
autocomplete="off"
aria-label="Chat message input"
/> />
<button <button
type="button" type="button"
@ -30,9 +243,194 @@ pub fn ChatInput() -> impl IntoView {
"Send" "Send"
</button> </button>
</div> </div>
<p class="text-gray-500 text-xs mt-2 text-center">
"Chat functionality coming soon"
</p>
</div> </div>
} }
} }
/// Emote list popup component.
///
/// Shows available emotions in a 2-column grid with avatar previews.
#[component]
fn EmoteListPopup(
emotion_availability: Signal<Option<EmotionAvailability>>,
skin_preview_path: Signal<Option<String>>,
on_select: Callback<u8>,
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::<Vec<_>>()
})
.unwrap_or_default()
};
view! {
<div
class="absolute bottom-full left-0 mb-2 w-full max-w-lg bg-gray-800/95 backdrop-blur-sm rounded-lg shadow-2xl border border-gray-700 p-3 z-50"
role="listbox"
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">
<For
each=move || available_emotions()
key=|(idx, _, _): &(u8, &str, Option<String>)| *idx
children=move |(emotion_idx, emotion_name, preview_path): (u8, &str, Option<String>)| {
let on_select = on_select.clone();
let skin_path = skin_preview_path.get();
view! {
<button
type="button"
class="flex items-center gap-2 p-2 rounded hover:bg-gray-700 transition-colors text-left w-full"
on:click=move |_| on_select.run(emotion_idx)
role="option"
>
<EmotionPreview
skin_path=skin_path.clone()
emotion_path=preview_path.clone()
/>
<span class="text-white text-sm">
":"
{emotion_name}
</span>
</button>
}
}
/>
</div>
<Show when=move || available_emotions().is_empty()>
<div class="text-gray-500 text-sm text-center py-4">
"No emotions configured for your avatar"
</div>
</Show>
</div>
}
}
/// Emotion preview component.
///
/// Renders a small canvas with the avatar skin and emotion overlay.
#[component]
fn EmotionPreview(skin_path: Option<String>, emotion_path: Option<String>) -> impl IntoView {
let canvas_ref = NodeRef::<leptos::html::Canvas>::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<dyn FnOnce()>);
emotion_img.set_onload(Some(emotion_onload.as_ref().unchecked_ref()));
emotion_onload.forget();
emotion_img.set_src(&emotion_url);
}
}) as Box<dyn FnOnce()>);
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<dyn FnOnce()>);
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! {
<canvas
node_ref=canvas_ref
width="32"
height="32"
class="w-8 h-8 rounded"
aria-hidden="true"
/>
}
}

View file

@ -1,6 +1,7 @@
//! Realm landing page after login. //! Realm landing page after login.
use leptos::prelude::*; use leptos::prelude::*;
use leptos::reactive::owner::LocalStorage;
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
use leptos::task::spawn_local; use leptos::task::spawn_local;
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
@ -10,10 +11,14 @@ use leptos_router::hooks::use_params_map;
use crate::components::{Card, ChatInput, RealmHeader, RealmSceneViewer}; use crate::components::{Card, ChatInput, RealmHeader, RealmSceneViewer};
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
use crate::components::use_channel_websocket; use crate::components::use_channel_websocket;
use chattyness_db::models::{ChannelMemberWithAvatar, RealmRole, RealmWithUserRole, Scene}; use chattyness_db::models::{
ChannelMemberWithAvatar, EmotionAvailability, RealmRole, RealmWithUserRole, Scene,
};
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
use chattyness_db::ws_messages::ClientMessage; use chattyness_db::ws_messages::ClientMessage;
use crate::components::ws_client::WsSender;
/// Realm landing page component. /// Realm landing page component.
#[component] #[component]
pub fn RealmPage() -> impl IntoView { pub fn RealmPage() -> impl IntoView {
@ -27,6 +32,16 @@ pub fn RealmPage() -> impl IntoView {
let (members, set_members) = signal(Vec::<ChannelMemberWithAvatar>::new()); let (members, set_members) = signal(Vec::<ChannelMemberWithAvatar>::new());
let (channel_id, set_channel_id) = signal(Option::<uuid::Uuid>::None); 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);
let realm_data = LocalResource::new(move || { let realm_data = LocalResource::new(move || {
let slug = slug.get(); let slug = slug.get();
async move { async move {
@ -78,6 +93,52 @@ pub fn RealmPage() -> impl IntoView {
} }
}); });
// Fetch emotion availability and avatar render data for emote picker
#[cfg(feature = "hydrate")]
{
let slug_for_emotions = slug.clone();
Effect::new(move |_| {
use gloo_net::http::Request;
let current_slug = slug_for_emotions.get();
if current_slug.is_empty() {
return;
}
// Fetch emotion availability
let slug_clone = current_slug.clone();
spawn_local(async move {
let response = Request::get(&format!("/api/realms/{}/avatar/emotions", slug_clone))
.send()
.await;
if let Ok(resp) = response {
if resp.ok() {
if let Ok(avail) = resp.json::<EmotionAvailability>().await {
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());
}
}
}
});
});
}
// WebSocket connection for real-time updates // WebSocket connection for real-time updates
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
let on_members_update = Callback::new(move |new_members: Vec<ChannelMemberWithAvatar>| { let on_members_update = Callback::new(move |new_members: Vec<ChannelMemberWithAvatar>| {
@ -115,7 +176,7 @@ pub fn RealmPage() -> impl IntoView {
#[cfg(not(feature = "hydrate"))] #[cfg(not(feature = "hydrate"))]
let on_move = Callback::new(move |(_x, _y): (f64, f64)| {}); let on_move = Callback::new(move |(_x, _y): (f64, f64)| {});
// Handle emotion change via keyboard (e then 0-9) // Handle global keyboard shortcuts (e+0-9 for emotions, : for chat focus)
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
{ {
use std::cell::RefCell; use std::cell::RefCell;
@ -149,6 +210,25 @@ pub fn RealmPage() -> impl IntoView {
let closure = Closure::new(move |ev: web_sys::KeyboardEvent| { let closure = Closure::new(move |ev: web_sys::KeyboardEvent| {
let key = ev.key(); let key = ev.key();
// If chat is focused, let it handle all keys
if chat_focused.get() {
*e_pressed_clone.borrow_mut() = false;
return;
}
// Handle ':' to focus chat input
if key == ":" {
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;
}
// Check if 'e' key was pressed // Check if 'e' key was pressed
if key == "e" || key == "E" { if key == "e" || key == "E" {
*e_pressed_clone.borrow_mut() = true; *e_pressed_clone.borrow_mut() = true;
@ -189,6 +269,11 @@ pub fn RealmPage() -> impl IntoView {
}); });
} }
// 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) // Create logout callback (WebSocket disconnects automatically)
let on_logout = Callback::new(move |_: ()| { let on_logout = Callback::new(move |_: ()| {
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
@ -272,13 +357,23 @@ pub fn RealmPage() -> impl IntoView {
}> }>
{move || { {move || {
let on_move = on_move.clone(); let on_move = on_move.clone();
let on_chat_focus_change = on_chat_focus_change.clone();
let realm_slug_for_viewer = realm_slug_val.clone(); let realm_slug_for_viewer = realm_slug_val.clone();
#[cfg(feature = "hydrate")]
let ws_sender_clone = ws_sender.clone();
entry_scene entry_scene
.get() .get()
.map(|maybe_scene| { .map(|maybe_scene| {
match maybe_scene { match maybe_scene {
Some(scene) => { Some(scene) => {
let members_signal = Signal::derive(move || members.get()); 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);
view! { view! {
<div class="relative w-full"> <div class="relative w-full">
<RealmSceneViewer <RealmSceneViewer
@ -287,8 +382,14 @@ pub fn RealmPage() -> impl IntoView {
members=members_signal members=members_signal
on_move=on_move.clone() on_move=on_move.clone()
/> />
<div class="absolute bottom-0 left-0 right-0 z-10 pb-4 px-4"> <div class="absolute bottom-0 left-0 right-0 z-10 pb-4 px-4 pointer-events-none">
<ChatInput /> <ChatInput
ws_sender=ws_for_chat
emotion_availability=emotion_avail_signal
skin_preview_path=skin_path_signal
focus_trigger=focus_trigger_signal
on_focus_change=on_chat_focus_change.clone()
/>
</div> </div>
</div> </div>
} }