diff --git a/crates/chattyness-db/Cargo.toml b/crates/chattyness-db/Cargo.toml index be61afb..4a6768d 100644 --- a/crates/chattyness-db/Cargo.toml +++ b/crates/chattyness-db/Cargo.toml @@ -7,7 +7,6 @@ edition.workspace = true chattyness-error = { workspace = true, optional = true } chattyness-shared = { workspace = true, optional = true } serde.workspace = true -serde_json = { workspace = true, optional = true } uuid.workspace = true chrono.workspace = true @@ -18,4 +17,4 @@ rand = { workspace = true, optional = true } [features] default = [] -ssr = ["sqlx", "argon2", "rand", "chattyness-error/ssr", "dep:chattyness-error", "dep:chattyness-shared", "dep:serde_json"] +ssr = ["sqlx", "argon2", "rand", "chattyness-error/ssr", "dep:chattyness-error", "dep:chattyness-shared"] diff --git a/crates/chattyness-db/src/models.rs b/crates/chattyness-db/src/models.rs index 3dee106..c943e18 100644 --- a/crates/chattyness-db/src/models.rs +++ b/crates/chattyness-db/src/models.rs @@ -209,44 +209,6 @@ impl std::str::FromStr for RealmRole { } } -/// Moderation action type. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "ssr", derive(sqlx::Type))] -#[cfg_attr( - feature = "ssr", - sqlx(type_name = "action_type", rename_all = "snake_case") -)] -#[serde(rename_all = "snake_case")] -pub enum ActionType { - Warning, - Mute, - Kick, - Ban, - Unban, - PropRemoval, - MessageDeletion, - Summon, - SummonAll, - Teleport, -} - -impl std::fmt::Display for ActionType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ActionType::Warning => write!(f, "warning"), - ActionType::Mute => write!(f, "mute"), - ActionType::Kick => write!(f, "kick"), - ActionType::Ban => write!(f, "ban"), - ActionType::Unban => write!(f, "unban"), - ActionType::PropRemoval => write!(f, "prop_removal"), - ActionType::MessageDeletion => write!(f, "message_deletion"), - ActionType::Summon => write!(f, "summon"), - ActionType::SummonAll => write!(f, "summon_all"), - ActionType::Teleport => write!(f, "teleport"), - } - } -} - /// Scene dimension mode. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "ssr", derive(sqlx::Type))] @@ -333,7 +295,6 @@ pub enum AvatarLayer { #[default] Clothes, Accessories, - Emote, } impl std::fmt::Display for AvatarLayer { @@ -342,7 +303,6 @@ impl std::fmt::Display for AvatarLayer { AvatarLayer::Skin => write!(f, "skin"), AvatarLayer::Clothes => write!(f, "clothes"), AvatarLayer::Accessories => write!(f, "accessories"), - AvatarLayer::Emote => write!(f, "emote"), } } } @@ -355,7 +315,6 @@ impl std::str::FromStr for AvatarLayer { "skin" => Ok(AvatarLayer::Skin), "clothes" => Ok(AvatarLayer::Clothes), "accessories" => Ok(AvatarLayer::Accessories), - "emote" => Ok(AvatarLayer::Emote), _ => Err(format!("Invalid avatar layer: {}", s)), } } @@ -726,40 +685,21 @@ pub struct InventoryResponse { pub items: Vec, } -/// Extended prop info for acquisition UI (works for both server and realm props). -/// Includes ownership and availability status for the current user. +/// A public prop from server or realm library. +/// Used for the public inventory tabs (Server/Realm). #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] -pub struct PropAcquisitionInfo { +pub struct PublicProp { pub id: Uuid, pub name: String, pub asset_path: String, pub description: Option, - pub is_unique: bool, - /// User already has this prop in their inventory. - pub user_owns: bool, - /// For unique props: someone has already claimed it. - pub is_claimed: bool, - /// Prop is within its availability window (or has no window). - pub is_available: bool, } -/// Request to acquire a prop (server or realm). +/// Response for public props list. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AcquirePropRequest { - pub prop_id: Uuid, -} - -/// Response after acquiring a prop. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AcquirePropResponse { - pub item: InventoryItem, -} - -/// Response for prop acquisition list with status. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PropAcquisitionListResponse { - pub props: Vec, +pub struct PublicPropsResponse { + pub props: Vec, } /// A prop dropped in a channel, available for pickup. diff --git a/crates/chattyness-db/src/queries.rs b/crates/chattyness-db/src/queries.rs index 196db89..e06df7b 100644 --- a/crates/chattyness-db/src/queries.rs +++ b/crates/chattyness-db/src/queries.rs @@ -7,7 +7,6 @@ pub mod guests; pub mod inventory; pub mod loose_props; pub mod memberships; -pub mod moderation; pub mod owner; pub mod props; pub mod realms; diff --git a/crates/chattyness-db/src/queries/inventory.rs b/crates/chattyness-db/src/queries/inventory.rs index 4c9d854..6047292 100644 --- a/crates/chattyness-db/src/queries/inventory.rs +++ b/crates/chattyness-db/src/queries/inventory.rs @@ -3,7 +3,7 @@ use sqlx::PgExecutor; use uuid::Uuid; -use crate::models::{InventoryItem, PropAcquisitionInfo}; +use crate::models::{InventoryItem, PublicProp}; use chattyness_error::AppError; /// List all inventory items for a user. @@ -92,455 +92,66 @@ pub async fn drop_inventory_item<'e>( Ok(()) } -/// List public server props with optional acquisition status. +/// List all public server props. /// -/// Returns props that are active and public, with flags indicating: -/// - `user_owns`: Whether the user already has this prop (false if no user_id) -/// - `is_claimed`: Whether a unique prop has been claimed by anyone -/// - `is_available`: Whether the prop is within its availability window -/// -/// When `user_id` is None, returns default values for user-specific fields. -pub async fn list_server_props<'e>( +/// Returns props that are: +/// - Active (`is_active = true`) +/// - Public (`is_public = true`) +/// - Currently available (within availability window if set) +pub async fn list_public_server_props<'e>( executor: impl PgExecutor<'e>, - user_id: Option, -) -> Result, AppError> { - let props = sqlx::query_as::<_, PropAcquisitionInfo>( +) -> Result, AppError> { + let props = sqlx::query_as::<_, PublicProp>( r#" SELECT - p.id, - p.name, - p.asset_path, - p.description, - p.is_unique, - CASE - WHEN $1::uuid IS NOT NULL THEN EXISTS( - SELECT 1 FROM auth.inventory i - WHERE i.user_id = $1 AND i.server_prop_id = p.id - ) - ELSE false - END AS user_owns, - CASE - WHEN p.is_unique THEN EXISTS( - SELECT 1 FROM auth.inventory i WHERE i.server_prop_id = p.id - ) - ELSE false - END AS is_claimed, - (p.available_from IS NULL OR p.available_from <= now()) - AND (p.available_until IS NULL OR p.available_until > now()) AS is_available - FROM server.props p - WHERE p.is_active = true - AND p.is_public = true - ORDER BY p.name ASC + id, + name, + asset_path, + description + FROM server.props + WHERE is_active = true + AND is_public = true + AND (available_from IS NULL OR available_from <= now()) + AND (available_until IS NULL OR available_until > now()) + ORDER BY name ASC "#, ) - .bind(user_id) .fetch_all(executor) .await?; Ok(props) } -/// List public realm props with optional acquisition status. +/// List all public realm props for a specific realm. /// -/// Returns props that are active and public in the specified realm, with flags indicating: -/// - `user_owns`: Whether the user already has this prop (false if no user_id) -/// - `is_claimed`: Whether a unique prop has been claimed by anyone -/// - `is_available`: Whether the prop is within its availability window -/// -/// When `user_id` is None, returns default values for user-specific fields. -pub async fn list_realm_props<'e>( +/// Returns props that are: +/// - In the specified realm +/// - Active (`is_active = true`) +/// - Public (`is_public = true`) +/// - Currently available (within availability window if set) +pub async fn list_public_realm_props<'e>( executor: impl PgExecutor<'e>, realm_id: Uuid, - user_id: Option, -) -> Result, AppError> { - let props = sqlx::query_as::<_, PropAcquisitionInfo>( +) -> Result, AppError> { + let props = sqlx::query_as::<_, PublicProp>( r#" SELECT - p.id, - p.name, - p.asset_path, - p.description, - p.is_unique, - CASE - WHEN $2::uuid IS NOT NULL THEN EXISTS( - SELECT 1 FROM auth.inventory i - WHERE i.user_id = $2 AND i.realm_prop_id = p.id - ) - ELSE false - END AS user_owns, - CASE - WHEN p.is_unique THEN EXISTS( - SELECT 1 FROM auth.inventory i WHERE i.realm_prop_id = p.id - ) - ELSE false - END AS is_claimed, - (p.available_from IS NULL OR p.available_from <= now()) - AND (p.available_until IS NULL OR p.available_until > now()) AS is_available - FROM realm.props p - WHERE p.realm_id = $1 - AND p.is_active = true - AND p.is_public = true - ORDER BY p.name ASC + id, + name, + asset_path, + description + FROM realm.props + WHERE realm_id = $1 + AND is_active = true + AND is_public = true + AND (available_from IS NULL OR available_from <= now()) + AND (available_until IS NULL OR available_until > now()) + ORDER BY name ASC "#, ) .bind(realm_id) - .bind(user_id) .fetch_all(executor) .await?; Ok(props) } - -/// Acquire a server prop into user's inventory. -/// -/// Atomically validates and acquires the prop: -/// - Validates prop is active, public, within availability window -/// - For unique props: checks no one owns it yet -/// - For non-unique props: checks user doesn't already own it -/// - Inserts into `auth.inventory` with `origin = server_library` -/// -/// Returns the created inventory item or an appropriate error. -pub async fn acquire_server_prop<'e>( - executor: impl PgExecutor<'e>, - prop_id: Uuid, - user_id: Uuid, -) -> Result { - // Use a CTE to atomically check conditions and insert - let result: Option = sqlx::query_as( - r#" - WITH prop_check AS ( - SELECT - p.id, - p.name, - p.asset_path, - p.default_layer, - p.is_unique, - p.is_transferable, - p.is_portable, - p.is_droppable, - p.is_active, - p.is_public, - (p.available_from IS NULL OR p.available_from <= now()) AS available_from_ok, - (p.available_until IS NULL OR p.available_until > now()) AS available_until_ok - FROM server.props p - WHERE p.id = $1 - ), - ownership_check AS ( - SELECT - pc.*, - EXISTS( - SELECT 1 FROM auth.inventory i - WHERE i.user_id = $2 AND i.server_prop_id = $1 - ) AS user_owns, - CASE - WHEN pc.is_unique THEN EXISTS( - SELECT 1 FROM auth.inventory i WHERE i.server_prop_id = $1 - ) - ELSE false - END AS is_claimed - FROM prop_check pc - ), - inserted AS ( - INSERT INTO auth.inventory ( - user_id, - server_prop_id, - prop_name, - prop_asset_path, - layer, - origin, - is_transferable, - is_portable, - is_droppable - ) - SELECT - $2, - oc.id, - oc.name, - oc.asset_path, - oc.default_layer, - 'server_library'::server.prop_origin, - oc.is_transferable, - oc.is_portable, - oc.is_droppable - FROM ownership_check oc - WHERE oc.is_active = true - AND oc.is_public = true - AND oc.available_from_ok = true - AND oc.available_until_ok = true - AND oc.user_owns = false - AND oc.is_claimed = false - RETURNING - id, - prop_name, - prop_asset_path, - layer, - is_transferable, - is_portable, - is_droppable, - origin, - acquired_at - ) - SELECT * FROM inserted - "#, - ) - .bind(prop_id) - .bind(user_id) - .fetch_optional(executor) - .await?; - - match result { - Some(item) => Ok(item), - None => { - // Need to determine the specific error case - // We'll do a separate query to understand why it failed - Err(AppError::Conflict( - "Unable to acquire prop - it may not exist, not be available, or already owned" - .to_string(), - )) - } - } -} - -/// Get detailed acquisition error for a server prop. -/// -/// This is called when acquire_server_prop fails to determine the specific error. -pub async fn get_server_prop_acquisition_error<'e>( - executor: impl PgExecutor<'e>, - prop_id: Uuid, - user_id: Uuid, -) -> Result { - #[derive(sqlx::FromRow)] - #[allow(dead_code)] - struct PropStatus { - exists: bool, - is_active: bool, - is_public: bool, - is_available: bool, - is_unique: bool, - user_owns: bool, - is_claimed: bool, - } - - let status: Option = sqlx::query_as( - r#" - SELECT - true AS exists, - p.is_active, - p.is_public, - (p.available_from IS NULL OR p.available_from <= now()) - AND (p.available_until IS NULL OR p.available_until > now()) AS is_available, - p.is_unique, - EXISTS( - SELECT 1 FROM auth.inventory i - WHERE i.user_id = $2 AND i.server_prop_id = $1 - ) AS user_owns, - CASE - WHEN p.is_unique THEN EXISTS( - SELECT 1 FROM auth.inventory i WHERE i.server_prop_id = $1 - ) - ELSE false - END AS is_claimed - FROM server.props p - WHERE p.id = $1 - "#, - ) - .bind(prop_id) - .bind(user_id) - .fetch_optional(executor) - .await?; - - match status { - None => Ok(AppError::NotFound("Server prop not found".to_string())), - Some(s) if !s.is_active || !s.is_public => { - Ok(AppError::Forbidden("This prop is not available".to_string())) - } - Some(s) if !s.is_available => Ok(AppError::Forbidden( - "This prop is not currently available".to_string(), - )), - Some(s) if s.user_owns => Ok(AppError::Conflict("You already own this prop".to_string())), - Some(s) if s.is_claimed => Ok(AppError::Conflict( - "This unique prop has already been claimed by another user".to_string(), - )), - Some(_) => Ok(AppError::Internal( - "Unknown error acquiring prop".to_string(), - )), - } -} - -/// Acquire a realm prop into user's inventory. -/// -/// Atomically validates and acquires the prop: -/// - Validates prop belongs to realm, is active, public, within availability window -/// - For unique props: checks no one owns it yet -/// - For non-unique props: checks user doesn't already own it -/// - Inserts into `auth.inventory` with `origin = realm_library` -/// -/// Returns the created inventory item or an appropriate error. -pub async fn acquire_realm_prop<'e>( - executor: impl PgExecutor<'e>, - prop_id: Uuid, - realm_id: Uuid, - user_id: Uuid, -) -> Result { - // Use a CTE to atomically check conditions and insert - let result: Option = sqlx::query_as( - r#" - WITH prop_check AS ( - SELECT - p.id, - p.name, - p.asset_path, - p.default_layer, - p.is_unique, - p.is_transferable, - p.is_droppable, - p.is_active, - p.is_public, - (p.available_from IS NULL OR p.available_from <= now()) AS available_from_ok, - (p.available_until IS NULL OR p.available_until > now()) AS available_until_ok - FROM realm.props p - WHERE p.id = $1 AND p.realm_id = $2 - ), - ownership_check AS ( - SELECT - pc.*, - EXISTS( - SELECT 1 FROM auth.inventory i - WHERE i.user_id = $3 AND i.realm_prop_id = $1 - ) AS user_owns, - CASE - WHEN pc.is_unique THEN EXISTS( - SELECT 1 FROM auth.inventory i WHERE i.realm_prop_id = $1 - ) - ELSE false - END AS is_claimed - FROM prop_check pc - ), - inserted AS ( - INSERT INTO auth.inventory ( - user_id, - realm_prop_id, - prop_name, - prop_asset_path, - layer, - origin, - is_transferable, - is_portable, - is_droppable - ) - SELECT - $3, - oc.id, - oc.name, - oc.asset_path, - oc.default_layer, - 'realm_library'::server.prop_origin, - oc.is_transferable, - true, -- realm props are portable by default - oc.is_droppable - FROM ownership_check oc - WHERE oc.is_active = true - AND oc.is_public = true - AND oc.available_from_ok = true - AND oc.available_until_ok = true - AND oc.user_owns = false - AND oc.is_claimed = false - RETURNING - id, - prop_name, - prop_asset_path, - layer, - is_transferable, - is_portable, - is_droppable, - origin, - acquired_at - ) - SELECT * FROM inserted - "#, - ) - .bind(prop_id) - .bind(realm_id) - .bind(user_id) - .fetch_optional(executor) - .await?; - - match result { - Some(item) => Ok(item), - None => { - // Need to determine the specific error case - Err(AppError::Conflict( - "Unable to acquire prop - it may not exist, not be available, or already owned" - .to_string(), - )) - } - } -} - -/// Get detailed acquisition error for a realm prop. -/// -/// This is called when acquire_realm_prop fails to determine the specific error. -pub async fn get_realm_prop_acquisition_error<'e>( - executor: impl PgExecutor<'e>, - prop_id: Uuid, - realm_id: Uuid, - user_id: Uuid, -) -> Result { - #[derive(sqlx::FromRow)] - #[allow(dead_code)] - struct PropStatus { - exists: bool, - is_active: bool, - is_public: bool, - is_available: bool, - is_unique: bool, - user_owns: bool, - is_claimed: bool, - } - - let status: Option = sqlx::query_as( - r#" - SELECT - true AS exists, - p.is_active, - p.is_public, - (p.available_from IS NULL OR p.available_from <= now()) - AND (p.available_until IS NULL OR p.available_until > now()) AS is_available, - p.is_unique, - EXISTS( - SELECT 1 FROM auth.inventory i - WHERE i.user_id = $3 AND i.realm_prop_id = $1 - ) AS user_owns, - CASE - WHEN p.is_unique THEN EXISTS( - SELECT 1 FROM auth.inventory i WHERE i.realm_prop_id = $1 - ) - ELSE false - END AS is_claimed - FROM realm.props p - WHERE p.id = $1 AND p.realm_id = $2 - "#, - ) - .bind(prop_id) - .bind(realm_id) - .bind(user_id) - .fetch_optional(executor) - .await?; - - match status { - None => Ok(AppError::NotFound("Realm prop not found".to_string())), - Some(s) if !s.is_active || !s.is_public => { - Ok(AppError::Forbidden("This prop is not available".to_string())) - } - Some(s) if !s.is_available => Ok(AppError::Forbidden( - "This prop is not currently available".to_string(), - )), - Some(s) if s.user_owns => Ok(AppError::Conflict("You already own this prop".to_string())), - Some(s) if s.is_claimed => Ok(AppError::Conflict( - "This unique prop has already been claimed by another user".to_string(), - )), - Some(_) => Ok(AppError::Internal( - "Unknown error acquiring prop".to_string(), - )), - } -} diff --git a/crates/chattyness-db/src/queries/loose_props.rs b/crates/chattyness-db/src/queries/loose_props.rs index 415d907..8819119 100644 --- a/crates/chattyness-db/src/queries/loose_props.rs +++ b/crates/chattyness-db/src/queries/loose_props.rs @@ -8,30 +8,6 @@ use uuid::Uuid; use crate::models::{InventoryItem, LooseProp}; use chattyness_error::AppError; -/// Ensure an instance exists for a scene. -/// -/// In this system, scenes are used directly as instances (channel_id = scene_id). -/// This creates an instance record if one doesn't exist, using the scene_id as the instance_id. -/// This is needed for loose_props foreign key constraint. -pub async fn ensure_scene_instance<'e>( - executor: impl PgExecutor<'e>, - scene_id: Uuid, -) -> Result<(), AppError> { - sqlx::query( - r#" - INSERT INTO scene.instances (id, scene_id, instance_type) - SELECT $1, $1, 'public'::scene.instance_type - WHERE EXISTS (SELECT 1 FROM realm.scenes WHERE id = $1) - ON CONFLICT (id) DO NOTHING - "#, - ) - .bind(scene_id) - .execute(executor) - .await?; - - Ok(()) -} - /// List all loose props in a channel (excluding expired). pub async fn list_channel_loose_props<'e>( executor: impl PgExecutor<'e>, @@ -130,8 +106,8 @@ pub async fn drop_prop_to_canvas<'e>( instance_id as channel_id, server_prop_id, realm_prop_id, - ST_X(position)::real as position_x, - ST_Y(position)::real as position_y, + ST_X(position) as position_x, + ST_Y(position) as position_y, dropped_by, expires_at, created_at diff --git a/crates/chattyness-db/src/queries/memberships.rs b/crates/chattyness-db/src/queries/memberships.rs index 212fe1e..ab4ab21 100644 --- a/crates/chattyness-db/src/queries/memberships.rs +++ b/crates/chattyness-db/src/queries/memberships.rs @@ -199,33 +199,3 @@ pub async fn is_member(pool: &PgPool, user_id: Uuid, realm_id: Uuid) -> Result Result { - // Check server-level staff first (owner, admin, moderator can moderate any realm) - if let Some(server_role) = get_user_staff_role(pool, user_id).await? { - match server_role { - ServerRole::Owner | ServerRole::Admin | ServerRole::Moderator => return Ok(true), - } - } - - // Check realm-level role (owner or moderator for this specific realm) - let realm_mod: (bool,) = sqlx::query_as( - r#" - SELECT EXISTS( - SELECT 1 FROM realm.memberships - WHERE user_id = $1 AND realm_id = $2 AND role IN ('owner', 'moderator') - ) - "#, - ) - .bind(user_id) - .bind(realm_id) - .fetch_one(pool) - .await?; - - Ok(realm_mod.0) -} diff --git a/crates/chattyness-db/src/queries/moderation.rs b/crates/chattyness-db/src/queries/moderation.rs deleted file mode 100644 index 3ddc0fb..0000000 --- a/crates/chattyness-db/src/queries/moderation.rs +++ /dev/null @@ -1,77 +0,0 @@ -//! Moderation-related database queries. - -use sqlx::PgPool; -use uuid::Uuid; - -use crate::models::ActionType; -use chattyness_error::AppError; - -/// Log a moderation action to the realm audit log. -pub async fn log_moderation_action( - pool: &PgPool, - realm_id: Uuid, - moderator_id: Uuid, - action_type: ActionType, - target_user_id: Option, - reason: &str, - metadata: serde_json::Value, -) -> Result<(), AppError> { - sqlx::query( - r#" - INSERT INTO realm.moderation_actions ( - realm_id, - action_type, - target_user_id, - moderator_id, - reason, - metadata - ) - VALUES ($1, $2, $3, $4, $5, $6) - "#, - ) - .bind(realm_id) - .bind(action_type) - .bind(target_user_id) - .bind(moderator_id) - .bind(reason) - .bind(metadata) - .execute(pool) - .await?; - - Ok(()) -} - -/// Log a moderation action using a connection (for RLS support). -pub async fn log_moderation_action_conn( - conn: &mut sqlx::PgConnection, - realm_id: Uuid, - moderator_id: Uuid, - action_type: ActionType, - target_user_id: Option, - reason: &str, - metadata: serde_json::Value, -) -> Result<(), AppError> { - sqlx::query( - r#" - INSERT INTO realm.moderation_actions ( - realm_id, - action_type, - target_user_id, - moderator_id, - reason, - metadata - ) - VALUES ($1, $2, $3, $4, $5, $6) - "#, - ) - .bind(realm_id) - .bind(action_type) - .bind(target_user_id) - .bind(moderator_id) - .bind(reason) - .bind(metadata) - .execute(conn) - .await?; - - Ok(()) -} diff --git a/crates/chattyness-db/src/ws_messages.rs b/crates/chattyness-db/src/ws_messages.rs index 350ca0f..3dd17fc 100644 --- a/crates/chattyness-db/src/ws_messages.rs +++ b/crates/chattyness-db/src/ws_messages.rs @@ -90,14 +90,6 @@ pub enum ClientMessage { /// Scene ID to teleport to. scene_id: Uuid, }, - - /// Moderator command (only processed if sender is a moderator). - ModCommand { - /// Subcommand name ("summon", "avatar", "teleport", "ban", etc.). - subcommand: String, - /// Arguments for the subcommand. - args: Vec, - }, } /// Server-to-client WebSocket messages. @@ -242,22 +234,4 @@ pub enum ServerMessage { /// Scene slug for URL. scene_slug: String, }, - - /// User has been summoned by a moderator - triggers teleport. - Summoned { - /// Scene ID to teleport to. - scene_id: Uuid, - /// Scene slug for URL. - scene_slug: String, - /// Display name of the moderator who summoned. - summoned_by: String, - }, - - /// Result of a moderator command. - ModCommandResult { - /// Whether the command succeeded. - success: bool, - /// Human-readable result message. - message: String, - }, } diff --git a/crates/chattyness-user-ui/src/api/inventory.rs b/crates/chattyness-user-ui/src/api/inventory.rs index dcb7b15..7fd643c 100644 --- a/crates/chattyness-user-ui/src/api/inventory.rs +++ b/crates/chattyness-user-ui/src/api/inventory.rs @@ -8,194 +8,66 @@ use sqlx::PgPool; use uuid::Uuid; use chattyness_db::{ - User, - models::{ - AcquirePropRequest, AcquirePropResponse, InventoryResponse, PropAcquisitionListResponse, - }, + models::{InventoryResponse, PublicPropsResponse}, queries::{inventory, realms}, }; use chattyness_error::AppError; -use crate::auth::{AuthUser, OptionalAuthUser, RlsConn}; +use crate::auth::{AuthUser, RlsConn}; /// Get user's full inventory. /// -/// GET /api/user/{uuid}/inventory -/// -/// Supports "me" as a special UUID value to get the current user's inventory. -pub async fn get_user_inventory( +/// GET /api/inventory +pub async fn get_inventory( rls_conn: RlsConn, AuthUser(user): AuthUser, - Path(uuid_str): Path, ) -> Result, AppError> { - // Resolve "me" to current user ID, otherwise parse UUID - let target_user_id = if uuid_str.eq_ignore_ascii_case("me") { - user.id - } else { - uuid_str.parse::().map_err(|_| { - AppError::Validation(format!("Invalid user UUID: {}", uuid_str)) - })? - }; - - // For now, users can only view their own inventory - if target_user_id != user.id { - return Err(AppError::Forbidden( - "You can only view your own inventory".to_string(), - )); - } - let mut conn = rls_conn.acquire().await; - let items = inventory::list_user_inventory(&mut *conn, target_user_id).await?; + + let items = inventory::list_user_inventory(&mut *conn, user.id).await?; Ok(Json(InventoryResponse { items })) } /// Drop an item from inventory. /// -/// DELETE /api/user/{uuid}/inventory/{item_id} +/// DELETE /api/inventory/{item_id} pub async fn drop_item( rls_conn: RlsConn, AuthUser(user): AuthUser, - Path((uuid_str, item_id)): Path<(String, Uuid)>, + Path(item_id): Path, ) -> Result, AppError> { - // Resolve "me" to current user ID - let target_user_id = if uuid_str.eq_ignore_ascii_case("me") { - user.id - } else { - uuid_str.parse::().map_err(|_| { - AppError::Validation(format!("Invalid user UUID: {}", uuid_str)) - })? - }; - - // Users can only drop from their own inventory - if target_user_id != user.id { - return Err(AppError::Forbidden( - "You can only drop items from your own inventory".to_string(), - )); - } - 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 }))) } -/// Get server prop catalog. +/// Get public server props. /// -/// GET /api/server/inventory -/// -/// Returns all public server props. If the user is authenticated (non-guest), -/// includes acquisition status (user_owns, is_claimed, is_available). -/// For guests/unauthenticated, returns default status values. +/// GET /api/inventory/server pub async fn get_server_props( State(pool): State, - OptionalAuthUser(maybe_user): OptionalAuthUser, -) -> Result, AppError> { - // Get user_id if authenticated and not a guest - let user_id = maybe_user - .as_ref() - .filter(|u: &&User| !u.is_guest()) - .map(|u| u.id); +) -> Result, AppError> { + let props = inventory::list_public_server_props(&pool).await?; - let props = inventory::list_server_props(&pool, user_id).await?; - - Ok(Json(PropAcquisitionListResponse { props })) + Ok(Json(PublicPropsResponse { props })) } -/// Get realm prop catalog. +/// Get public realm props. /// /// GET /api/realms/{slug}/inventory -/// -/// Returns all public realm props for the specified realm. If the user is authenticated -/// (non-guest), includes acquisition status. For guests/unauthenticated, returns default status. pub async fn get_realm_props( State(pool): State, - OptionalAuthUser(maybe_user): OptionalAuthUser, Path(slug): Path, -) -> Result, AppError> { +) -> Result, AppError> { // Get the realm by slug to get its ID let realm = realms::get_realm_by_slug(&pool, &slug) .await? .ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?; - // Get user_id if authenticated and not a guest - let user_id = maybe_user - .as_ref() - .filter(|u: &&User| !u.is_guest()) - .map(|u| u.id); + let props = inventory::list_public_realm_props(&pool, realm.id).await?; - let props = inventory::list_realm_props(&pool, realm.id, user_id).await?; - - Ok(Json(PropAcquisitionListResponse { props })) -} - -/// Acquire a server prop into user's inventory. -/// -/// POST /api/server/inventory/request -pub async fn acquire_server_prop( - rls_conn: RlsConn, - AuthUser(user): AuthUser, - Json(req): Json, -) -> Result, AppError> { - // Guests cannot acquire props - if user.is_guest() { - return Err(AppError::Forbidden( - "Guests cannot acquire props".to_string(), - )); - } - - let mut conn = rls_conn.acquire().await; - - // Try to acquire the prop - match inventory::acquire_server_prop(&mut *conn, req.prop_id, user.id).await { - Ok(item) => Ok(Json(AcquirePropResponse { item })), - Err(_) => { - // Get the specific error reason - let error = - inventory::get_server_prop_acquisition_error(&mut *conn, req.prop_id, user.id) - .await?; - Err(error) - } - } -} - -/// Acquire a realm prop into user's inventory. -/// -/// POST /api/realms/{slug}/inventory/request -pub async fn acquire_realm_prop( - rls_conn: RlsConn, - State(pool): State, - AuthUser(user): AuthUser, - Path(slug): Path, - Json(req): Json, -) -> Result, AppError> { - // Guests cannot acquire props - if user.is_guest() { - return Err(AppError::Forbidden( - "Guests cannot acquire props".to_string(), - )); - } - - // Get the realm by slug to get its ID - let realm = realms::get_realm_by_slug(&pool, &slug) - .await? - .ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?; - - let mut conn = rls_conn.acquire().await; - - // Try to acquire the prop - match inventory::acquire_realm_prop(&mut *conn, req.prop_id, realm.id, user.id).await { - Ok(item) => Ok(Json(AcquirePropResponse { item })), - Err(_) => { - // Get the specific error reason - let error = inventory::get_realm_prop_acquisition_error( - &mut *conn, - req.prop_id, - realm.id, - user.id, - ) - .await?; - Err(error) - } - } + Ok(Json(PublicPropsResponse { props })) } diff --git a/crates/chattyness-user-ui/src/api/routes.rs b/crates/chattyness-user-ui/src/api/routes.rs index dd528e4..273df49 100644 --- a/crates/chattyness-user-ui/src/api/routes.rs +++ b/crates/chattyness-user-ui/src/api/routes.rs @@ -58,22 +58,13 @@ pub fn api_router() -> Router { "/realms/{slug}/avatar/slot", axum::routing::put(avatars::assign_slot).delete(avatars::clear_slot), ) - // User inventory routes - .route("/user/{uuid}/inventory", get(inventory::get_user_inventory)) + // Inventory routes (require authentication) + .route("/inventory", get(inventory::get_inventory)) .route( - "/user/{uuid}/inventory/{item_id}", + "/inventory/{item_id}", axum::routing::delete(inventory::drop_item), ) - // Server prop catalog (enriched if authenticated) - .route("/server/inventory", get(inventory::get_server_props)) - .route( - "/server/inventory/request", - axum::routing::post(inventory::acquire_server_prop), - ) - // Realm prop catalog (enriched if authenticated) + // Public inventory routes (public server/realm props) + .route("/inventory/server", get(inventory::get_server_props)) .route("/realms/{slug}/inventory", get(inventory::get_realm_props)) - .route( - "/realms/{slug}/inventory/request", - axum::routing::post(inventory::acquire_realm_prop), - ) } diff --git a/crates/chattyness-user-ui/src/api/websocket.rs b/crates/chattyness-user-ui/src/api/websocket.rs index 1473f24..56f8e08 100644 --- a/crates/chattyness-user-ui/src/api/websocket.rs +++ b/crates/chattyness-user-ui/src/api/websocket.rs @@ -18,8 +18,8 @@ use tokio::sync::{broadcast, mpsc}; use uuid::Uuid; use chattyness_db::{ - models::{ActionType, AvatarRenderData, ChannelMemberWithAvatar, EmotionState, User}, - queries::{avatars, channel_members, loose_props, memberships, moderation, realms, scenes}, + models::{AvatarRenderData, ChannelMemberWithAvatar, EmotionState, User}, + queries::{avatars, channel_members, loose_props, realms, scenes}, ws_messages::{close_codes, ClientMessage, DisconnectReason, ServerMessage, WsConfig}, }; use chattyness_error::AppError; @@ -607,20 +607,6 @@ async fn handle_socket( } } ClientMessage::DropProp { inventory_item_id } => { - // Ensure instance exists for this scene (required for loose_props FK) - // In this system, channel_id = scene_id - if let Err(e) = loose_props::ensure_scene_instance( - &mut *recv_conn, - channel_id, - ) - .await - { - tracing::error!( - "[WS] Failed to ensure scene instance: {:?}", - e - ); - } - // Get user's current position for random offset let member_info = channel_members::get_channel_member( &mut *recv_conn, @@ -828,252 +814,6 @@ async fn handle_socket( scene_slug: scene.slug, }).await; } - ClientMessage::ModCommand { subcommand, args } => { - // Check if user is a moderator - let is_mod = match memberships::is_moderator(&pool, user_id, realm_id).await { - Ok(result) => result, - Err(e) => { - tracing::error!("[WS] Failed to check moderator status: {:?}", e); - false - } - }; - - if !is_mod { - let _ = direct_tx.send(ServerMessage::ModCommandResult { - success: false, - message: "You do not have moderator permissions".to_string(), - }).await; - continue; - } - - // Get moderator's current scene info and display name - let mod_member = match channel_members::get_channel_member( - &mut *recv_conn, - channel_id, - user_id, - realm_id, - ).await { - Ok(Some(m)) => m, - Ok(None) | Err(_) => { - let _ = direct_tx.send(ServerMessage::ModCommandResult { - success: false, - message: "Failed to get moderator info".to_string(), - }).await; - continue; - } - }; - - // Get moderator's current scene details - let mod_scene = match scenes::get_scene_by_id(&pool, channel_id).await { - Ok(Some(s)) => s, - Ok(None) | Err(_) => { - let _ = direct_tx.send(ServerMessage::ModCommandResult { - success: false, - message: "Failed to get scene info".to_string(), - }).await; - continue; - } - }; - - match subcommand.as_str() { - "summon" => { - if args.is_empty() { - let _ = direct_tx.send(ServerMessage::ModCommandResult { - success: false, - message: "Usage: /mod summon [nick|*]".to_string(), - }).await; - continue; - } - - let target = &args[0]; - - if target == "*" { - // Summon all users in the realm - let mut summoned_count = 0; - let mut target_ids = Vec::new(); - - // Iterate all connected users in this realm - for entry in ws_state.users.iter() { - let (target_user_id, target_conn) = entry.pair(); - if target_conn.realm_id == realm_id && *target_user_id != user_id { - // Send Summoned message to each user - let summon_msg = ServerMessage::Summoned { - scene_id: mod_scene.id, - scene_slug: mod_scene.slug.clone(), - summoned_by: mod_member.display_name.clone(), - }; - if target_conn.direct_tx.send(summon_msg).await.is_ok() { - summoned_count += 1; - target_ids.push(*target_user_id); - } - } - } - - // Log the action - let metadata = serde_json::json!({ - "scene_id": mod_scene.id, - "scene_slug": mod_scene.slug, - "summoned_count": summoned_count, - }); - let _ = moderation::log_moderation_action( - &pool, - realm_id, - user_id, - ActionType::SummonAll, - None, - &format!("Summoned {} users to scene {}", summoned_count, mod_scene.name), - metadata, - ).await; - - let _ = direct_tx.send(ServerMessage::ModCommandResult { - success: true, - message: format!("Summoned {} users to {}", summoned_count, mod_scene.name), - }).await; - } else { - // Summon specific user by display name - if let Some((target_user_id, target_conn)) = ws_state - .find_user_by_display_name(realm_id, target) - { - if target_user_id == user_id { - let _ = direct_tx.send(ServerMessage::ModCommandResult { - success: false, - message: "You cannot summon yourself".to_string(), - }).await; - continue; - } - - // Send Summoned message to target - let summon_msg = ServerMessage::Summoned { - scene_id: mod_scene.id, - scene_slug: mod_scene.slug.clone(), - summoned_by: mod_member.display_name.clone(), - }; - if target_conn.direct_tx.send(summon_msg).await.is_ok() { - // Log the action - let metadata = serde_json::json!({ - "scene_id": mod_scene.id, - "scene_slug": mod_scene.slug, - "target_display_name": target, - }); - let _ = moderation::log_moderation_action( - &pool, - realm_id, - user_id, - ActionType::Summon, - Some(target_user_id), - &format!("Summoned {} to scene {}", target, mod_scene.name), - metadata, - ).await; - - let _ = direct_tx.send(ServerMessage::ModCommandResult { - success: true, - message: format!("Summoned {} to {}", target, mod_scene.name), - }).await; - } else { - let _ = direct_tx.send(ServerMessage::ModCommandResult { - success: false, - message: format!("Failed to send summon to {}", target), - }).await; - } - } else { - let _ = direct_tx.send(ServerMessage::ModCommandResult { - success: false, - message: format!("User '{}' is not online in this realm", target), - }).await; - } - } - } - "teleport" => { - if args.len() < 2 { - let _ = direct_tx.send(ServerMessage::ModCommandResult { - success: false, - message: "Usage: /mod teleport [nick] [slug]".to_string(), - }).await; - continue; - } - - let target_nick = &args[0]; - let target_slug = &args[1]; - - // Look up the target scene by slug - let target_scene = match scenes::get_scene_by_slug(&pool, realm_id, target_slug).await { - Ok(Some(s)) => s, - Ok(None) => { - let _ = direct_tx.send(ServerMessage::ModCommandResult { - success: false, - message: format!("Scene '{}' not found", target_slug), - }).await; - continue; - } - Err(_) => { - let _ = direct_tx.send(ServerMessage::ModCommandResult { - success: false, - message: "Failed to look up scene".to_string(), - }).await; - continue; - } - }; - - // Find target user by display name - if let Some((target_user_id, target_conn)) = ws_state - .find_user_by_display_name(realm_id, target_nick) - { - if target_user_id == user_id { - let _ = direct_tx.send(ServerMessage::ModCommandResult { - success: false, - message: "You cannot teleport yourself".to_string(), - }).await; - continue; - } - - // Send Summoned message to target user with the specified scene - let teleport_msg = ServerMessage::Summoned { - scene_id: target_scene.id, - scene_slug: target_scene.slug.clone(), - summoned_by: mod_member.display_name.clone(), - }; - if target_conn.direct_tx.send(teleport_msg).await.is_ok() { - // Log the action - let metadata = serde_json::json!({ - "scene_id": target_scene.id, - "scene_slug": target_scene.slug, - "target_display_name": target_nick, - }); - let _ = moderation::log_moderation_action( - &pool, - realm_id, - user_id, - ActionType::Teleport, - Some(target_user_id), - &format!("Teleported {} to scene {}", target_nick, target_scene.name), - metadata, - ).await; - - let _ = direct_tx.send(ServerMessage::ModCommandResult { - success: true, - message: format!("Teleported {} to {}", target_nick, target_scene.name), - }).await; - } else { - let _ = direct_tx.send(ServerMessage::ModCommandResult { - success: false, - message: format!("Failed to send teleport to {}", target_nick), - }).await; - } - } else { - let _ = direct_tx.send(ServerMessage::ModCommandResult { - success: false, - message: format!("User '{}' is not online in this realm", target_nick), - }).await; - } - } - _ => { - let _ = direct_tx.send(ServerMessage::ModCommandResult { - success: false, - message: format!("Unknown mod command: {}", subcommand), - }).await; - } - } - } } } Message::Close(close_frame) => { diff --git a/crates/chattyness-user-ui/src/components.rs b/crates/chattyness-user-ui/src/components.rs index ec189ea..5779f00 100644 --- a/crates/chattyness-user-ui/src/components.rs +++ b/crates/chattyness-user-ui/src/components.rs @@ -9,12 +9,10 @@ pub mod conversation_modal; pub mod editor; pub mod emotion_picker; pub mod forms; -pub mod hotkey_help; pub mod inventory; pub mod keybindings; pub mod keybindings_popup; pub mod layout; -pub mod log_popup; pub mod modals; pub mod notification_history; pub mod notifications; @@ -35,12 +33,10 @@ pub use conversation_modal::*; pub use editor::*; pub use emotion_picker::*; pub use forms::*; -pub use hotkey_help::*; pub use inventory::*; pub use keybindings::*; pub use keybindings_popup::*; pub use layout::*; -pub use log_popup::*; pub use modals::*; pub use notification_history::*; pub use notifications::*; diff --git a/crates/chattyness-user-ui/src/components/chat.rs b/crates/chattyness-user-ui/src/components/chat.rs index 3f3dfe7..0be1a35 100644 --- a/crates/chattyness-user-ui/src/components/chat.rs +++ b/crates/chattyness-user-ui/src/components/chat.rs @@ -17,10 +17,8 @@ enum CommandMode { None, /// Showing command hint for colon commands (`:e[mote], :l[ist]`). ShowingColonHint, - /// Showing command hint for slash commands (`/setting`, `/mod` for mods). + /// Showing command hint for slash commands (`/setting`). ShowingSlashHint, - /// Showing mod command hints only (`/mod summon [nick|*]`). - ShowingModHint, /// Showing emotion list popup. ShowingList, /// Showing scene list popup for teleport. @@ -100,35 +98,6 @@ fn parse_whisper_command(cmd: &str) -> Option<(String, String)> { Some((name, message)) } -/// Parse a mod command and return (subcommand, args) if valid. -/// -/// Supports `/mod summon [nick|*]` etc. -#[cfg(feature = "hydrate")] -fn parse_mod_command(cmd: &str) -> Option<(String, Vec)> { - let cmd = cmd.trim(); - - // Strip the leading slash if present - let cmd = cmd.strip_prefix('/').unwrap_or(cmd); - - // Check for `mod [args...]` - let rest = cmd.strip_prefix("mod ").map(str::trim)?; - - if rest.is_empty() { - return None; - } - - // Split into parts - let parts: Vec<&str> = rest.split_whitespace().collect(); - if parts.is_empty() { - return None; - } - - let subcommand = parts[0].to_lowercase(); - let args: Vec = parts[1..].iter().map(|s| s.to_string()).collect(); - - Some((subcommand, args)) -} - /// Chat input component with emote command support. /// /// Props: @@ -140,7 +109,6 @@ fn parse_mod_command(cmd: &str) -> Option<(String, Vec)> { /// - `on_focus_change`: Callback when focus state changes /// - `on_open_settings`: Callback to open settings popup /// - `on_open_inventory`: Callback to open inventory popup -/// - `on_open_log`: Callback to open message log popup /// - `whisper_target`: Signal containing the display name to whisper to (triggers pre-fill) /// - `scenes`: List of available scenes for teleport command /// - `allow_user_teleport`: Whether teleporting is enabled for this realm @@ -155,13 +123,9 @@ pub fn ChatInput( on_focus_change: Callback, #[prop(optional)] on_open_settings: Option>, #[prop(optional)] on_open_inventory: Option>, - /// Callback to open message log popup. - #[prop(optional)] - on_open_log: Option>, /// Signal containing the display name to whisper to. When set, pre-fills the input. - /// Uses RwSignal so the component can clear it after consuming. - #[prop(optional)] - whisper_target: Option>>, + #[prop(optional, into)] + whisper_target: Option>>, /// List of available scenes for teleport command. #[prop(optional, into)] scenes: Option>>, @@ -171,12 +135,6 @@ pub fn ChatInput( /// Callback when a teleport is requested. #[prop(optional)] on_teleport: Option>, - /// Whether the current user is a moderator. - #[prop(default = Signal::derive(|| false))] - is_moderator: Signal, - /// Callback to send a mod command. - #[prop(optional)] - on_mod_command: Option)>>, ) -> impl IntoView { let (message, set_message) = signal(String::new()); let (command_mode, set_command_mode) = signal(CommandMode::None); @@ -278,9 +236,6 @@ pub fn ChatInput( let _ = input.focus(); let len = whisper_prefix.len() as u32; let _ = input.set_selection_range(len, len); - - // Clear the whisper target so it doesn't re-trigger on re-render - whisper_signal.set(None); } }); } @@ -361,37 +316,21 @@ pub fn ChatInput( } }; - // Check if typing mod command (only for moderators) - // Show mod hint when typing "/mod" or "/mod ..." - let is_typing_mod = is_moderator.get_untracked() - && (cmd == "mod" || cmd.starts_with("mod ")); - // Show /mod in slash hints when just starting to type it - let is_partial_mod = is_moderator.get_untracked() - && !cmd.is_empty() - && "mod".starts_with(&cmd) - && cmd != "mod"; - if is_complete_whisper || is_complete_teleport { // User is typing the argument part, no hint needed set_command_mode.set(CommandMode::None); - } else if is_typing_mod { - // Show mod-specific hint bar - set_command_mode.set(CommandMode::ShowingModHint); } else if cmd.is_empty() || "setting".starts_with(&cmd) || "inventory".starts_with(&cmd) || "whisper".starts_with(&cmd) || "teleport".starts_with(&cmd) - || "log".starts_with(&cmd) || cmd == "setting" || cmd == "settings" || cmd == "inventory" - || cmd == "log" || cmd.starts_with("w ") || cmd.starts_with("whisper ") || cmd.starts_with("t ") || cmd.starts_with("teleport ") - || is_partial_mod { set_command_mode.set(CommandMode::ShowingSlashHint); } else { @@ -409,7 +348,6 @@ pub fn ChatInput( let apply_emotion = apply_emotion.clone(); let on_open_settings = on_open_settings.clone(); let on_open_inventory = on_open_inventory.clone(); - let on_open_log = on_open_log.clone(); move |ev: web_sys::KeyboardEvent| { let key = ev.key(); let current_mode = command_mode.get_untracked(); @@ -546,20 +484,6 @@ pub fn ChatInput( ev.prevent_default(); return; } - // Autocomplete to /log if /l, /lo (but not if it could be /list or /teleport) - // Only match /l if it's exactly /l (not /li which would match /list) - if !cmd.is_empty() - && "log".starts_with(&cmd) - && cmd != "log" - && !cmd.starts_with("li") - { - set_message.set("/log".to_string()); - if let Some(input) = input_ref.get() { - input.set_value("/log"); - } - ev.prevent_default(); - return; - } } // Always prevent Tab from moving focus when in input ev.prevent_default(); @@ -603,21 +527,6 @@ pub fn ChatInput( return; } - // /l, /lo, /log - open message log - if !cmd.is_empty() && "log".starts_with(&cmd) { - if let Some(ref callback) = on_open_log { - callback.run(()); - } - set_message.set(String::new()); - set_command_mode.set(CommandMode::None); - if let Some(input) = input_ref.get() { - input.set_value(""); - let _ = input.blur(); - } - ev.prevent_default(); - return; - } - // /w NAME message or /whisper NAME message if let Some((target_name, whisper_content)) = parse_whisper_command(&msg) { if !whisper_content.trim().is_empty() { @@ -676,22 +585,6 @@ pub fn ChatInput( return; } - // /mod [args...] - execute mod command - if is_moderator.get_untracked() { - if let Some((subcommand, args)) = parse_mod_command(&msg) { - if let Some(ref callback) = on_mod_command { - callback.run((subcommand, args)); - } - set_message.set(String::new()); - set_command_mode.set(CommandMode::None); - if let Some(input) = input_ref.get() { - input.set_value(""); - } - ev.prevent_default(); - return; - } - } - // Invalid slash command - just ignore, don't send ev.prevent_default(); return; @@ -809,7 +702,7 @@ pub fn ChatInput( - // Slash command hint bar (/s[etting], /i[nventory], /w[hisper], /l[og], /t[eleport]) + // Slash command hint bar (/s[etting], /i[nventory], /w[hisper], /t[eleport])
"/" @@ -823,35 +716,12 @@ pub fn ChatInput( "/" "w" "[hisper] name" - "|" - "/" - "l" - "[og]" "|" "/" "t" "[eleport]" - // Show /mod hint for moderators (details shown when typing /mod) - - "|" - "/" - "mod" - -
-
- - // Mod command hint bar (shown when typing /mod) - -
- "/" - "mod" - " summon" - " [nick|*]" - " | " - "teleport" - " [nick] [slug]"
diff --git a/crates/chattyness-user-ui/src/components/hotkey_help.rs b/crates/chattyness-user-ui/src/components/hotkey_help.rs deleted file mode 100644 index 8f25986..0000000 --- a/crates/chattyness-user-ui/src/components/hotkey_help.rs +++ /dev/null @@ -1,106 +0,0 @@ -//! Hotkey help overlay component. -//! -//! Displays available keyboard shortcuts while held. - -use leptos::prelude::*; - -/// Hotkey help overlay that shows available keyboard shortcuts. -/// -/// This component displays while the user holds down the `?` key. -#[component] -pub fn HotkeyHelp( - /// Whether the help overlay is visible. - #[prop(into)] - visible: Signal, -) -> impl IntoView { - let outer_class = move || { - if visible.get() { - "fixed inset-0 z-50 flex items-center justify-center pointer-events-none" - } else { - "hidden" - } - }; - - view! { -
- // Semi-transparent backdrop - - } -} - -/// A single hotkey row with key and description. -#[component] -fn HotkeyRow( - /// The key or key combination. - key: &'static str, - /// Description of what the key does. - description: &'static str, -) -> impl IntoView { - view! { -
-
- - {key} - -
-
- {description} -
-
- } -} diff --git a/crates/chattyness-user-ui/src/components/inventory.rs b/crates/chattyness-user-ui/src/components/inventory.rs index 67fa342..add9e96 100644 --- a/crates/chattyness-user-ui/src/components/inventory.rs +++ b/crates/chattyness-user-ui/src/components/inventory.rs @@ -4,7 +4,7 @@ use leptos::prelude::*; use leptos::reactive::owner::LocalStorage; use uuid::Uuid; -use chattyness_db::models::{InventoryItem, PropAcquisitionInfo}; +use chattyness_db::models::{InventoryItem, PublicProp}; #[cfg(feature = "hydrate")] use chattyness_db::ws_messages::ClientMessage; @@ -46,13 +46,13 @@ pub fn InventoryPopup( let (selected_item, set_selected_item) = signal(Option::::None); let (dropping, set_dropping) = signal(false); - // Server props state (with acquisition info for authenticated users) - let (server_props, set_server_props) = signal(Vec::::new()); + // Server props state + let (server_props, set_server_props) = signal(Vec::::new()); let (server_loading, set_server_loading) = signal(false); let (server_error, set_server_error) = signal(Option::::None); - // Realm props state (with acquisition info for authenticated users) - let (realm_props, set_realm_props) = signal(Vec::::new()); + // Realm props state + let (realm_props, set_realm_props) = signal(Vec::::new()); let (realm_loading, set_realm_loading) = signal(false); let (realm_error, set_realm_error) = signal(Option::::None); @@ -61,9 +61,6 @@ pub fn InventoryPopup( let (server_loaded, set_server_loaded) = signal(false); let (realm_loaded, set_realm_loaded) = signal(false); - // Trigger to refresh my inventory after acquisition - let (inventory_refresh_trigger, set_inventory_refresh_trigger) = signal(0u32); - // Fetch my inventory when popup opens or tab is selected #[cfg(feature = "hydrate")] { @@ -71,9 +68,6 @@ pub fn InventoryPopup( use leptos::task::spawn_local; Effect::new(move |_| { - // Track refresh trigger to refetch after acquisition - let _refresh = inventory_refresh_trigger.get(); - if !open.get() { // Reset state when closing set_selected_item.set(None); @@ -92,7 +86,7 @@ pub fn InventoryPopup( set_error.set(None); spawn_local(async move { - let response = Request::get("/api/user/me/inventory").send().await; + let response = Request::get("/api/inventory").send().await; match response { Ok(resp) if resp.ok() => { if let Ok(data) = resp @@ -118,7 +112,6 @@ pub fn InventoryPopup( } // Fetch server props when server tab is selected - // Uses status endpoint if authenticated (non-guest), otherwise basic endpoint #[cfg(feature = "hydrate")] { use gloo_net::http::Request; @@ -133,19 +126,17 @@ pub fn InventoryPopup( set_server_error.set(None); spawn_local(async move { - // Single endpoint returns enriched data if authenticated - let response = Request::get("/api/server/inventory").send().await; + let response = Request::get("/api/inventory/server").send().await; match response { Ok(resp) if resp.ok() => { if let Ok(data) = resp - .json::() + .json::() .await { set_server_props.set(data.props); set_server_loaded.set(true); } else { - set_server_error - .set(Some("Failed to parse server props".to_string())); + set_server_error.set(Some("Failed to parse server props".to_string())); } } Ok(resp) => { @@ -184,20 +175,19 @@ pub fn InventoryPopup( set_realm_error.set(None); spawn_local(async move { - // Single endpoint returns enriched data if authenticated - let endpoint = format!("/api/realms/{}/inventory", slug); - let response = Request::get(&endpoint).send().await; + let response = Request::get(&format!("/api/realms/{}/inventory", slug)) + .send() + .await; match response { Ok(resp) if resp.ok() => { if let Ok(data) = resp - .json::() + .json::() .await { set_realm_props.set(data.props); set_realm_loaded.set(true); } else { - set_realm_error - .set(Some("Failed to parse realm props".to_string())); + set_realm_error.set(Some("Failed to parse realm props".to_string())); } } Ok(resp) => { @@ -282,40 +272,23 @@ pub fn InventoryPopup( // Server tab - // Realm tab -
@@ -477,103 +450,17 @@ fn MyInventoryTab( } } -/// Acquisition props tab content with acquire functionality. +/// Public props tab content (read-only display). #[component] -fn AcquisitionPropsTab( - #[prop(into)] props: Signal>, - set_props: WriteSignal>, +fn PublicPropsTab( + #[prop(into)] props: Signal>, #[prop(into)] loading: Signal, #[prop(into)] error: Signal>, tab_name: &'static str, empty_message: &'static str, - #[prop(into)] is_guest: Signal, - /// Static endpoint for server props (e.g., "/api/server/inventory/request") - #[prop(optional)] - acquire_endpoint: Option<&'static str>, - /// Whether this is a realm props tab (uses dynamic endpoint with slug) - #[prop(optional, default = false)] - acquire_endpoint_is_realm: bool, - /// Realm slug for realm prop acquisition (required if acquire_endpoint_is_realm is true) - #[prop(optional, into)] - realm_slug: Signal, - #[prop(into)] on_acquired: Callback<()>, ) -> impl IntoView { // Selected prop for showing details let (selected_prop, set_selected_prop) = signal(Option::::None); - let (acquiring, set_acquiring) = signal(false); - let (acquire_error, set_acquire_error) = signal(Option::::None); - - // Handle acquire action - let acquire_endpoint_opt = acquire_endpoint.map(|s| s.to_string()); - let do_acquire = Callback::new(move |prop_id: Uuid| { - #[cfg(feature = "hydrate")] - { - set_acquiring.set(true); - set_acquire_error.set(None); - - let endpoint = if acquire_endpoint_is_realm { - let slug = realm_slug.get(); - if slug.is_empty() { - set_acquire_error.set(Some("No realm selected".to_string())); - set_acquiring.set(false); - return; - } - format!("/api/realms/{}/inventory/request", slug) - } else { - acquire_endpoint_opt.clone().unwrap_or_default() - }; - - let on_acquired = on_acquired.clone(); - - leptos::task::spawn_local(async move { - use gloo_net::http::Request; - - // Simple body with just prop_id - realm_id comes from URL for realm props - let body = serde_json::json!({ - "prop_id": prop_id - }); - - let response = Request::post(&endpoint) - .header("Content-Type", "application/json") - .body(body.to_string()) - .unwrap() - .send() - .await; - - match response { - Ok(resp) if resp.ok() => { - // Update local state to mark prop as owned - set_props.update(|props| { - if let Some(prop) = props.iter_mut().find(|p| p.id == prop_id) { - prop.user_owns = true; - } - }); - // Notify parent to refresh inventory - on_acquired.run(()); - } - Ok(resp) => { - // Try to parse error message from response - if let Ok(error_json) = resp.json::().await { - let error_msg = error_json - .get("error") - .and_then(|e| e.as_str()) - .unwrap_or("Unknown error"); - set_acquire_error.set(Some(error_msg.to_string())); - } else { - set_acquire_error.set(Some(format!( - "Failed to acquire prop: {}", - resp.status() - ))); - } - } - Err(e) => { - set_acquire_error.set(Some(format!("Network error: {}", e))); - } - } - set_acquiring.set(false); - }); - } - }); view! { // Loading state @@ -590,13 +477,6 @@ fn AcquisitionPropsTab( - // Acquire error state - -
-

