From ea3b444d711b1bb81826e1e9663da614c823f97f9dc85de6e42c9e79c41755c7 Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Tue, 13 Jan 2026 16:49:07 -0600 Subject: [PATCH] make emotions named instead, add drop prop --- crates/chattyness-db/src/models.rs | 102 ++++++ crates/chattyness-db/src/queries.rs | 3 + crates/chattyness-db/src/queries/avatars.rs | 40 ++- crates/chattyness-db/src/queries/channels.rs | 43 +++ crates/chattyness-db/src/queries/inventory.rs | 62 ++++ .../chattyness-db/src/queries/loose_props.rs | 217 +++++++++++++ crates/chattyness-db/src/queries/scenes.rs | 158 +++++----- crates/chattyness-db/src/ws_messages.rs | 56 +++- crates/chattyness-user-ui/src/api.rs | 1 + .../chattyness-user-ui/src/api/inventory.rs | 41 +++ crates/chattyness-user-ui/src/api/routes.rs | 8 +- .../chattyness-user-ui/src/api/websocket.rs | 120 ++++++- crates/chattyness-user-ui/src/components.rs | 2 + .../chattyness-user-ui/src/components/chat.rs | 44 +-- .../src/components/chat_types.rs | 101 ++++++ .../src/components/inventory.rs | 295 ++++++++++++++++++ .../src/components/scene_viewer.rs | 162 +++++++++- .../src/components/ws_client.rs | 36 ++- crates/chattyness-user-ui/src/pages/realm.rs | 88 +++++- 19 files changed, 1429 insertions(+), 150 deletions(-) create mode 100644 crates/chattyness-db/src/queries/channels.rs create mode 100644 crates/chattyness-db/src/queries/inventory.rs create mode 100644 crates/chattyness-db/src/queries/loose_props.rs create mode 100644 crates/chattyness-user-ui/src/api/inventory.rs create mode 100644 crates/chattyness-user-ui/src/components/chat_types.rs create mode 100644 crates/chattyness-user-ui/src/components/inventory.rs diff --git a/crates/chattyness-db/src/models.rs b/crates/chattyness-db/src/models.rs index f8bbeb6..eb6ccf1 100644 --- a/crates/chattyness-db/src/models.rs +++ b/crates/chattyness-db/src/models.rs @@ -343,6 +343,45 @@ impl std::str::FromStr for EmotionState { } } +impl EmotionState { + /// Convert a keybinding index (0-11) to an emotion state. + pub fn from_index(i: u8) -> Option { + match i { + 0 => Some(Self::Neutral), + 1 => Some(Self::Happy), + 2 => Some(Self::Sad), + 3 => Some(Self::Angry), + 4 => Some(Self::Surprised), + 5 => Some(Self::Thinking), + 6 => Some(Self::Laughing), + 7 => Some(Self::Crying), + 8 => Some(Self::Love), + 9 => Some(Self::Confused), + 10 => Some(Self::Sleeping), + 11 => Some(Self::Wink), + _ => None, + } + } + + /// Convert emotion state to its index (0-11). + pub fn to_index(&self) -> u8 { + match self { + Self::Neutral => 0, + Self::Happy => 1, + Self::Sad => 2, + Self::Angry => 3, + Self::Surprised => 4, + Self::Thinking => 5, + Self::Laughing => 6, + Self::Crying => 7, + Self::Love => 8, + Self::Confused => 9, + Self::Sleeping => 10, + Self::Wink => 11, + } + } +} + // ============================================================================= // User Models // ============================================================================= @@ -485,6 +524,8 @@ pub struct Scene { pub is_hidden: bool, pub created_at: DateTime, pub updated_at: DateTime, + /// Default public channel ID for this scene. + pub default_channel_id: Option, } /// Minimal scene info for listings. @@ -541,6 +582,67 @@ pub struct SpotSummary { // Props Models // ============================================================================= +/// Origin source for a prop in inventory. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "ssr", derive(sqlx::Type))] +#[cfg_attr(feature = "ssr", sqlx(type_name = "prop_origin", rename_all = "snake_case"))] +#[serde(rename_all = "snake_case")] +pub enum PropOrigin { + #[default] + ServerLibrary, + RealmLibrary, + UserUpload, +} + +impl std::fmt::Display for PropOrigin { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PropOrigin::ServerLibrary => write!(f, "server_library"), + PropOrigin::RealmLibrary => write!(f, "realm_library"), + PropOrigin::UserUpload => write!(f, "user_upload"), + } + } +} + +/// An inventory item (user-owned prop). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] +pub struct InventoryItem { + pub id: Uuid, + pub prop_name: String, + pub prop_asset_path: String, + pub layer: Option, + pub is_transferable: bool, + pub is_portable: bool, + pub origin: PropOrigin, + pub acquired_at: DateTime, +} + +/// Response for inventory list. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InventoryResponse { + pub items: Vec, +} + +/// A prop dropped in a channel, available for pickup. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] +pub struct LooseProp { + pub id: Uuid, + pub channel_id: Uuid, + pub server_prop_id: Option, + pub realm_prop_id: Option, + pub position_x: f64, + pub position_y: f64, + pub dropped_by: Option, + pub expires_at: Option>, + pub created_at: DateTime, + /// Prop name (JOINed from source prop). + pub prop_name: String, + /// Asset path for rendering (JOINed from source prop). + pub prop_asset_path: String, +} + /// A server-wide prop (global library). #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] diff --git a/crates/chattyness-db/src/queries.rs b/crates/chattyness-db/src/queries.rs index 0e50022..e06df7b 100644 --- a/crates/chattyness-db/src/queries.rs +++ b/crates/chattyness-db/src/queries.rs @@ -2,7 +2,10 @@ pub mod avatars; pub mod channel_members; +pub mod channels; pub mod guests; +pub mod inventory; +pub mod loose_props; pub mod memberships; pub mod owner; pub mod props; diff --git a/crates/chattyness-db/src/queries/avatars.rs b/crates/chattyness-db/src/queries/avatars.rs index 45d4051..9ced564 100644 --- a/crates/chattyness-db/src/queries/avatars.rs +++ b/crates/chattyness-db/src/queries/avatars.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; use sqlx::{postgres::PgConnection, PgExecutor, PgPool}; use uuid::Uuid; -use crate::models::{ActiveAvatar, AvatarWithPaths, EmotionAvailability}; +use crate::models::{ActiveAvatar, AvatarWithPaths, EmotionAvailability, EmotionState}; use chattyness_error::AppError; /// Get the active avatar for a user in a realm. @@ -35,29 +35,27 @@ pub async fn set_emotion<'e>( executor: impl PgExecutor<'e>, user_id: Uuid, realm_id: Uuid, - emotion: i16, + emotion: EmotionState, ) -> Result<[Option; 9], AppError> { - if emotion < 0 || emotion > 11 { - return Err(AppError::Validation("Emotion must be 0-11".to_string())); - } - - // Map emotion index to column prefix + // Map emotion to column prefix let emotion_prefix = match emotion { - 0 => "e_neutral", - 1 => "e_happy", - 2 => "e_sad", - 3 => "e_angry", - 4 => "e_surprised", - 5 => "e_thinking", - 6 => "e_laughing", - 7 => "e_crying", - 8 => "e_love", - 9 => "e_confused", - 10 => "e_sleeping", - 11 => "e_wink", - _ => return Err(AppError::Validation("Emotion must be 0-11".to_string())), + EmotionState::Neutral => "e_neutral", + EmotionState::Happy => "e_happy", + EmotionState::Sad => "e_sad", + EmotionState::Angry => "e_angry", + EmotionState::Surprised => "e_surprised", + EmotionState::Thinking => "e_thinking", + EmotionState::Laughing => "e_laughing", + EmotionState::Crying => "e_crying", + EmotionState::Love => "e_love", + EmotionState::Confused => "e_confused", + EmotionState::Sleeping => "e_sleeping", + EmotionState::Wink => "e_wink", }; + // Get the numeric index for the database + let emotion_index = emotion.to_index() as i16; + // Build dynamic query for the specific emotion's 9 positions let query = format!( r#" @@ -86,7 +84,7 @@ pub async fn set_emotion<'e>( let result = sqlx::query_as::<_, EmotionLayerRow>(&query) .bind(user_id) .bind(realm_id) - .bind(emotion) + .bind(emotion_index) .fetch_optional(executor) .await?; diff --git a/crates/chattyness-db/src/queries/channels.rs b/crates/chattyness-db/src/queries/channels.rs new file mode 100644 index 0000000..4f80891 --- /dev/null +++ b/crates/chattyness-db/src/queries/channels.rs @@ -0,0 +1,43 @@ +//! Channel-related database queries. + +use sqlx::PgExecutor; +use uuid::Uuid; + +use chattyness_error::AppError; + +/// Minimal channel info for WebSocket validation. +#[derive(Debug, Clone)] +#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] +pub struct ChannelInfo { + /// Channel ID. + pub id: Uuid, + /// Scene ID this channel belongs to. + pub scene_id: Uuid, + /// Realm ID (from the scene). + pub realm_id: Uuid, +} + +/// Get channel info by ID. +/// +/// Returns the channel with its associated scene and realm IDs. +pub async fn get_channel_info<'e>( + executor: impl PgExecutor<'e>, + channel_id: Uuid, +) -> Result, AppError> { + let info = sqlx::query_as::<_, ChannelInfo>( + r#" + SELECT + c.id, + c.scene_id, + s.realm_id + FROM realm.channels c + JOIN realm.scenes s ON s.id = c.scene_id + WHERE c.id = $1 + "#, + ) + .bind(channel_id) + .fetch_optional(executor) + .await?; + + Ok(info) +} diff --git a/crates/chattyness-db/src/queries/inventory.rs b/crates/chattyness-db/src/queries/inventory.rs new file mode 100644 index 0000000..35bb45f --- /dev/null +++ b/crates/chattyness-db/src/queries/inventory.rs @@ -0,0 +1,62 @@ +//! Inventory-related database queries. + +use sqlx::PgExecutor; +use uuid::Uuid; + +use crate::models::InventoryItem; +use chattyness_error::AppError; + +/// List all inventory items for a user. +pub async fn list_user_inventory<'e>( + executor: impl PgExecutor<'e>, + user_id: Uuid, +) -> Result, AppError> { + let items = sqlx::query_as::<_, InventoryItem>( + r#" + SELECT + id, + prop_name, + prop_asset_path, + layer, + is_transferable, + is_portable, + origin, + acquired_at + FROM props.inventory + WHERE user_id = $1 + ORDER BY acquired_at DESC + "#, + ) + .bind(user_id) + .fetch_all(executor) + .await?; + + Ok(items) +} + +/// Drop a prop (remove from inventory). +pub async fn drop_inventory_item<'e>( + executor: impl PgExecutor<'e>, + user_id: Uuid, + item_id: Uuid, +) -> Result<(), AppError> { + let result = sqlx::query( + r#" + DELETE FROM props.inventory + WHERE id = $1 AND user_id = $2 + RETURNING id + "#, + ) + .bind(item_id) + .bind(user_id) + .fetch_optional(executor) + .await?; + + if result.is_none() { + return Err(AppError::NotFound( + "Inventory item not found or not owned by user".to_string(), + )); + } + + Ok(()) +} diff --git a/crates/chattyness-db/src/queries/loose_props.rs b/crates/chattyness-db/src/queries/loose_props.rs new file mode 100644 index 0000000..d92893e --- /dev/null +++ b/crates/chattyness-db/src/queries/loose_props.rs @@ -0,0 +1,217 @@ +//! Loose props database queries. +//! +//! Handles props dropped in channels that can be picked up by users. + +use sqlx::PgExecutor; +use uuid::Uuid; + +use crate::models::{InventoryItem, LooseProp}; +use chattyness_error::AppError; + +/// List all loose props in a channel (excluding expired). +pub async fn list_channel_loose_props<'e>( + executor: impl PgExecutor<'e>, + channel_id: Uuid, +) -> Result, AppError> { + let props = sqlx::query_as::<_, LooseProp>( + r#" + SELECT + lp.id, + lp.channel_id, + lp.server_prop_id, + lp.realm_prop_id, + ST_X(lp.position) as position_x, + ST_Y(lp.position) as position_y, + lp.dropped_by, + lp.expires_at, + lp.created_at, + COALESCE(sp.name, rp.name) as prop_name, + COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path + FROM props.loose_props lp + LEFT JOIN server.props sp ON lp.server_prop_id = sp.id + LEFT JOIN props.realm_props rp ON lp.realm_prop_id = rp.id + WHERE lp.channel_id = $1 + AND (lp.expires_at IS NULL OR lp.expires_at > now()) + ORDER BY lp.created_at ASC + "#, + ) + .bind(channel_id) + .fetch_all(executor) + .await?; + + Ok(props) +} + +/// Drop a prop from inventory to the canvas. +/// +/// Deletes from inventory and inserts into loose_props with 30-minute expiry. +/// Returns the created loose prop. +pub async fn drop_prop_to_canvas<'e>( + executor: impl PgExecutor<'e>, + inventory_item_id: Uuid, + user_id: Uuid, + channel_id: Uuid, + position_x: f64, + position_y: f64, +) -> Result { + // Use a CTE to delete from inventory and insert to loose_props in one query + let prop = sqlx::query_as::<_, LooseProp>( + r#" + WITH deleted_item AS ( + DELETE FROM props.inventory + WHERE id = $1 AND user_id = $2 + RETURNING id, server_prop_id, realm_prop_id, prop_name, prop_asset_path + ), + inserted_prop AS ( + INSERT INTO props.loose_props ( + channel_id, + server_prop_id, + realm_prop_id, + position, + dropped_by, + expires_at + ) + SELECT + $3, + di.server_prop_id, + di.realm_prop_id, + public.make_virtual_point($4::real, $5::real), + $2, + now() + interval '30 minutes' + FROM deleted_item di + RETURNING + id, + channel_id, + server_prop_id, + realm_prop_id, + ST_X(position) as position_x, + ST_Y(position) as position_y, + dropped_by, + expires_at, + created_at + ) + SELECT + ip.id, + ip.channel_id, + ip.server_prop_id, + ip.realm_prop_id, + ip.position_x, + ip.position_y, + ip.dropped_by, + ip.expires_at, + ip.created_at, + di.prop_name, + di.prop_asset_path + FROM inserted_prop ip + CROSS JOIN deleted_item di + "#, + ) + .bind(inventory_item_id) + .bind(user_id) + .bind(channel_id) + .bind(position_x as f32) + .bind(position_y as f32) + .fetch_optional(executor) + .await? + .ok_or_else(|| { + AppError::NotFound("Inventory item not found or not owned by user".to_string()) + })?; + + Ok(prop) +} + +/// Pick up a loose prop (delete from loose_props, insert to inventory). +/// +/// Returns the created inventory item. +pub async fn pick_up_loose_prop<'e>( + executor: impl PgExecutor<'e>, + loose_prop_id: Uuid, + user_id: Uuid, +) -> Result { + // Use a CTE to delete from loose_props and insert to inventory + let item = sqlx::query_as::<_, InventoryItem>( + r#" + WITH deleted_prop AS ( + DELETE FROM props.loose_props + WHERE id = $1 + AND (expires_at IS NULL OR expires_at > now()) + RETURNING id, server_prop_id, realm_prop_id + ), + source_info AS ( + SELECT + COALESCE(sp.name, rp.name) as prop_name, + COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path, + COALESCE(sp.default_layer, rp.default_layer) as layer, + COALESCE(sp.is_transferable, rp.is_transferable) as is_transferable, + COALESCE(sp.is_portable, true) as is_portable, + dp.server_prop_id, + dp.realm_prop_id + FROM deleted_prop dp + LEFT JOIN server.props sp ON dp.server_prop_id = sp.id + LEFT JOIN props.realm_props rp ON dp.realm_prop_id = rp.id + ), + inserted_item AS ( + INSERT INTO props.inventory ( + user_id, + server_prop_id, + realm_prop_id, + prop_name, + prop_asset_path, + layer, + origin, + is_transferable, + is_portable, + provenance, + acquired_at + ) + SELECT + $2, + si.server_prop_id, + si.realm_prop_id, + si.prop_name, + si.prop_asset_path, + si.layer, + 'server_library'::props.prop_origin, + COALESCE(si.is_transferable, true), + COALESCE(si.is_portable, true), + '[]'::jsonb, + now() + FROM source_info si + RETURNING id, prop_name, prop_asset_path, layer, is_transferable, is_portable, acquired_at + ) + SELECT + ii.id, + ii.prop_name, + ii.prop_asset_path, + ii.layer, + ii.is_transferable, + ii.is_portable, + 'server_library'::props.prop_origin as origin, + ii.acquired_at + FROM inserted_item ii + "#, + ) + .bind(loose_prop_id) + .bind(user_id) + .fetch_optional(executor) + .await? + .ok_or_else(|| AppError::NotFound("Loose prop not found or has expired".to_string()))?; + + Ok(item) +} + +/// Delete expired loose props. +/// +/// Returns the number of props deleted. +pub async fn cleanup_expired_props<'e>(executor: impl PgExecutor<'e>) -> Result { + let result = sqlx::query( + r#" + DELETE FROM props.loose_props + WHERE expires_at IS NOT NULL AND expires_at <= now() + "#, + ) + .execute(executor) + .await?; + + Ok(result.rows_affected()) +} diff --git a/crates/chattyness-db/src/queries/scenes.rs b/crates/chattyness-db/src/queries/scenes.rs index 7363181..edd27da 100644 --- a/crates/chattyness-db/src/queries/scenes.rs +++ b/crates/chattyness-db/src/queries/scenes.rs @@ -42,24 +42,26 @@ pub async fn get_scene_by_id<'e>( let scene = sqlx::query_as::<_, Scene>( r#" SELECT - id, - realm_id, - name, - slug, - description, - background_image_path, - background_color, - ST_AsText(bounds) as bounds_wkt, - dimension_mode, - ambient_audio_id, - ambient_volume, - sort_order, - is_entry_point, - is_hidden, - created_at, - updated_at - FROM realm.scenes - WHERE id = $1 + s.id, + s.realm_id, + s.name, + s.slug, + s.description, + s.background_image_path, + s.background_color, + ST_AsText(s.bounds) as bounds_wkt, + s.dimension_mode, + s.ambient_audio_id, + s.ambient_volume, + s.sort_order, + s.is_entry_point, + s.is_hidden, + s.created_at, + s.updated_at, + c.id as default_channel_id + FROM realm.scenes s + LEFT JOIN realm.channels c ON c.scene_id = s.id AND c.channel_type = 'public' + WHERE s.id = $1 "#, ) .bind(scene_id) @@ -78,24 +80,26 @@ pub async fn get_scene_by_slug<'e>( let scene = sqlx::query_as::<_, Scene>( r#" SELECT - id, - realm_id, - name, - slug, - description, - background_image_path, - background_color, - ST_AsText(bounds) as bounds_wkt, - dimension_mode, - ambient_audio_id, - ambient_volume, - sort_order, - is_entry_point, - is_hidden, - created_at, - updated_at - FROM realm.scenes - WHERE realm_id = $1 AND slug = $2 + s.id, + s.realm_id, + s.name, + s.slug, + s.description, + s.background_image_path, + s.background_color, + ST_AsText(s.bounds) as bounds_wkt, + s.dimension_mode, + s.ambient_audio_id, + s.ambient_volume, + s.sort_order, + s.is_entry_point, + s.is_hidden, + s.created_at, + s.updated_at, + c.id as default_channel_id + FROM realm.scenes s + LEFT JOIN realm.channels c ON c.scene_id = s.id AND c.channel_type = 'public' + WHERE s.realm_id = $1 AND s.slug = $2 "#, ) .bind(realm_id) @@ -167,7 +171,8 @@ pub async fn create_scene<'e>( is_entry_point, is_hidden, created_at, - updated_at + updated_at, + NULL::uuid as default_channel_id "#, ) .bind(realm_id) @@ -236,7 +241,8 @@ pub async fn create_scene_with_id<'e>( is_entry_point, is_hidden, created_at, - updated_at + updated_at, + NULL::uuid as default_channel_id "#, ) .bind(scene_id) @@ -305,20 +311,27 @@ pub async fn update_scene<'e>( // If no updates, just return the current scene let query = if set_clauses.is_empty() { - r#"SELECT id, realm_id, name, slug, description, background_image_path, - background_color, ST_AsText(bounds) as bounds_wkt, dimension_mode, - ambient_audio_id, ambient_volume, sort_order, is_entry_point, - is_hidden, created_at, updated_at - FROM realm.scenes WHERE id = $1"#.to_string() + r#"SELECT s.id, s.realm_id, s.name, s.slug, s.description, s.background_image_path, + s.background_color, ST_AsText(s.bounds) as bounds_wkt, s.dimension_mode, + s.ambient_audio_id, s.ambient_volume, s.sort_order, s.is_entry_point, + s.is_hidden, s.created_at, s.updated_at, c.id as default_channel_id + FROM realm.scenes s + LEFT JOIN realm.channels c ON c.scene_id = s.id AND c.channel_type = 'public' + WHERE s.id = $1"#.to_string() } else { set_clauses.push("updated_at = now()".to_string()); format!( - r#"UPDATE realm.scenes SET {} - WHERE id = $1 - RETURNING id, realm_id, name, slug, description, background_image_path, - background_color, ST_AsText(bounds) as bounds_wkt, dimension_mode, - ambient_audio_id, ambient_volume, sort_order, is_entry_point, - is_hidden, created_at, updated_at"#, + r#"WITH updated AS ( + UPDATE realm.scenes SET {} + WHERE id = $1 + RETURNING id, realm_id, name, slug, description, background_image_path, + background_color, ST_AsText(bounds) as bounds_wkt, dimension_mode, + ambient_audio_id, ambient_volume, sort_order, is_entry_point, + is_hidden, created_at, updated_at + ) + SELECT u.*, c.id as default_channel_id + FROM updated u + LEFT JOIN realm.channels c ON c.scene_id = u.id AND c.channel_type = 'public'"#, set_clauses.join(", ") ) }; @@ -399,37 +412,42 @@ pub async fn get_next_sort_order<'e>( /// 1. The scene specified by `default_scene_id` on the realm (if provided and exists) /// 2. The first scene marked as `is_entry_point` /// 3. The first scene by sort_order +/// +/// Also includes the default public channel ID for the scene. pub async fn get_entry_scene_for_realm<'e>( executor: impl PgExecutor<'e>, realm_id: Uuid, default_scene_id: Option, ) -> Result, AppError> { // Use a single query that handles the priority in SQL + // Joins with channels to get the default public channel for the scene let scene = sqlx::query_as::<_, Scene>( r#" SELECT - id, - realm_id, - name, - slug, - description, - background_image_path, - background_color, - ST_AsText(bounds) as bounds_wkt, - dimension_mode, - ambient_audio_id, - ambient_volume, - sort_order, - is_entry_point, - is_hidden, - created_at, - updated_at - FROM realm.scenes - WHERE realm_id = $1 AND is_hidden = false + s.id, + s.realm_id, + s.name, + s.slug, + s.description, + s.background_image_path, + s.background_color, + ST_AsText(s.bounds) as bounds_wkt, + s.dimension_mode, + s.ambient_audio_id, + s.ambient_volume, + s.sort_order, + s.is_entry_point, + s.is_hidden, + s.created_at, + s.updated_at, + c.id as default_channel_id + FROM realm.scenes s + LEFT JOIN realm.channels c ON c.scene_id = s.id AND c.channel_type = 'public' + WHERE s.realm_id = $1 AND s.is_hidden = false ORDER BY - CASE WHEN id = $2 THEN 0 ELSE 1 END, - is_entry_point DESC, - sort_order ASC + CASE WHEN s.id = $2 THEN 0 ELSE 1 END, + s.is_entry_point DESC, + s.sort_order ASC LIMIT 1 "#, ) diff --git a/crates/chattyness-db/src/ws_messages.rs b/crates/chattyness-db/src/ws_messages.rs index df9bf76..2f0d458 100644 --- a/crates/chattyness-db/src/ws_messages.rs +++ b/crates/chattyness-db/src/ws_messages.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::models::{ChannelMemberInfo, ChannelMemberWithAvatar}; +use crate::models::{ChannelMemberInfo, ChannelMemberWithAvatar, LooseProp}; /// Client-to-server WebSocket messages. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -19,10 +19,10 @@ pub enum ClientMessage { y: f64, }, - /// Update emotion (0-9). + /// Update emotion by name. UpdateEmotion { - /// Emotion slot (0-9, keyboard: e0-e9). - emotion: u8, + /// Emotion name (e.g., "happy", "sad", "neutral"). + emotion: String, }, /// Ping to keep connection alive. @@ -33,6 +33,18 @@ pub enum ClientMessage { /// Message content (max 500 chars). content: String, }, + + /// Drop a prop from inventory to the canvas. + DropProp { + /// Inventory item ID to drop. + inventory_item_id: Uuid, + }, + + /// Pick up a loose prop from the canvas. + PickUpProp { + /// Loose prop ID to pick up. + loose_prop_id: Uuid, + }, } /// Server-to-client WebSocket messages. @@ -79,8 +91,8 @@ pub enum ServerMessage { user_id: Option, /// Guest session ID (if guest). guest_session_id: Option, - /// New emotion slot (0-9). - emotion: u8, + /// Emotion name (e.g., "happy", "sad", "neutral"). + emotion: String, /// Asset paths for all 9 positions of the new emotion layer. emotion_layer: [Option; 9], }, @@ -108,8 +120,8 @@ pub enum ServerMessage { display_name: String, /// Message content. content: String, - /// Current emotion of sender (0-11) for bubble styling. - emotion: u8, + /// Emotion name for bubble styling (e.g., "happy", "sad", "neutral"). + emotion: String, /// Sender's X position at time of message. x: f64, /// Sender's Y position at time of message. @@ -117,4 +129,32 @@ pub enum ServerMessage { /// Server timestamp (milliseconds since epoch). timestamp: i64, }, + + /// Initial list of loose props when joining channel. + LoosePropsSync { + /// All current loose props in the channel. + props: Vec, + }, + + /// A prop was dropped on the canvas. + PropDropped { + /// The dropped prop. + prop: LooseProp, + }, + + /// A prop was picked up from the canvas. + PropPickedUp { + /// ID of the prop that was picked up. + prop_id: Uuid, + /// User ID who picked it up (if authenticated). + picked_up_by_user_id: Option, + /// Guest session ID who picked it up (if guest). + picked_up_by_guest_id: Option, + }, + + /// A prop expired and was removed. + PropExpired { + /// ID of the expired prop. + prop_id: Uuid, + }, } diff --git a/crates/chattyness-user-ui/src/api.rs b/crates/chattyness-user-ui/src/api.rs index 63cd08b..7bba15f 100644 --- a/crates/chattyness-user-ui/src/api.rs +++ b/crates/chattyness-user-ui/src/api.rs @@ -2,6 +2,7 @@ pub mod auth; pub mod avatars; +pub mod inventory; pub mod realms; pub mod routes; pub mod scenes; diff --git a/crates/chattyness-user-ui/src/api/inventory.rs b/crates/chattyness-user-ui/src/api/inventory.rs new file mode 100644 index 0000000..69dd766 --- /dev/null +++ b/crates/chattyness-user-ui/src/api/inventory.rs @@ -0,0 +1,41 @@ +//! Inventory API handlers for user UI. +//! +//! Handles inventory listing and item management. + +use axum::extract::Path; +use axum::Json; +use uuid::Uuid; + +use chattyness_db::{models::InventoryResponse, queries::inventory}; +use chattyness_error::AppError; + +use crate::auth::{AuthUser, RlsConn}; + +/// Get user's full inventory. +/// +/// GET /api/inventory +pub async fn get_inventory( + rls_conn: RlsConn, + AuthUser(user): AuthUser, +) -> Result, AppError> { + let mut conn = rls_conn.acquire().await; + + let items = inventory::list_user_inventory(&mut *conn, user.id).await?; + + Ok(Json(InventoryResponse { items })) +} + +/// Drop an item from inventory. +/// +/// DELETE /api/inventory/{item_id} +pub async fn drop_item( + rls_conn: RlsConn, + AuthUser(user): AuthUser, + Path(item_id): Path, +) -> Result, AppError> { + let mut conn = rls_conn.acquire().await; + + inventory::drop_inventory_item(&mut *conn, user.id, item_id).await?; + + Ok(Json(serde_json::json!({ "success": true }))) +} diff --git a/crates/chattyness-user-ui/src/api/routes.rs b/crates/chattyness-user-ui/src/api/routes.rs index be68309..c32b223 100644 --- a/crates/chattyness-user-ui/src/api/routes.rs +++ b/crates/chattyness-user-ui/src/api/routes.rs @@ -6,7 +6,7 @@ use axum::{routing::get, Router}; -use super::{auth, avatars, realms, scenes, websocket}; +use super::{auth, avatars, inventory, realms, scenes, websocket}; use crate::app::AppState; /// Build the API router for user UI. @@ -51,4 +51,10 @@ pub fn api_router() -> Router { ) // Avatar routes (require authentication) .route("/realms/{slug}/avatar", get(avatars::get_avatar)) + // Inventory routes (require authentication) + .route("/inventory", get(inventory::get_inventory)) + .route( + "/inventory/{item_id}", + axum::routing::delete(inventory::drop_item), + ) } diff --git a/crates/chattyness-user-ui/src/api/websocket.rs b/crates/chattyness-user-ui/src/api/websocket.rs index ca05f63..79a7266 100644 --- a/crates/chattyness-user-ui/src/api/websocket.rs +++ b/crates/chattyness-user-ui/src/api/websocket.rs @@ -17,8 +17,8 @@ use tokio::sync::broadcast; use uuid::Uuid; use chattyness_db::{ - models::{AvatarRenderData, ChannelMemberWithAvatar, User}, - queries::{avatars, channel_members, realms, scenes}, + models::{AvatarRenderData, ChannelMemberWithAvatar, EmotionState, User}, + queries::{avatars, channel_members, loose_props, realms, scenes}, ws_messages::{ClientMessage, ServerMessage}, }; use chattyness_error::AppError; @@ -97,14 +97,15 @@ where .await? .ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?; - // Verify channel (scene) exists and belongs to this realm + // Verify scene exists and belongs to this realm + // Note: Using scene_id as channel_id since channel_members uses scenes directly let scene = scenes::get_scene_by_id(&pool, channel_id) .await? - .ok_or_else(|| AppError::NotFound("Channel not found".to_string()))?; + .ok_or_else(|| AppError::NotFound("Scene not found".to_string()))?; if scene.realm_id != realm.id { return Err(AppError::NotFound( - "Channel not found in this realm".to_string(), + "Scene not found in this realm".to_string(), )); } @@ -230,6 +231,24 @@ async fn handle_socket( } } + // Send loose props sync + match loose_props::list_channel_loose_props(&mut *conn, channel_id).await { + Ok(props) => { + let props_sync = ServerMessage::LoosePropsSync { props }; + if let Ok(json) = serde_json::to_string(&props_sync) { + #[cfg(debug_assertions)] + tracing::debug!("[WS->Client] {}", json); + if sender.send(Message::Text(json.into())).await.is_err() { + let _ = channel_members::leave_channel(&mut *conn, channel_id, user.id).await; + return; + } + } + } + Err(e) => { + tracing::warn!("[WS] Failed to get loose props: {:?}", e); + } + } + // Broadcast join to others let avatar = avatars::get_avatar_with_paths_conn(&mut *conn, user.id, realm_id) .await @@ -295,15 +314,20 @@ async fn handle_socket( }); } ClientMessage::UpdateEmotion { emotion } => { - // We have 12 emotions (0-11) - if emotion > 11 { - continue; - } + // Parse emotion name to EmotionState + let emotion_state = match emotion.parse::() { + Ok(e) => e, + Err(_) => { + #[cfg(debug_assertions)] + tracing::warn!("[WS] Invalid emotion name: {}", emotion); + continue; + } + }; let emotion_layer = match avatars::set_emotion( &mut *recv_conn, user_id, realm_id, - emotion as i16, + emotion_state, ) .await { @@ -341,13 +365,17 @@ async fn handle_socket( .await; if let Ok(Some(member)) = member_info { + // Convert emotion index to name + let emotion_name = EmotionState::from_index(member.current_emotion as u8) + .map(|e| e.to_string()) + .unwrap_or_else(|| "neutral".to_string()); 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, + emotion: emotion_name, x: member.position_x, y: member.position_y, timestamp: chrono::Utc::now().timestamp_millis(), @@ -355,6 +383,76 @@ async fn handle_socket( let _ = tx.send(msg); } } + ClientMessage::DropProp { inventory_item_id } => { + // Get user's current position for random offset + 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 { + // Generate random offset (within ~50 pixels) + let offset_x = (rand::random::() - 0.5) * 100.0; + let offset_y = (rand::random::() - 0.5) * 100.0; + let pos_x = member.position_x + offset_x; + let pos_y = member.position_y + offset_y; + + match loose_props::drop_prop_to_canvas( + &mut *recv_conn, + inventory_item_id, + user_id, + channel_id, + pos_x, + pos_y, + ) + .await + { + Ok(prop) => { + #[cfg(debug_assertions)] + tracing::debug!( + "[WS] User {} dropped prop {} at ({}, {})", + user_id, + prop.id, + pos_x, + pos_y + ); + let _ = tx.send(ServerMessage::PropDropped { prop }); + } + Err(e) => { + tracing::error!("[WS] Drop prop failed: {:?}", e); + } + } + } + } + ClientMessage::PickUpProp { loose_prop_id } => { + match loose_props::pick_up_loose_prop( + &mut *recv_conn, + loose_prop_id, + user_id, + ) + .await + { + Ok(_inventory_item) => { + #[cfg(debug_assertions)] + tracing::debug!( + "[WS] User {} picked up prop {}", + user_id, + loose_prop_id + ); + let _ = tx.send(ServerMessage::PropPickedUp { + prop_id: loose_prop_id, + picked_up_by_user_id: Some(user_id), + picked_up_by_guest_id: None, + }); + } + Err(e) => { + tracing::error!("[WS] Pick up prop failed: {:?}", e); + } + } + } } } } diff --git a/crates/chattyness-user-ui/src/components.rs b/crates/chattyness-user-ui/src/components.rs index e356f67..89ca350 100644 --- a/crates/chattyness-user-ui/src/components.rs +++ b/crates/chattyness-user-ui/src/components.rs @@ -4,6 +4,7 @@ pub mod chat; pub mod chat_types; pub mod editor; pub mod forms; +pub mod inventory; pub mod layout; pub mod modals; pub mod scene_viewer; @@ -13,6 +14,7 @@ pub use chat::*; pub use chat_types::*; pub use editor::*; pub use forms::*; +pub use inventory::*; pub use layout::*; pub use modals::*; pub use scene_viewer::*; diff --git a/crates/chattyness-user-ui/src/components/chat.rs b/crates/chattyness-user-ui/src/components/chat.rs index 1bda14e..7e02ec5 100644 --- a/crates/chattyness-user-ui/src/components/chat.rs +++ b/crates/chattyness-user-ui/src/components/chat.rs @@ -34,10 +34,10 @@ enum CommandMode { ShowingList, } -/// Parse an emote command and return the emotion index if valid. +/// Parse an emote command and return the emotion name if valid. /// /// Supports `:e name`, `:emote name` with partial matching. -fn parse_emote_command(cmd: &str) -> Option { +fn parse_emote_command(cmd: &str) -> Option { let cmd = cmd.trim().to_lowercase(); // Strip the leading colon if present @@ -52,9 +52,8 @@ fn parse_emote_command(cmd: &str) -> Option { name.and_then(|n| { EMOTIONS .iter() - .enumerate() - .find(|(_, ename)| ename.starts_with(n) || n.starts_with(**ename)) - .map(|(idx, _)| idx as u8) + .find(|ename| ename.starts_with(n) || n.starts_with(**ename)) + .map(|ename| (*ename).to_string()) }) } @@ -97,12 +96,10 @@ pub fn ChatInput( // Apply emotion via WebSocket let apply_emotion = { - move |emotion_idx: u8| { + move |emotion: String| { ws_sender.with_value(|sender| { if let Some(send_fn) = sender { - send_fn(ClientMessage::UpdateEmotion { - emotion: emotion_idx, - }); + send_fn(ClientMessage::UpdateEmotion { emotion }); } }); // Clear input and close popup @@ -199,8 +196,8 @@ pub fn ChatInput( }; // Popup select handler - let on_popup_select = Callback::new(move |emotion_idx: u8| { - apply_emotion(emotion_idx); + let on_popup_select = Callback::new(move |emotion: String| { + apply_emotion(emotion); }); let on_popup_close = Callback::new(move |_: ()| { @@ -265,10 +262,11 @@ pub fn ChatInput( fn EmoteListPopup( emotion_availability: Signal>, skin_preview_path: Signal>, - on_select: Callback, - on_close: Callback<()>, + on_select: Callback, + #[prop(into)] on_close: Callback<()>, ) -> impl IntoView { - // Get list of available emotions + let _ = on_close; // Suppress unused warning + // Get list of available emotions (name, preview_path) let available_emotions = move || { emotion_availability .get() @@ -279,7 +277,7 @@ fn EmoteListPopup( .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) + ((*name).to_string(), preview) }) .collect::>() }) @@ -296,24 +294,26 @@ fn EmoteListPopup(
)| *idx - children=move |(emotion_idx, emotion_name, preview_path): (u8, &str, Option)| { + key=|(name, _): &(String, Option)| name.clone() + children=move |(emotion_name, preview_path): (String, Option)| { let on_select = on_select.clone(); - let skin_path = skin_preview_path.get(); + let emotion_name_for_click = emotion_name.clone(); + let _skin_path = skin_preview_path.get(); + let _emotion_path = preview_path.clone(); view! { } diff --git a/crates/chattyness-user-ui/src/components/chat_types.rs b/crates/chattyness-user-ui/src/components/chat_types.rs new file mode 100644 index 0000000..e998f66 --- /dev/null +++ b/crates/chattyness-user-ui/src/components/chat_types.rs @@ -0,0 +1,101 @@ +//! Chat message types for client-side state management. + +use serde::{Deserialize, Serialize}; +use std::collections::VecDeque; +use uuid::Uuid; + +/// Maximum messages to keep in the local log. +pub const MAX_MESSAGE_LOG_SIZE: usize = 2000; + +/// Default speech bubble timeout in milliseconds. +pub const DEFAULT_BUBBLE_TIMEOUT_MS: i64 = 60_000; + +/// A chat message for display and logging. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatMessage { + pub message_id: Uuid, + pub user_id: Option, + pub guest_session_id: Option, + pub display_name: String, + pub content: String, + /// Emotion name (e.g., "happy", "sad", "neutral"). + pub emotion: String, + pub x: f64, + pub y: f64, + /// Timestamp in milliseconds since epoch. + pub timestamp: i64, +} + +/// Message log with bounded capacity for future replay support. +#[derive(Debug, Clone, Default)] +pub struct MessageLog { + messages: VecDeque, +} + +impl MessageLog { + pub fn new() -> Self { + Self { + messages: VecDeque::with_capacity(MAX_MESSAGE_LOG_SIZE), + } + } + + pub fn push(&mut self, msg: ChatMessage) { + if self.messages.len() >= MAX_MESSAGE_LOG_SIZE { + self.messages.pop_front(); + } + self.messages.push_back(msg); + } + + /// Get messages within a time range (for replay). + pub fn messages_in_range(&self, start_ms: i64, end_ms: i64) -> Vec<&ChatMessage> { + self.messages + .iter() + .filter(|m| m.timestamp >= start_ms && m.timestamp <= end_ms) + .collect() + } + + /// Get the latest message from a specific user. + pub fn latest_from_user( + &self, + user_id: Option, + guest_id: Option, + ) -> Option<&ChatMessage> { + self.messages + .iter() + .rev() + .find(|m| m.user_id == user_id && m.guest_session_id == guest_id) + } + + /// Get all messages. + pub fn all_messages(&self) -> &VecDeque { + &self.messages + } +} + +/// Active speech bubble state for a user. +#[derive(Debug, Clone)] +pub struct ActiveBubble { + pub message: ChatMessage, + /// When the bubble should expire (milliseconds since epoch). + pub expires_at: i64, +} + +/// Get bubble colors based on emotion name. +/// Returns (background_color, border_color, text_color). +pub fn emotion_bubble_colors(emotion: &str) -> (&'static str, &'static str, &'static str) { + match emotion { + "neutral" => ("#374151", "#4B5563", "#F9FAFB"), // gray + "happy" => ("#FBBF24", "#F59E0B", "#1F2937"), // amber + "sad" => ("#3B82F6", "#2563EB", "#F9FAFB"), // blue + "angry" => ("#EF4444", "#DC2626", "#F9FAFB"), // red + "surprised" => ("#A855F7", "#9333EA", "#F9FAFB"), // purple + "thinking" => ("#6366F1", "#4F46E5", "#F9FAFB"), // indigo + "laughing" => ("#FBBF24", "#F59E0B", "#1F2937"), // amber + "crying" => ("#3B82F6", "#2563EB", "#F9FAFB"), // blue + "love" => ("#EC4899", "#DB2777", "#F9FAFB"), // pink + "confused" => ("#8B5CF6", "#7C3AED", "#F9FAFB"), // violet + "sleeping" => ("#1F2937", "#374151", "#9CA3AF"), // dark gray + "wink" => ("#10B981", "#059669", "#F9FAFB"), // emerald + _ => ("#374151", "#4B5563", "#F9FAFB"), // default - gray + } +} diff --git a/crates/chattyness-user-ui/src/components/inventory.rs b/crates/chattyness-user-ui/src/components/inventory.rs new file mode 100644 index 0000000..8c48ef1 --- /dev/null +++ b/crates/chattyness-user-ui/src/components/inventory.rs @@ -0,0 +1,295 @@ +//! Inventory popup component. + +use leptos::prelude::*; +use leptos::reactive::owner::LocalStorage; +use uuid::Uuid; + +use chattyness_db::models::InventoryItem; +#[cfg(feature = "hydrate")] +use chattyness_db::ws_messages::ClientMessage; + +use super::ws_client::WsSender; + +/// Inventory popup component. +/// +/// Shows a grid of user-owned props with drop functionality. +/// +/// Props: +/// - `open`: Signal controlling visibility +/// - `on_close`: Callback when popup should close +/// - `ws_sender`: WebSocket sender for dropping props +#[component] +pub fn InventoryPopup( + #[prop(into)] open: Signal, + on_close: Callback<()>, + ws_sender: StoredValue, LocalStorage>, +) -> impl IntoView { + let (items, set_items) = signal(Vec::::new()); + let (loading, set_loading) = signal(false); + let (error, set_error) = signal(Option::::None); + let (selected_item, set_selected_item) = signal(Option::::None); + let (dropping, set_dropping) = signal(false); + + // Fetch inventory when popup opens + #[cfg(feature = "hydrate")] + { + use gloo_net::http::Request; + use leptos::task::spawn_local; + + Effect::new(move |_| { + if !open.get() { + // Reset state when closing + set_selected_item.set(None); + return; + } + + set_loading.set(true); + set_error.set(None); + + spawn_local(async move { + let response = Request::get("/api/inventory").send().await; + match response { + Ok(resp) if resp.ok() => { + if let Ok(data) = + resp.json::().await + { + set_items.set(data.items); + } else { + set_error.set(Some("Failed to parse inventory data".to_string())); + } + } + Ok(resp) => { + set_error.set(Some(format!("Failed to load inventory: {}", resp.status()))); + } + Err(e) => { + set_error.set(Some(format!("Network error: {}", e))); + } + } + set_loading.set(false); + }); + }); + } + + // Handle escape key to close + #[cfg(feature = "hydrate")] + { + use wasm_bindgen::{closure::Closure, JsCast}; + + Effect::new(move |_| { + if !open.get() { + return; + } + + let on_close_clone = on_close.clone(); + let closure = + Closure::::new(move |ev: web_sys::KeyboardEvent| { + if ev.key() == "Escape" { + on_close_clone.run(()); + } + }); + + if let Some(window) = web_sys::window() { + let _ = window.add_event_listener_with_callback( + "keydown", + closure.as_ref().unchecked_ref(), + ); + } + + // Intentionally not cleaning up - closure lives for session + closure.forget(); + }); + } + + // Handle drop action via WebSocket + #[cfg(feature = "hydrate")] + let handle_drop = { + move |item_id: Uuid| { + set_dropping.set(true); + + // Send drop command via WebSocket + ws_sender.with_value(|sender| { + if let Some(send_fn) = sender { + send_fn(ClientMessage::DropProp { + inventory_item_id: item_id, + }); + // Optimistically remove from local list + set_items.update(|items| { + items.retain(|i| i.id != item_id); + }); + set_selected_item.set(None); + } else { + set_error.set(Some("Not connected to server".to_string())); + } + }); + + set_dropping.set(false); + } + }; + + #[cfg(not(feature = "hydrate"))] + let handle_drop = |_item_id: Uuid| {}; + + let on_close_backdrop = on_close.clone(); + let on_close_button = on_close.clone(); + + view! { + +