From 3e1afb82c877062abb58c429c794c6579aa705ce346fd71ed6c645c613875ca1 Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Tue, 20 Jan 2026 16:10:41 -0600 Subject: [PATCH 1/6] feat: host publicly --- run-dev.sh | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/run-dev.sh b/run-dev.sh index 8e1a213..0eb14c4 100755 --- a/run-dev.sh +++ b/run-dev.sh @@ -35,6 +35,7 @@ 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 @@ -70,17 +71,19 @@ 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://127.0.0.1:$OWNER_PORT" + echo " Owner Admin: http://$bind_addr:$OWNER_PORT" fi if run_app; then - echo " User App: http://127.0.0.1:$APP_PORT" - echo " Realm Admin: http://127.0.0.1:$APP_PORT/admin" + echo " User App: http://$bind_addr:$APP_PORT" + echo " Realm Admin: http://$bind_addr:$APP_PORT/admin" fi echo "" if [ "$TARGET" = "both" ]; then @@ -225,6 +228,9 @@ 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)..." @@ -234,12 +240,12 @@ do_watch() { # Start watch processes if run_owner; then - cargo leptos watch -p chattyness-owner $release_flag & + HOST="$bind_addr" cargo leptos watch -p chattyness-owner $release_flag & OWNER_PID=$! fi if run_app; then - cargo leptos watch -p chattyness-app --split $release_flag & + HOST="$bind_addr" cargo leptos watch -p chattyness-app --split $release_flag & APP_PID=$! fi @@ -279,15 +285,18 @@ 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 :$OWNER_PORT..." - ./target/$target_dir/chattyness-owner & + echo "Starting Owner Server on $bind_addr:$OWNER_PORT..." + HOST="$bind_addr" ./target/$target_dir/chattyness-owner & OWNER_PID=$! fi if run_app; then - echo "Starting App Server on :$APP_PORT..." - ./target/$target_dir/chattyness-app & + echo "Starting App Server on $bind_addr:$APP_PORT..." + HOST="$bind_addr" ./target/$target_dir/chattyness-app & APP_PID=$! fi @@ -306,6 +315,7 @@ FORCE="false" KILL_EXISTING="false" CHECK_STATUS="false" RELEASE="false" +PUBLIC="false" for arg in "$@"; do case $arg in @@ -333,6 +343,9 @@ for arg in "$@"; do -r | --release) RELEASE="true" ;; + --public) + PUBLIC="true" + ;; --help | -h) usage ;; From 7852790a1e0e1a62187244b8eb4dae580b0b543dab7f0ff2ca7dd56a47179ed5 Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Tue, 20 Jan 2026 18:33:15 -0600 Subject: [PATCH 2/6] support aquiring server props and test dropping them --- crates/chattyness-db/src/models.rs | 34 +- crates/chattyness-db/src/queries/inventory.rs | 467 ++++++++++++++++-- .../chattyness-db/src/queries/loose_props.rs | 28 +- .../chattyness-user-ui/src/api/inventory.rs | 164 +++++- crates/chattyness-user-ui/src/api/routes.rs | 19 +- .../chattyness-user-ui/src/api/websocket.rs | 14 + .../src/components/inventory.rs | 215 +++++++- db/schema/types/001_enums.sql | 5 +- stock/avatar/upload-stockavatars.sh | 62 +-- 9 files changed, 858 insertions(+), 150 deletions(-) diff --git a/crates/chattyness-db/src/models.rs b/crates/chattyness-db/src/models.rs index c943e18..7655989 100644 --- a/crates/chattyness-db/src/models.rs +++ b/crates/chattyness-db/src/models.rs @@ -295,6 +295,7 @@ pub enum AvatarLayer { #[default] Clothes, Accessories, + Emote, } impl std::fmt::Display for AvatarLayer { @@ -303,6 +304,7 @@ 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"), } } } @@ -315,6 +317,7 @@ 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)), } } @@ -685,21 +688,40 @@ pub struct InventoryResponse { pub items: Vec, } -/// A public prop from server or realm library. -/// Used for the public inventory tabs (Server/Realm). +/// Extended prop info for acquisition UI (works for both server and realm props). +/// Includes ownership and availability status for the current user. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] -pub struct PublicProp { +pub struct PropAcquisitionInfo { 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, } -/// Response for public props list. +/// Request to acquire a prop (server or realm). #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PublicPropsResponse { - pub props: Vec, +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, } /// A prop dropped in a channel, available for pickup. diff --git a/crates/chattyness-db/src/queries/inventory.rs b/crates/chattyness-db/src/queries/inventory.rs index 6047292..4c9d854 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, PublicProp}; +use crate::models::{InventoryItem, PropAcquisitionInfo}; use chattyness_error::AppError; /// List all inventory items for a user. @@ -92,66 +92,455 @@ pub async fn drop_inventory_item<'e>( Ok(()) } -/// List all public server props. +/// List public server props with optional acquisition status. /// -/// 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>( +/// 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>( executor: impl PgExecutor<'e>, -) -> Result, AppError> { - let props = sqlx::query_as::<_, PublicProp>( + user_id: Option, +) -> Result, AppError> { + let props = sqlx::query_as::<_, PropAcquisitionInfo>( r#" SELECT - 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 + 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 "#, ) + .bind(user_id) .fetch_all(executor) .await?; Ok(props) } -/// List all public realm props for a specific realm. +/// List public realm props with optional acquisition status. /// -/// 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>( +/// 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>( executor: impl PgExecutor<'e>, realm_id: Uuid, -) -> Result, AppError> { - let props = sqlx::query_as::<_, PublicProp>( + user_id: Option, +) -> Result, AppError> { + let props = sqlx::query_as::<_, PropAcquisitionInfo>( r#" SELECT - 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 + 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 "#, ) .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 8819119..415d907 100644 --- a/crates/chattyness-db/src/queries/loose_props.rs +++ b/crates/chattyness-db/src/queries/loose_props.rs @@ -8,6 +8,30 @@ 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>, @@ -106,8 +130,8 @@ pub async fn drop_prop_to_canvas<'e>( instance_id as channel_id, server_prop_id, realm_prop_id, - ST_X(position) as position_x, - ST_Y(position) as position_y, + ST_X(position)::real as position_x, + ST_Y(position)::real as position_y, dropped_by, expires_at, created_at diff --git a/crates/chattyness-user-ui/src/api/inventory.rs b/crates/chattyness-user-ui/src/api/inventory.rs index 7fd643c..dcb7b15 100644 --- a/crates/chattyness-user-ui/src/api/inventory.rs +++ b/crates/chattyness-user-ui/src/api/inventory.rs @@ -8,66 +8,194 @@ use sqlx::PgPool; use uuid::Uuid; use chattyness_db::{ - models::{InventoryResponse, PublicPropsResponse}, + User, + models::{ + AcquirePropRequest, AcquirePropResponse, InventoryResponse, PropAcquisitionListResponse, + }, queries::{inventory, realms}, }; use chattyness_error::AppError; -use crate::auth::{AuthUser, RlsConn}; +use crate::auth::{AuthUser, OptionalAuthUser, RlsConn}; /// Get user's full inventory. /// -/// GET /api/inventory -pub async fn get_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( rls_conn: RlsConn, AuthUser(user): AuthUser, + Path(uuid_str): Path, ) -> Result, AppError> { - let mut conn = rls_conn.acquire().await; + // 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)) + })? + }; - let items = inventory::list_user_inventory(&mut *conn, user.id).await?; + // 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?; Ok(Json(InventoryResponse { items })) } /// Drop an item from inventory. /// -/// DELETE /api/inventory/{item_id} +/// DELETE /api/user/{uuid}/inventory/{item_id} pub async fn drop_item( rls_conn: RlsConn, AuthUser(user): AuthUser, - Path(item_id): Path, + Path((uuid_str, item_id)): Path<(String, Uuid)>, ) -> Result, AppError> { - let mut conn = rls_conn.acquire().await; + // 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 public server props. +/// Get server prop catalog. /// -/// GET /api/inventory/server +/// 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. pub async fn get_server_props( State(pool): State, -) -> Result, AppError> { - let props = inventory::list_public_server_props(&pool).await?; + 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); - Ok(Json(PublicPropsResponse { props })) + let props = inventory::list_server_props(&pool, user_id).await?; + + Ok(Json(PropAcquisitionListResponse { props })) } -/// Get public realm props. +/// Get realm prop catalog. /// /// 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)))?; - let props = inventory::list_public_realm_props(&pool, realm.id).await?; + // 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); - Ok(Json(PublicPropsResponse { props })) + 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) + } + } } diff --git a/crates/chattyness-user-ui/src/api/routes.rs b/crates/chattyness-user-ui/src/api/routes.rs index 273df49..dd528e4 100644 --- a/crates/chattyness-user-ui/src/api/routes.rs +++ b/crates/chattyness-user-ui/src/api/routes.rs @@ -58,13 +58,22 @@ pub fn api_router() -> Router { "/realms/{slug}/avatar/slot", axum::routing::put(avatars::assign_slot).delete(avatars::clear_slot), ) - // Inventory routes (require authentication) - .route("/inventory", get(inventory::get_inventory)) + // User inventory routes + .route("/user/{uuid}/inventory", get(inventory::get_user_inventory)) .route( - "/inventory/{item_id}", + "/user/{uuid}/inventory/{item_id}", axum::routing::delete(inventory::drop_item), ) - // Public inventory routes (public server/realm props) - .route("/inventory/server", get(inventory::get_server_props)) + // 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) .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 56f8e08..14766e1 100644 --- a/crates/chattyness-user-ui/src/api/websocket.rs +++ b/crates/chattyness-user-ui/src/api/websocket.rs @@ -607,6 +607,20 @@ 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, diff --git a/crates/chattyness-user-ui/src/components/inventory.rs b/crates/chattyness-user-ui/src/components/inventory.rs index add9e96..67fa342 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, PublicProp}; +use chattyness_db::models::{InventoryItem, PropAcquisitionInfo}; #[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 - let (server_props, set_server_props) = signal(Vec::::new()); + // Server props state (with acquisition info for authenticated users) + 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 - let (realm_props, set_realm_props) = signal(Vec::::new()); + // Realm props state (with acquisition info for authenticated users) + 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,6 +61,9 @@ 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")] { @@ -68,6 +71,9 @@ 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); @@ -86,7 +92,7 @@ pub fn InventoryPopup( set_error.set(None); spawn_local(async move { - let response = Request::get("/api/inventory").send().await; + let response = Request::get("/api/user/me/inventory").send().await; match response { Ok(resp) if resp.ok() => { if let Ok(data) = resp @@ -112,6 +118,7 @@ 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; @@ -126,17 +133,19 @@ pub fn InventoryPopup( set_server_error.set(None); spawn_local(async move { - let response = Request::get("/api/inventory/server").send().await; + // Single endpoint returns enriched data if authenticated + let response = Request::get("/api/server/inventory").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) => { @@ -175,19 +184,20 @@ pub fn InventoryPopup( set_realm_error.set(None); spawn_local(async move { - let response = Request::get(&format!("/api/realms/{}/inventory", slug)) - .send() - .await; + // Single endpoint returns enriched data if authenticated + let endpoint = format!("/api/realms/{}/inventory", slug); + let response = Request::get(&endpoint).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) => { @@ -272,23 +282,40 @@ pub fn InventoryPopup( // Server tab - // Realm tab - @@ -450,17 +477,103 @@ fn MyInventoryTab( } } -/// Public props tab content (read-only display). +/// Acquisition props tab content with acquire functionality. #[component] -fn PublicPropsTab( - #[prop(into)] props: Signal>, +fn AcquisitionPropsTab( + #[prop(into)] props: Signal>, + set_props: WriteSignal>, #[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 @@ -477,6 +590,13 @@ fn PublicPropsTab( + // Acquire error state + +
+

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