{move || acquire_error.get().unwrap_or_default()}

-
-
- // Empty state
@@ -621,7 +501,7 @@ fn AcquisitionPropsTab( - // Ownership badge - - - } } />
- // Selected prop details with acquire button + // Selected prop details (read-only) {move || { let prop_id = selected_prop.get()?; let prop = props.get().into_iter().find(|p| p.id == prop_id)?; - let guest = is_guest.get(); - let is_acquiring = acquiring.get(); - - // Determine button state - let (button_text, button_class, button_disabled, button_title) = if guest { - ("Sign in to Acquire", "bg-gray-600 text-gray-400 cursor-not-allowed", true, "Guests cannot acquire props") - } else if prop.user_owns { - ("Already Owned", "bg-green-700 text-white cursor-default", true, "You already own this prop") - } else if prop.is_claimed && prop.is_unique { - ("Claimed", "bg-red-700 text-white cursor-not-allowed", true, "This unique prop has been claimed by another user") - } else if !prop.is_available { - ("Unavailable", "bg-gray-600 text-gray-400 cursor-not-allowed", true, "This prop is not currently available") - } else if is_acquiring { - ("Acquiring...", "bg-blue-600 text-white opacity-50", true, "") - } else { - ("Acquire", "bg-blue-600 hover:bg-blue-700 text-white", false, "Add this prop to your inventory") - }; - - let prop_name = prop.name.clone(); - let prop_description = prop.description.clone(); Some(view! {
-

{prop_name}

- {prop_description.map(|desc| view! { +

{prop.name.clone()}

+ {prop.description.map(|desc| view! {

{desc}

})}
- +

"View only"

}) @@ -727,4 +563,3 @@ fn AcquisitionPropsTab(
} } - diff --git a/crates/chattyness-user-ui/src/components/log_popup.rs b/crates/chattyness-user-ui/src/components/log_popup.rs deleted file mode 100644 index c4b09f1..0000000 --- a/crates/chattyness-user-ui/src/components/log_popup.rs +++ /dev/null @@ -1,206 +0,0 @@ -//! Message log popup component. -//! -//! Displays a filterable chronological log of received messages. - -use leptos::prelude::*; -use leptos::reactive::owner::LocalStorage; - -use super::chat_types::{ChatMessage, MessageLog}; -use super::modals::Modal; - -/// Filter mode for message log display. -#[derive(Clone, Copy, PartialEq, Eq, Default)] -pub enum LogFilter { - /// Show all messages. - #[default] - All, - /// Show only broadcast chat messages. - Chat, - /// Show only whispers. - Whispers, -} - -/// Message log popup component. -/// -/// Displays a filterable list of messages from the session. -#[component] -pub fn LogPopup( - #[prop(into)] open: Signal, - message_log: StoredValue, - on_close: Callback<()>, -) -> impl IntoView { - let (filter, set_filter) = signal(LogFilter::All); - - // Get filtered messages based on current filter - // Note: We read `open` to force re-evaluation when the modal opens, - // since StoredValue is not reactive. - let filtered_messages = move || { - // Reading open ensures we re-fetch messages when modal opens - let _ = open.get(); - let current_filter = filter.get(); - message_log.with_value(|log| { - log.all_messages() - .iter() - .filter(|msg| match current_filter { - LogFilter::All => true, - LogFilter::Chat => !msg.is_whisper, - LogFilter::Whispers => msg.is_whisper, - }) - .cloned() - .collect::>() - }) - }; - - // Auto-scroll to bottom when modal opens - #[cfg(feature = "hydrate")] - { - Effect::new(move |_| { - if open.get() { - // Use a small delay to ensure the DOM is rendered - use gloo_timers::callback::Timeout; - Timeout::new(50, || { - if let Some(document) = web_sys::window().and_then(|w| w.document()) { - if let Some(container) = document - .query_selector(".log-popup-messages") - .ok() - .flatten() - { - let scroll_height = container.scroll_height(); - container.set_scroll_top(scroll_height); - } - } - }) - .forget(); - } - }); - } - - let tab_class = |is_active: bool| { - if is_active { - "px-3 py-1.5 rounded text-sm font-medium bg-blue-600 text-white" - } else { - "px-3 py-1.5 rounded text-sm font-medium bg-gray-700 text-gray-300 hover:bg-gray-600" - } - }; - - view! { - - // Filter tabs -
- - - -
- - // Message list -
- - "No messages yet" -

- } - > -
    - - - "[" - {format_timestamp(timestamp)} - "] " - - - {display_name} - - - - "(whisper)" - - - - ": " - - - {content} - - - } - } - /> -