+
+
+ // Empty state
@@ -501,7 +621,7 @@ fn PublicPropsTab( + // Ownership badge + + + } } />
- // Selected prop details (read-only) + // Selected prop details with acquire button {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.clone()}

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

{prop_name}

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

{desc}

})}
-

"View only"

+
}) @@ -563,3 +727,4 @@ fn PublicPropsTab(
} } + diff --git a/db/schema/types/001_enums.sql b/db/schema/types/001_enums.sql index fa7e9e6..6e5e659 100644 --- a/db/schema/types/001_enums.sql +++ b/db/schema/types/001_enums.sql @@ -71,9 +71,10 @@ 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) + 'accessories', -- Foreground layer (in front of user, held/attached items) + 'emote' -- Facial expression layer (overlays face) ); -COMMENT ON TYPE server.avatar_layer IS 'Z-layer for avatar prop positioning: skin (behind), clothes (with), accessories (front)'; +COMMENT ON TYPE server.avatar_layer IS 'Z-layer for avatar prop positioning: skin (behind), clothes (with), accessories (front), emote (expressions)'; -- Emotion state for avatar overlays (moved from props schema) CREATE TYPE server.emotion_state AS ENUM ( diff --git a/stock/avatar/upload-stockavatars.sh b/stock/avatar/upload-stockavatars.sh index 2272cf0..1670fcb 100755 --- a/stock/avatar/upload-stockavatars.sh +++ b/stock/avatar/upload-stockavatars.sh @@ -58,24 +58,16 @@ capitalize() { } # Function to determine tags based on filename -# Tags complement default_layer/default_emotion - avoid redundant info +# Tags complement default_layer - avoid redundant info get_tags() { local filename="$1" case "$filename" in face.svg) - # Content layer prop - "skin" is already in default_layer + # Base face prop - "skin" is already in default_layer echo '["base", "face"]' ;; - 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 + 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 echo '["face"]' ;; *) @@ -85,7 +77,7 @@ get_tags() { } # Function to get positioning fields based on filename -# Returns: "layer:" for content layer props, "emotion:" for emotion props, "none" for generic props +# Returns: "layer:" for content layer props, "none" for generic props get_positioning() { local filename="$1" case "$filename" in @@ -93,41 +85,9 @@ get_positioning() { # Base face is a content layer prop (skin layer) echo "layer:skin" ;; - 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" + 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" ;; *) echo "none" @@ -159,13 +119,9 @@ for file in "$STOCKAVATAR_DIR"/*.svg; do case "$positioning_type" in layer) - # Content layer prop + # Content layer prop (skin, clothes, accessories, emote) 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="" From 41ea9d13cb0f362fc8dd204624245871b599a4458da0a1b1873aa0382d199891 Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Tue, 20 Jan 2026 19:25:58 -0600 Subject: [PATCH 3/6] feat: add log and hotkey list --- crates/chattyness-user-ui/src/components.rs | 4 + .../chattyness-user-ui/src/components/chat.rs | 42 +++- .../src/components/hotkey_help.rs | 106 +++++++++ .../src/components/log_popup.rs | 206 ++++++++++++++++++ crates/chattyness-user-ui/src/pages/realm.rs | 61 +++++- 5 files changed, 415 insertions(+), 4 deletions(-) create mode 100644 crates/chattyness-user-ui/src/components/hotkey_help.rs create mode 100644 crates/chattyness-user-ui/src/components/log_popup.rs diff --git a/crates/chattyness-user-ui/src/components.rs b/crates/chattyness-user-ui/src/components.rs index 5779f00..ec189ea 100644 --- a/crates/chattyness-user-ui/src/components.rs +++ b/crates/chattyness-user-ui/src/components.rs @@ -9,10 +9,12 @@ 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; @@ -33,10 +35,12 @@ 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 0be1a35..314eb67 100644 --- a/crates/chattyness-user-ui/src/components/chat.rs +++ b/crates/chattyness-user-ui/src/components/chat.rs @@ -109,6 +109,7 @@ fn parse_whisper_command(cmd: &str) -> Option<(String, String)> { /// - `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 @@ -123,6 +124,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. #[prop(optional, into)] whisper_target: Option>>, @@ -324,9 +328,11 @@ pub fn ChatInput( || "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 ") @@ -348,6 +354,7 @@ 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(); @@ -484,6 +491,20 @@ 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(); @@ -527,6 +548,21 @@ 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() { @@ -702,7 +738,7 @@ pub fn ChatInput( - // Slash command hint bar (/s[etting], /i[nventory], /w[hisper], /t[eleport]) + // Slash command hint bar (/s[etting], /i[nventory], /w[hisper], /l[og], /t[eleport])
"/" @@ -716,6 +752,10 @@ pub fn ChatInput( "/" "w" "[hisper] name" + "|" + "/" + "l" + "[og]" "|" "/" diff --git a/crates/chattyness-user-ui/src/components/hotkey_help.rs b/crates/chattyness-user-ui/src/components/hotkey_help.rs new file mode 100644 index 0000000..8f25986 --- /dev/null +++ b/crates/chattyness-user-ui/src/components/hotkey_help.rs @@ -0,0 +1,106 @@ +//! 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/log_popup.rs b/crates/chattyness-user-ui/src/components/log_popup.rs new file mode 100644 index 0000000..c4b09f1 --- /dev/null +++ b/crates/chattyness-user-ui/src/components/log_popup.rs @@ -0,0 +1,206 @@ +//! 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/pages/realm.rs b/crates/chattyness-user-ui/src/pages/realm.rs index ef81a32..113fb53 100644 --- a/crates/chattyness-user-ui/src/pages/realm.rs +++ b/crates/chattyness-user-ui/src/pages/realm.rs @@ -13,9 +13,9 @@ use uuid::Uuid; use crate::components::{ ActiveBubble, AvatarEditorPopup, Card, ChatInput, ConversationModal, EmotionKeybindings, - FadingMember, InventoryPopup, KeybindingsPopup, MessageLog, NotificationHistoryModal, - NotificationMessage, NotificationToast, RealmHeader, RealmSceneViewer, ReconnectionOverlay, - SettingsPopup, ViewerSettings, + FadingMember, HotkeyHelp, InventoryPopup, KeybindingsPopup, LogPopup, MessageLog, + NotificationHistoryModal, NotificationMessage, NotificationToast, RealmHeader, + RealmSceneViewer, ReconnectionOverlay, SettingsPopup, ViewerSettings, }; #[cfg(feature = "hydrate")] use crate::components::{ @@ -74,6 +74,12 @@ 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); @@ -794,6 +800,20 @@ 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; @@ -830,6 +850,25 @@ pub fn RealmPage() -> impl IntoView { // Store the closure for cleanup *closure_holder_clone.borrow_mut() = Some(closure); + + // Add keyup handler for releasing '?' (hotkey help) + let keyup_closure = Closure::::new( + move |ev: web_sys::KeyboardEvent| { + if ev.key() == "?" { + set_hotkey_help_visible.set(false); + } + }, + ); + + 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) @@ -982,6 +1021,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| { set_whisper_target.set(Some(target)); @@ -1031,6 +1073,7 @@ pub fn RealmPage() -> 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_signal scenes=scenes_signal allow_user_teleport=teleport_enabled_signal @@ -1088,6 +1131,15 @@ pub fn RealmPage() -> impl IntoView { scene_dimensions=scene_dimensions.get() /> + // Log popup + + // Keybindings popup impl IntoView { /> } } + + // Hotkey help overlay (shown while ? is held) + } .into_any() } From 864cfaec544497dfc0531dd6be4d93488b303df39091f2cdae4a469e5f79af5d Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Tue, 20 Jan 2026 20:11:37 -0600 Subject: [PATCH 4/6] fix: teleport was prepopulating prior wisper --- .../chattyness-user-ui/src/components/chat.rs | 8 ++++++-- crates/chattyness-user-ui/src/pages/realm.rs | 20 ++++++++++++------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/crates/chattyness-user-ui/src/components/chat.rs b/crates/chattyness-user-ui/src/components/chat.rs index 314eb67..1ea4738 100644 --- a/crates/chattyness-user-ui/src/components/chat.rs +++ b/crates/chattyness-user-ui/src/components/chat.rs @@ -128,8 +128,9 @@ pub fn ChatInput( #[prop(optional)] on_open_log: Option>, /// Signal containing the display name to whisper to. When set, pre-fills the input. - #[prop(optional, into)] - whisper_target: Option>>, + /// Uses RwSignal so the component can clear it after consuming. + #[prop(optional)] + whisper_target: Option>>, /// List of available scenes for teleport command. #[prop(optional, into)] scenes: Option>>, @@ -240,6 +241,9 @@ 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); } }); } diff --git a/crates/chattyness-user-ui/src/pages/realm.rs b/crates/chattyness-user-ui/src/pages/realm.rs index 113fb53..3fe2a94 100644 --- a/crates/chattyness-user-ui/src/pages/realm.rs +++ b/crates/chattyness-user-ui/src/pages/realm.rs @@ -111,7 +111,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, set_whisper_target) = signal(Option::::None); + let whisper_target = RwSignal::new(Option::::None); // Notification state for cross-scene whispers let (current_notification, set_current_notification) = @@ -852,10 +852,17 @@ pub fn RealmPage() -> impl IntoView { *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| { - if ev.key() == "?" { + 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(); } }, ); @@ -1024,9 +1031,8 @@ pub fn RealmPage() -> impl IntoView { 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| { - set_whisper_target.set(Some(target)); + 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()); @@ -1074,7 +1080,7 @@ pub fn RealmPage() -> impl IntoView { 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_signal + whisper_target=whisper_target scenes=scenes_signal allow_user_teleport=teleport_enabled_signal on_teleport=on_teleport_cb @@ -1179,7 +1185,7 @@ pub fn RealmPage() -> impl IntoView { impl IntoView { open=Signal::derive(move || history_modal_open.get()) on_close=Callback::new(move |_: ()| set_history_modal_open.set(false)) on_reply=Callback::new(move |name: String| { - set_whisper_target.set(Some(name)); + whisper_target.set(Some(name)); }) on_context=Callback::new(move |name: String| { set_conversation_partner.set(name); From 45a7e44b3a5c4462ad9fb7e7e6ef48ac08af877b48c24de31629ef7344d4b96d Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Tue, 20 Jan 2026 21:48:04 -0600 Subject: [PATCH 5/6] feat: add /mod summon --- crates/chattyness-db/Cargo.toml | 3 +- crates/chattyness-db/src/models.rs | 36 ++++ crates/chattyness-db/src/queries.rs | 1 + .../chattyness-db/src/queries/memberships.rs | 30 ++++ .../chattyness-db/src/queries/moderation.rs | 77 ++++++++ crates/chattyness-db/src/ws_messages.rs | 26 +++ .../chattyness-user-ui/src/api/websocket.rs | 167 +++++++++++++++++- .../chattyness-user-ui/src/components/chat.rs | 71 ++++++++ .../src/components/ws_client.rs | 48 +++++ crates/chattyness-user-ui/src/pages/realm.rs | 140 ++++++++++++++- db/schema/types/001_enums.sql | 4 +- 11 files changed, 598 insertions(+), 5 deletions(-) create mode 100644 crates/chattyness-db/src/queries/moderation.rs diff --git a/crates/chattyness-db/Cargo.toml b/crates/chattyness-db/Cargo.toml index 4a6768d..be61afb 100644 --- a/crates/chattyness-db/Cargo.toml +++ b/crates/chattyness-db/Cargo.toml @@ -7,6 +7,7 @@ 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 @@ -17,4 +18,4 @@ rand = { workspace = true, optional = true } [features] default = [] -ssr = ["sqlx", "argon2", "rand", "chattyness-error/ssr", "dep:chattyness-error", "dep:chattyness-shared"] +ssr = ["sqlx", "argon2", "rand", "chattyness-error/ssr", "dep:chattyness-error", "dep:chattyness-shared", "dep:serde_json"] diff --git a/crates/chattyness-db/src/models.rs b/crates/chattyness-db/src/models.rs index 7655989..919709b 100644 --- a/crates/chattyness-db/src/models.rs +++ b/crates/chattyness-db/src/models.rs @@ -209,6 +209,42 @@ 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, +} + +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"), + } + } +} + /// Scene dimension mode. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "ssr", derive(sqlx::Type))] diff --git a/crates/chattyness-db/src/queries.rs b/crates/chattyness-db/src/queries.rs index e06df7b..196db89 100644 --- a/crates/chattyness-db/src/queries.rs +++ b/crates/chattyness-db/src/queries.rs @@ -7,6 +7,7 @@ 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/memberships.rs b/crates/chattyness-db/src/queries/memberships.rs index ab4ab21..212fe1e 100644 --- a/crates/chattyness-db/src/queries/memberships.rs +++ b/crates/chattyness-db/src/queries/memberships.rs @@ -199,3 +199,33 @@ 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 new file mode 100644 index 0000000..3ddc0fb --- /dev/null +++ b/crates/chattyness-db/src/queries/moderation.rs @@ -0,0 +1,77 @@ +//! 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 3dd17fc..350ca0f 100644 --- a/crates/chattyness-db/src/ws_messages.rs +++ b/crates/chattyness-db/src/ws_messages.rs @@ -90,6 +90,14 @@ 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. @@ -234,4 +242,22 @@ 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/websocket.rs b/crates/chattyness-user-ui/src/api/websocket.rs index 14766e1..1f6f54c 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::{AvatarRenderData, ChannelMemberWithAvatar, EmotionState, User}, - queries::{avatars, channel_members, loose_props, realms, scenes}, + models::{ActionType, AvatarRenderData, ChannelMemberWithAvatar, EmotionState, User}, + queries::{avatars, channel_members, loose_props, memberships, moderation, realms, scenes}, ws_messages::{close_codes, ClientMessage, DisconnectReason, ServerMessage, WsConfig}, }; use chattyness_error::AppError; @@ -828,6 +828,169 @@ 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; + } + } + } + _ => { + 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/chat.rs b/crates/chattyness-user-ui/src/components/chat.rs index 1ea4738..ec0a6d5 100644 --- a/crates/chattyness-user-ui/src/components/chat.rs +++ b/crates/chattyness-user-ui/src/components/chat.rs @@ -19,6 +19,8 @@ enum CommandMode { ShowingColonHint, /// Showing command hint for slash commands (`/setting`). ShowingSlashHint, + /// Showing mod command hint (`/mod summon [nick|*]`). + ShowingModHint, /// Showing emotion list popup. ShowingList, /// Showing scene list popup for teleport. @@ -98,6 +100,35 @@ 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,6 +171,12 @@ 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); @@ -324,9 +361,16 @@ pub fn ChatInput( } }; + // Check if mod command (only for moderators) + let is_mod_command = is_moderator.get_untracked() + && (cmd.starts_with("mod") || "mod".starts_with(&cmd)); + 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_mod_command { + // Show mod command hint + set_command_mode.set(CommandMode::ShowingModHint); } else if cmd.is_empty() || "setting".starts_with(&cmd) || "inventory".starts_with(&cmd) @@ -625,6 +669,22 @@ 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; @@ -769,6 +829,17 @@ pub fn ChatInput(
+ // Mod command hint bar (/mod summon [nick|*]) + +
+ "[MOD] " + "/" + "mod" + " summon" + " [nick|*]" +
+
+ // Emotion list popup >, 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; @@ -220,6 +242,8 @@ 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)); @@ -293,6 +317,8 @@ 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, ); } @@ -402,6 +428,8 @@ 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(); @@ -578,6 +606,24 @@ 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 }); + } + } } } @@ -596,6 +642,8 @@ 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 3fe2a94..c905111 100644 --- a/crates/chattyness-user-ui/src/pages/realm.rs +++ b/crates/chattyness-user-ui/src/pages/realm.rs @@ -20,7 +20,7 @@ use crate::components::{ #[cfg(feature = "hydrate")] use crate::components::{ ChannelMemberInfo, ChatMessage, DEFAULT_BUBBLE_TIMEOUT_MS, FADE_DURATION_MS, HistoryEntry, - TeleportInfo, WsError, add_to_history, use_channel_websocket, + ModCommandResultInfo, SummonInfo, TeleportInfo, WsError, add_to_history, use_channel_websocket, }; use crate::utils::LocalStoragePersist; #[cfg(feature = "hydrate")] @@ -141,6 +141,12 @@ 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 { @@ -418,6 +424,90 @@ 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, @@ -432,6 +522,8 @@ 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 @@ -955,6 +1047,9 @@ 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() @@ -1046,6 +1141,17 @@ 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 { 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 />
@@ -1223,6 +1331,36 @@ pub fn RealmPage() -> 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 Date: Tue, 20 Jan 2026 22:40:29 -0600 Subject: [PATCH 6/6] feat: add /mod teleport --- crates/chattyness-db/src/models.rs | 2 + .../chattyness-user-ui/src/api/websocket.rs | 83 +++++++++++++++++++ .../chattyness-user-ui/src/components/chat.rs | 41 ++++++--- 3 files changed, 113 insertions(+), 13 deletions(-) diff --git a/crates/chattyness-db/src/models.rs b/crates/chattyness-db/src/models.rs index 919709b..3dee106 100644 --- a/crates/chattyness-db/src/models.rs +++ b/crates/chattyness-db/src/models.rs @@ -227,6 +227,7 @@ pub enum ActionType { MessageDeletion, Summon, SummonAll, + Teleport, } impl std::fmt::Display for ActionType { @@ -241,6 +242,7 @@ impl std::fmt::Display for ActionType { ActionType::MessageDeletion => write!(f, "message_deletion"), ActionType::Summon => write!(f, "summon"), ActionType::SummonAll => write!(f, "summon_all"), + ActionType::Teleport => write!(f, "teleport"), } } } diff --git a/crates/chattyness-user-ui/src/api/websocket.rs b/crates/chattyness-user-ui/src/api/websocket.rs index 1f6f54c..1473f24 100644 --- a/crates/chattyness-user-ui/src/api/websocket.rs +++ b/crates/chattyness-user-ui/src/api/websocket.rs @@ -983,6 +983,89 @@ async fn handle_socket( } } } + "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, diff --git a/crates/chattyness-user-ui/src/components/chat.rs b/crates/chattyness-user-ui/src/components/chat.rs index ec0a6d5..3f3dfe7 100644 --- a/crates/chattyness-user-ui/src/components/chat.rs +++ b/crates/chattyness-user-ui/src/components/chat.rs @@ -17,9 +17,9 @@ enum CommandMode { None, /// Showing command hint for colon commands (`:e[mote], :l[ist]`). ShowingColonHint, - /// Showing command hint for slash commands (`/setting`). + /// Showing command hint for slash commands (`/setting`, `/mod` for mods). ShowingSlashHint, - /// Showing mod command hint (`/mod summon [nick|*]`). + /// Showing mod command hints only (`/mod summon [nick|*]`). ShowingModHint, /// Showing emotion list popup. ShowingList, @@ -361,15 +361,21 @@ pub fn ChatInput( } }; - // Check if mod command (only for moderators) - let is_mod_command = is_moderator.get_untracked() - && (cmd.starts_with("mod") || "mod".starts_with(&cmd)); + // 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_mod_command { - // Show mod command hint + } 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) @@ -385,6 +391,7 @@ pub fn ChatInput( || cmd.starts_with("whisper ") || cmd.starts_with("t ") || cmd.starts_with("teleport ") + || is_partial_mod { set_command_mode.set(CommandMode::ShowingSlashHint); } else { @@ -826,17 +833,25 @@ pub fn ChatInput( "t" "[eleport]" + // Show /mod hint for moderators (details shown when typing /mod) + + "|" + "/" + "mod" + - // Mod command hint bar (/mod summon [nick|*]) + // Mod command hint bar (shown when typing /mod) -
- "[MOD] " - "/" +
+ "/" "mod" - " summon" - " [nick|*]" + " summon" + " [nick|*]" + " | " + "teleport" + " [nick] [slug]"