-
-
- - // Footer hint -
- "Press " "Esc" " to close" -
-
- } -} - -/// Format a timestamp for display (HH:MM:SS). -fn format_timestamp(timestamp: i64) -> String { - #[cfg(feature = "hydrate")] - { - let date = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(timestamp as f64)); - let hours = date.get_hours(); - let minutes = date.get_minutes(); - let seconds = date.get_seconds(); - format!("{:02}:{:02}:{:02}", hours, minutes, seconds) - } - #[cfg(not(feature = "hydrate"))] - { - let _ = timestamp; - String::new() - } -} diff --git a/crates/chattyness-user-ui/src/components/ws_client.rs b/crates/chattyness-user-ui/src/components/ws_client.rs index c31d18f..a5845ef 100644 --- a/crates/chattyness-user-ui/src/components/ws_client.rs +++ b/crates/chattyness-user-ui/src/components/ws_client.rs @@ -88,26 +88,6 @@ pub struct TeleportInfo { pub scene_slug: String, } -/// Summon information received from server (moderator summoned this user). -#[derive(Clone, Debug)] -pub struct SummonInfo { - /// Scene ID to teleport to. - pub scene_id: uuid::Uuid, - /// Scene slug for URL. - pub scene_slug: String, - /// Display name of the moderator who summoned. - pub summoned_by: String, -} - -/// Result of a moderator command. -#[derive(Clone, Debug)] -pub struct ModCommandResultInfo { - /// Whether the command succeeded. - pub success: bool, - /// Human-readable result message. - pub message: String, -} - /// Hook to manage WebSocket connection for a channel. /// /// Returns a tuple of: @@ -127,8 +107,6 @@ pub fn use_channel_websocket( on_welcome: Option>, on_error: Option>, on_teleport_approved: Option>, - on_summoned: Option>, - on_mod_command_result: Option>, ) -> (Signal, WsSenderStorage) { use std::cell::RefCell; use std::rc::Rc; @@ -242,8 +220,6 @@ pub fn use_channel_websocket( let on_welcome_clone = on_welcome.clone(); let on_error_clone = on_error.clone(); let on_teleport_approved_clone = on_teleport_approved.clone(); - let on_summoned_clone = on_summoned.clone(); - let on_mod_command_result_clone = on_mod_command_result.clone(); // For starting heartbeat on Welcome let ws_ref_for_heartbeat = ws_ref.clone(); let heartbeat_started: Rc> = Rc::new(RefCell::new(false)); @@ -317,8 +293,6 @@ pub fn use_channel_websocket( &on_member_fading_clone, &on_error_clone, &on_teleport_approved_clone, - &on_summoned_clone, - &on_mod_command_result_clone, ¤t_user_id_for_msg, ); } @@ -428,8 +402,6 @@ fn handle_server_message( on_member_fading: &Callback, on_error: &Option>, on_teleport_approved: &Option>, - on_summoned: &Option>, - on_mod_command_result: &Option>, current_user_id: &std::rc::Rc>>, ) { let mut members_vec = members.borrow_mut(); @@ -606,24 +578,6 @@ fn handle_server_message( }); } } - ServerMessage::Summoned { - scene_id, - scene_slug, - summoned_by, - } => { - if let Some(callback) = on_summoned { - callback.run(SummonInfo { - scene_id, - scene_slug, - summoned_by, - }); - } - } - ServerMessage::ModCommandResult { success, message } => { - if let Some(callback) = on_mod_command_result { - callback.run(ModCommandResultInfo { success, message }); - } - } } } @@ -642,8 +596,6 @@ pub fn use_channel_websocket( _on_welcome: Option>, _on_error: Option>, _on_teleport_approved: Option>, - _on_summoned: Option>, - _on_mod_command_result: Option>, ) -> (Signal, WsSenderStorage) { let (ws_state, _) = signal(WsState::Disconnected); let sender: WsSenderStorage = StoredValue::new_local(None); diff --git a/crates/chattyness-user-ui/src/pages/realm.rs b/crates/chattyness-user-ui/src/pages/realm.rs index c905111..ef81a32 100644 --- a/crates/chattyness-user-ui/src/pages/realm.rs +++ b/crates/chattyness-user-ui/src/pages/realm.rs @@ -13,14 +13,14 @@ use uuid::Uuid; use crate::components::{ ActiveBubble, AvatarEditorPopup, Card, ChatInput, ConversationModal, EmotionKeybindings, - FadingMember, HotkeyHelp, InventoryPopup, KeybindingsPopup, LogPopup, MessageLog, - NotificationHistoryModal, NotificationMessage, NotificationToast, RealmHeader, - RealmSceneViewer, ReconnectionOverlay, SettingsPopup, ViewerSettings, + FadingMember, InventoryPopup, KeybindingsPopup, MessageLog, NotificationHistoryModal, + NotificationMessage, NotificationToast, RealmHeader, RealmSceneViewer, ReconnectionOverlay, + SettingsPopup, ViewerSettings, }; #[cfg(feature = "hydrate")] use crate::components::{ ChannelMemberInfo, ChatMessage, DEFAULT_BUBBLE_TIMEOUT_MS, FADE_DURATION_MS, HistoryEntry, - ModCommandResultInfo, SummonInfo, TeleportInfo, WsError, add_to_history, use_channel_websocket, + TeleportInfo, WsError, add_to_history, use_channel_websocket, }; use crate::utils::LocalStoragePersist; #[cfg(feature = "hydrate")] @@ -74,12 +74,6 @@ pub fn RealmPage() -> impl IntoView { let (settings_open, set_settings_open) = signal(false); let viewer_settings = RwSignal::new(ViewerSettings::load()); - // Log popup state - let (log_open, set_log_open) = signal(false); - - // Hotkey help overlay state (shown while ? is held) - let (hotkey_help_visible, set_hotkey_help_visible) = signal(false); - // Keybindings popup state let keybindings = RwSignal::new(EmotionKeybindings::load()); let (keybindings_open, set_keybindings_open) = signal(false); @@ -111,7 +105,7 @@ pub fn RealmPage() -> impl IntoView { let (is_guest, set_is_guest) = signal(false); // Whisper target - when set, triggers pre-fill in ChatInput - let whisper_target = RwSignal::new(Option::::None); + let (whisper_target, set_whisper_target) = signal(Option::::None); // Notification state for cross-scene whispers let (current_notification, set_current_notification) = @@ -141,12 +135,6 @@ pub fn RealmPage() -> impl IntoView { // Whether teleportation is allowed in this realm let (allow_user_teleport, set_allow_user_teleport) = signal(false); - // Whether the current user is a moderator (set from Welcome message or membership) - let (is_moderator, set_is_moderator) = signal(false); - - // Mod notification state (for summon notifications, command results) - let (mod_notification, set_mod_notification) = signal(Option::<(bool, String)>::None); - let realm_data = LocalResource::new(move || { let slug = slug.get(); async move { @@ -424,90 +412,6 @@ pub fn RealmPage() -> impl IntoView { }); }); - // Callback for being summoned by a moderator - show notification and teleport - #[cfg(feature = "hydrate")] - let on_summoned = Callback::new(move |info: SummonInfo| { - // Show notification - set_mod_notification.set(Some((true, format!("Summoned by {}", info.summoned_by)))); - - // Auto-dismiss notification after 3 seconds - let timeout = gloo_timers::callback::Timeout::new(3000, move || { - set_mod_notification.set(None); - }); - timeout.forget(); - - let scene_id = info.scene_id; - let scene_slug = info.scene_slug.clone(); - let realm_slug = slug.get_untracked(); - - // Fetch the new scene data (same as teleport approval) - let scene_slug_for_url = scene_slug.clone(); - let realm_slug_for_url = realm_slug.clone(); - spawn_local(async move { - use gloo_net::http::Request; - let response = Request::get(&format!( - "/api/realms/{}/scenes/{}", - realm_slug, scene_slug - )) - .send() - .await; - - if let Ok(resp) = response { - if resp.ok() { - if let Ok(scene) = resp.json::().await { - // Update scene dimensions from the new scene - if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) { - set_scene_dimensions.set((w as f64, h as f64)); - } - - // Update URL to reflect new scene - if let Some(window) = web_sys::window() { - if let Ok(history) = window.history() { - let new_url = if scene.is_entry_point { - format!("/realms/{}", realm_slug_for_url) - } else { - format!( - "/realms/{}/scenes/{}", - realm_slug_for_url, scene_slug_for_url - ) - }; - let _ = history.replace_state_with_url( - &wasm_bindgen::JsValue::NULL, - "", - Some(&new_url), - ); - } - } - - // Update the current scene for the viewer - set_current_scene.set(Some(scene)); - } - } - } - - // Update channel_id to trigger WebSocket reconnection - set_channel_id.set(Some(scene_id)); - - // Clear members since we're switching scenes - set_members.set(Vec::new()); - - // Trigger a reconnect to ensure fresh connection - reconnect_trigger.update(|t| *t += 1); - }); - }); - - // Callback for mod command result - show notification - #[cfg(feature = "hydrate")] - let on_mod_command_result = Callback::new(move |info: ModCommandResultInfo| { - set_mod_notification.set(Some((info.success, info.message))); - - // Auto-dismiss notification after 3 seconds - let timeout = gloo_timers::callback::Timeout::new(3000, move || { - set_mod_notification.set(None); - }); - timeout.forget(); - }); - #[cfg(feature = "hydrate")] let (ws_state, ws_sender) = use_channel_websocket( slug, @@ -522,8 +426,6 @@ pub fn RealmPage() -> impl IntoView { Some(on_welcome), Some(on_ws_error), Some(on_teleport_approved), - Some(on_summoned), - Some(on_mod_command_result), ); // Set channel ID, current scene, and scene dimensions when entry scene loads @@ -892,20 +794,6 @@ pub fn RealmPage() -> impl IntoView { return; } - // Handle 'l' to toggle message log - if key == "l" || key == "L" { - set_log_open.update(|v| *v = !*v); - ev.prevent_default(); - return; - } - - // Handle '?' to show hotkey help (while held) - if key == "?" { - set_hotkey_help_visible.set(true); - ev.prevent_default(); - return; - } - // Check if 'e' key was pressed if key == "e" || key == "E" { *e_pressed_clone.borrow_mut() = true; @@ -942,32 +830,6 @@ pub fn RealmPage() -> impl IntoView { // Store the closure for cleanup *closure_holder_clone.borrow_mut() = Some(closure); - - // Add keyup handler for releasing '?' (hotkey help) - // We hide on any keyup when visible, since ? = Shift+/ and releasing - // either key means the user is no longer holding '?' - let keyup_closure = Closure::::new( - move |ev: web_sys::KeyboardEvent| { - let key = ev.key(); - // Hide if releasing ?, /, or Shift while help is visible - if hotkey_help_visible.get_untracked() - && (key == "?" || key == "/" || key == "Shift") - { - set_hotkey_help_visible.set(false); - ev.prevent_default(); - } - }, - ); - - if let Some(window) = web_sys::window() { - let _ = window.add_event_listener_with_callback( - "keyup", - keyup_closure.as_ref().unchecked_ref(), - ); - } - - // Forget the keyup closure (it lives for the duration of the page) - keyup_closure.forget(); }); // Save position on page unload (beforeunload event) @@ -1047,9 +909,6 @@ pub fn RealmPage() -> impl IntoView { Some(RealmRole::Owner) | Some(RealmRole::Moderator) ); - // Update is_moderator signal for mod commands - set_is_moderator.set(can_admin); - // Get scene name and description for header let scene_info = entry_scene .get() @@ -1123,11 +982,9 @@ pub fn RealmPage() -> impl IntoView { let on_open_inventory_cb = Callback::new(move |_: ()| { set_inventory_open.set(true); }); - let on_open_log_cb = Callback::new(move |_: ()| { - set_log_open.set(true); - }); + let whisper_target_signal = Signal::derive(move || whisper_target.get()); let on_whisper_request_cb = Callback::new(move |target: String| { - whisper_target.set(Some(target)); + set_whisper_target.set(Some(target)); }); let scenes_signal = Signal::derive(move || available_scenes.get()); let teleport_enabled_signal = Signal::derive(move || allow_user_teleport.get()); @@ -1141,17 +998,6 @@ pub fn RealmPage() -> impl IntoView { } }); }); - #[cfg(feature = "hydrate")] - let ws_for_mod = ws_sender_clone.clone(); - let on_mod_command_cb = Callback::new(move |(subcommand, args): (String, Vec)| { - #[cfg(feature = "hydrate")] - ws_for_mod.with_value(|sender| { - if let Some(send_fn) = sender { - send_fn(ClientMessage::ModCommand { subcommand, args }); - } - }); - }); - let is_moderator_signal = Signal::derive(move || is_moderator.get()); view! {
impl IntoView { on_focus_change=on_chat_focus_change.clone() on_open_settings=on_open_settings_cb on_open_inventory=on_open_inventory_cb - on_open_log=on_open_log_cb - whisper_target=whisper_target + whisper_target=whisper_target_signal scenes=scenes_signal allow_user_teleport=teleport_enabled_signal on_teleport=on_teleport_cb - is_moderator=is_moderator_signal - on_mod_command=on_mod_command_cb />
@@ -1245,15 +1088,6 @@ pub fn RealmPage() -> impl IntoView { scene_dimensions=scene_dimensions.get() /> - // Log popup - - // Keybindings popup impl IntoView { impl IntoView { }} - // Mod command notification toast (summon, command results) - - {move || { - if let Some((success, msg)) = mod_notification.get() { - let (bg_class, border_class, icon_class, icon) = if success { - ("bg-purple-900/90", "border-purple-500/50", "text-purple-300", "✓") - } else { - ("bg-yellow-900/90", "border-yellow-500/50", "text-yellow-300", "⚠") - }; - view! { -
-
- "[MOD]" - {icon} - {msg} - -
-
- }.into_any() - } else { - ().into_any() - } - }} -
- // Notification history modal impl IntoView { /> } } - - // Hotkey help overlay (shown while ? is held) - } .into_any() } diff --git a/db/schema/types/001_enums.sql b/db/schema/types/001_enums.sql index fbf5edd..fa7e9e6 100644 --- a/db/schema/types/001_enums.sql +++ b/db/schema/types/001_enums.sql @@ -71,10 +71,9 @@ COMMENT ON TYPE server.portability IS 'Whether prop can be used outside origin r CREATE TYPE server.avatar_layer AS ENUM ( 'skin', -- Background layer (behind user, body/face) 'clothes', -- Middle layer (with user, worn items) - 'accessories', -- Foreground layer (in front of user, held/attached items) - 'emote' -- Facial expression layer (overlays face) + 'accessories' -- Foreground layer (in front of user, held/attached items) ); -COMMENT ON TYPE server.avatar_layer IS 'Z-layer for avatar prop positioning: skin (behind), clothes (with), accessories (front), emote (expressions)'; +COMMENT ON TYPE server.avatar_layer IS 'Z-layer for avatar prop positioning: skin (behind), clothes (with), accessories (front)'; -- Emotion state for avatar overlays (moved from props schema) CREATE TYPE server.emotion_state AS ENUM ( @@ -101,9 +100,7 @@ CREATE TYPE server.action_type AS ENUM ( 'ban', 'unban', 'prop_removal', - 'message_deletion', - 'summon', - 'summon_all' + 'message_deletion' ); COMMENT ON TYPE server.action_type IS 'Type of moderation action taken'; diff --git a/run-dev.sh b/run-dev.sh index 0eb14c4..8e1a213 100755 --- a/run-dev.sh +++ b/run-dev.sh @@ -35,7 +35,6 @@ Options: -k, --kill Kill existing instance and exit -s, --status Check if an instance is running -r, --release Build and run in release mode - --public Bind to 0.0.0.0 instead of 127.0.0.1 Examples: $0 # Build and run both @@ -71,19 +70,17 @@ print_server_info() { local mode="$1" local build_type="Debug" [ "$RELEASE" = "true" ] && build_type="Release" - local bind_addr="127.0.0.1" - [ "$PUBLIC" = "true" ] && bind_addr="0.0.0.0" echo "" echo "========================================" echo " Chattyness Development ($mode - $build_type)" echo "========================================" echo "" if run_owner; then - echo " Owner Admin: http://$bind_addr:$OWNER_PORT" + echo " Owner Admin: http://127.0.0.1:$OWNER_PORT" fi if run_app; then - echo " User App: http://$bind_addr:$APP_PORT" - echo " Realm Admin: http://$bind_addr:$APP_PORT/admin" + echo " User App: http://127.0.0.1:$APP_PORT" + echo " Realm Admin: http://127.0.0.1:$APP_PORT/admin" fi echo "" if [ "$TARGET" = "both" ]; then @@ -228,9 +225,6 @@ do_watch() { local release_flag="" [ "$RELEASE" = "true" ] && release_flag="--release" - local bind_addr="127.0.0.1" - [ "$PUBLIC" = "true" ] && bind_addr="0.0.0.0" - # Build owner first to create CSS (needed if user app needs admin CSS) if run_owner; then echo "Building owner app first (for admin CSS)..." @@ -240,12 +234,12 @@ do_watch() { # Start watch processes if run_owner; then - HOST="$bind_addr" cargo leptos watch -p chattyness-owner $release_flag & + cargo leptos watch -p chattyness-owner $release_flag & OWNER_PID=$! fi if run_app; then - HOST="$bind_addr" cargo leptos watch -p chattyness-app --split $release_flag & + cargo leptos watch -p chattyness-app --split $release_flag & APP_PID=$! fi @@ -285,18 +279,15 @@ do_build() { fi # Start servers - local bind_addr="127.0.0.1" - [ "$PUBLIC" = "true" ] && bind_addr="0.0.0.0" - if run_owner; then - echo "Starting Owner Server on $bind_addr:$OWNER_PORT..." - HOST="$bind_addr" ./target/$target_dir/chattyness-owner & + echo "Starting Owner Server on :$OWNER_PORT..." + ./target/$target_dir/chattyness-owner & OWNER_PID=$! fi if run_app; then - echo "Starting App Server on $bind_addr:$APP_PORT..." - HOST="$bind_addr" ./target/$target_dir/chattyness-app & + echo "Starting App Server on :$APP_PORT..." + ./target/$target_dir/chattyness-app & APP_PID=$! fi @@ -315,7 +306,6 @@ FORCE="false" KILL_EXISTING="false" CHECK_STATUS="false" RELEASE="false" -PUBLIC="false" for arg in "$@"; do case $arg in @@ -343,9 +333,6 @@ for arg in "$@"; do -r | --release) RELEASE="true" ;; - --public) - PUBLIC="true" - ;; --help | -h) usage ;; diff --git a/stock/avatar/upload-stockavatars.sh b/stock/avatar/upload-stockavatars.sh index 1670fcb..2272cf0 100755 --- a/stock/avatar/upload-stockavatars.sh +++ b/stock/avatar/upload-stockavatars.sh @@ -58,16 +58,24 @@ capitalize() { } # Function to determine tags based on filename -# Tags complement default_layer - avoid redundant info +# Tags complement default_layer/default_emotion - avoid redundant info get_tags() { local filename="$1" case "$filename" in face.svg) - # Base face prop - "skin" is already in default_layer + # Content layer prop - "skin" is already in default_layer echo '["base", "face"]' ;; - neutral.svg | smile.svg | sad.svg | angry.svg | surprised.svg | thinking.svg | laughing.svg | crying.svg | love.svg | confused.svg | sleeping.svg | wink.svg) - # Facial expression props - "emote" is already in default_layer + smile.svg | happy.svg | neutral.svg | sad.svg | angry.svg | surprised.svg | thinking.svg | laughing.svg | crying.svg | love.svg | confused.svg) + # Emotion props - emotion is already in default_emotion + echo '["face"]' + ;; + sleeping.svg) + # Emotion prop for sleeping + echo '["face"]' + ;; + wink.svg) + # Emotion prop for wink echo '["face"]' ;; *) @@ -77,7 +85,7 @@ get_tags() { } # Function to get positioning fields based on filename -# Returns: "layer:" for content layer props, "none" for generic props +# Returns: "layer:" for content layer props, "emotion:" for emotion props, "none" for generic props get_positioning() { local filename="$1" case "$filename" in @@ -85,9 +93,41 @@ get_positioning() { # Base face is a content layer prop (skin layer) echo "layer:skin" ;; - neutral.svg | smile.svg | sad.svg | angry.svg | surprised.svg | thinking.svg | laughing.svg | crying.svg | love.svg | confused.svg | sleeping.svg | wink.svg) - # Facial expression props use the emote layer - echo "layer:emote" + neutral.svg) + echo "emotion:neutral" + ;; + smile.svg) + echo "emotion:happy" + ;; + sad.svg) + echo "emotion:sad" + ;; + angry.svg) + echo "emotion:angry" + ;; + surprised.svg) + echo "emotion:surprised" + ;; + thinking.svg) + echo "emotion:thinking" + ;; + laughing.svg) + echo "emotion:laughing" + ;; + crying.svg) + echo "emotion:crying" + ;; + love.svg) + echo "emotion:love" + ;; + confused.svg) + echo "emotion:confused" + ;; + sleeping.svg) + echo "emotion:sleeping" + ;; + wink.svg) + echo "emotion:wink" ;; *) echo "none" @@ -119,9 +159,13 @@ for file in "$STOCKAVATAR_DIR"/*.svg; do case "$positioning_type" in layer) - # Content layer prop (skin, clothes, accessories, emote) + # Content layer prop positioning_json="\"default_layer\": \"$positioning_value\", \"default_position\": 4" ;; + emotion) + # Emotion layer prop + positioning_json="\"default_emotion\": \"$positioning_value\", \"default_position\": 4" + ;; *) # Generic prop (no default positioning) positioning_json=""