From 9541fb1927cfb7b383b9ca6f52f028867cca1e6f470824ad7d398026f09bcb7e Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Sat, 24 Jan 2026 10:03:10 -0600 Subject: [PATCH] Initial copy of business card prop with profile --- crates/chattyness-db/Cargo.toml | 4 +- crates/chattyness-db/src/models.rs | 158 +++++++++++ crates/chattyness-db/src/queries/inventory.rs | 156 ++++++++++- .../chattyness-db/src/queries/loose_props.rs | 205 +++++++++++---- crates/chattyness-db/src/ws_messages.rs | 46 +++- .../chattyness-user-ui/src/api/websocket.rs | 120 +++++++++ .../src/components/inventory.rs | 179 ++++++++++++- .../src/components/modals.rs | 248 ++++++++++++++++++ .../src/components/scene_viewer.rs | 11 + .../src/components/ws_client.rs | 15 +- crates/chattyness-user-ui/src/pages/realm.rs | 42 ++- db/schema/tables/020_auth.sql | 26 ++ db/schema/tables/030_realm.sql | 4 + db/schema/tables/045_scene.sql | 18 ++ stock/index.html | 2 +- stock/props/misc-businesscard.svg | 30 +++ 16 files changed, 1193 insertions(+), 71 deletions(-) create mode 100644 stock/props/misc-businesscard.svg diff --git a/crates/chattyness-db/Cargo.toml b/crates/chattyness-db/Cargo.toml index be61afb..9da525b 100644 --- a/crates/chattyness-db/Cargo.toml +++ b/crates/chattyness-db/Cargo.toml @@ -7,7 +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 } +serde_json.workspace = true uuid.workspace = true chrono.workspace = true @@ -18,4 +18,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 ee1688e..5cea354 100644 --- a/crates/chattyness-db/src/models.rs +++ b/crates/chattyness-db/src/models.rs @@ -866,6 +866,125 @@ impl From<&PropSource> for PropOrigin { } } +// ============================================================================ +// State types for inventory item state management +// ============================================================================ + +/// Scope of state visibility for inventory items. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StateScope { + /// Server-wide scope: visible to everyone. + Server, + /// Realm scope: visible only to realm members. + Realm, + /// User scope: visible to those inspecting the item. + User, +} + +impl std::fmt::Display for StateScope { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StateScope::Server => write!(f, "server"), + StateScope::Realm => write!(f, "realm"), + StateScope::User => write!(f, "user"), + } + } +} + +/// Visibility of state: public or private. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StateVisibility { + /// Public state: persists through transfer, visible based on scope. + Public, + /// Private state: cleared on transfer, only visible to owner. + Private, +} + +impl std::fmt::Display for StateVisibility { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StateVisibility::Public => write!(f, "public"), + StateVisibility::Private => write!(f, "private"), + } + } +} + +/// Hint for how to render/handle a prop's state (detected from state content). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PropActionHint { + /// Render as a business card with profile info and social links. + BusinessCard, + /// Render as an info panel with formatted JSON. + InfoPanel, + /// Render as a list of external links/buttons. + ExternalLinks, +} + +/// View of prop state filtered by viewer permissions. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PropStateView { + /// The prop ID (inventory item or loose prop). + pub prop_id: Uuid, + /// The prop name. + pub prop_name: String, + /// Display name of the owner (if known). + pub owner_display_name: Option, + /// Server-scoped public state (visible to everyone). + pub server_state: serde_json::Value, + /// Realm-scoped public state (None if viewer not in same realm). + pub realm_state: Option, + /// User-scoped public state (visible when inspecting). + pub user_state: serde_json::Value, + /// Private state bundle (only included if viewer is owner). + pub private_state: Option, + /// Detected action hint based on state content. + pub action_hint: Option, +} + +/// Bundle of private state (only visible to owner). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PrivateStateBundle { + pub server_private_state: serde_json::Value, + pub realm_private_state: serde_json::Value, + pub user_private_state: serde_json::Value, +} + +impl PropStateView { + /// Detect action hint from state content. + pub fn detect_action_hint(server_state: &serde_json::Value) -> Option { + use serde_json::Value; + + // Check for business card pattern (profile with social links) + if let Some(profile) = server_state.get("profile") { + let profile: &Value = profile; + if profile.get("linkedin").is_some() + || profile.get("github").is_some() + || profile.get("twitter").is_some() + || profile.get("website").is_some() + { + return Some(PropActionHint::BusinessCard); + } + } + + // Check for external links pattern + if server_state.get("links").is_some() { + return Some(PropActionHint::ExternalLinks); + } + + // If there's any non-empty state, show as info panel + if let Some(obj) = server_state.as_object() { + if !obj.is_empty() { + return Some(PropActionHint::InfoPanel); + } + } + + None + } +} + /// An inventory item (user-owned prop). #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] @@ -882,6 +1001,28 @@ pub struct InventoryItem { /// The source of this prop (ServerLibrary, RealmLibrary, or UserUpload) pub origin: PropOrigin, pub acquired_at: DateTime, + + // Public state columns (persist through transfer) + /// Server-scoped state visible to everyone. + #[serde(default)] + pub server_state: serde_json::Value, + /// Realm-scoped state visible to realm members. + #[serde(default)] + pub realm_state: serde_json::Value, + /// User-scoped state visible to item inspectors. + #[serde(default)] + pub user_state: serde_json::Value, + + // Private state columns (cleared on transfer) + /// Private server-scoped state, only visible to owner. + #[serde(default)] + pub server_private_state: serde_json::Value, + /// Private realm-scoped state, only visible to owner. + #[serde(default)] + pub realm_private_state: serde_json::Value, + /// Private user-scoped state, only visible to owner. + #[serde(default)] + pub user_private_state: serde_json::Value, } impl InventoryItem { @@ -960,6 +1101,10 @@ pub struct LoosePropRow { pub prop_asset_path: String, pub is_locked: bool, pub locked_by: Option, + // Public state columns (loose props only have public state) + pub server_state: serde_json::Value, + pub realm_state: serde_json::Value, + pub user_state: serde_json::Value, } #[cfg(feature = "ssr")] @@ -989,6 +1134,9 @@ impl From for LooseProp { prop_asset_path: row.prop_asset_path, is_locked: row.is_locked, locked_by: row.locked_by, + server_state: row.server_state, + realm_state: row.realm_state, + user_state: row.user_state, } } } @@ -1017,6 +1165,16 @@ pub struct LooseProp { /// User ID of the moderator who locked this prop. #[serde(default)] pub locked_by: Option, + // Public state columns (loose props only have public state, private is cleared on drop) + /// Server-scoped state visible to everyone. + #[serde(default)] + pub server_state: serde_json::Value, + /// Realm-scoped state visible to realm members. + #[serde(default)] + pub realm_state: serde_json::Value, + /// User-scoped state from previous owner. + #[serde(default)] + pub user_state: serde_json::Value, } /// A server-wide prop (global library). diff --git a/crates/chattyness-db/src/queries/inventory.rs b/crates/chattyness-db/src/queries/inventory.rs index 82efdcc..a45c91e 100644 --- a/crates/chattyness-db/src/queries/inventory.rs +++ b/crates/chattyness-db/src/queries/inventory.rs @@ -23,7 +23,13 @@ pub async fn list_user_inventory<'e>( is_portable, is_droppable, origin, - acquired_at + acquired_at, + server_state, + realm_state, + user_state, + server_private_state, + realm_private_state, + user_private_state FROM auth.inventory WHERE user_id = $1 ORDER BY acquired_at DESC @@ -251,7 +257,13 @@ pub async fn acquire_server_prop<'e>( origin, is_transferable, is_portable, - is_droppable + is_droppable, + server_state, + realm_state, + user_state, + server_private_state, + realm_private_state, + user_private_state ) SELECT $2, @@ -262,7 +274,13 @@ pub async fn acquire_server_prop<'e>( 'server_library'::server.prop_origin, oc.is_transferable, oc.is_portable, - oc.is_droppable + oc.is_droppable, + '{}'::jsonb, + '{}'::jsonb, + '{}'::jsonb, + '{}'::jsonb, + '{}'::jsonb, + '{}'::jsonb FROM ownership_check oc WHERE oc.is_active = true AND oc.is_public = true @@ -280,7 +298,13 @@ pub async fn acquire_server_prop<'e>( is_portable, is_droppable, origin, - acquired_at + acquired_at, + server_state, + realm_state, + user_state, + server_private_state, + realm_private_state, + user_private_state ) SELECT * FROM inserted "#, @@ -428,7 +452,13 @@ pub async fn acquire_realm_prop<'e>( origin, is_transferable, is_portable, - is_droppable + is_droppable, + server_state, + realm_state, + user_state, + server_private_state, + realm_private_state, + user_private_state ) SELECT $3, @@ -439,7 +469,13 @@ pub async fn acquire_realm_prop<'e>( 'realm_library'::server.prop_origin, oc.is_transferable, true, -- realm props are portable by default - oc.is_droppable + oc.is_droppable, + '{}'::jsonb, + '{}'::jsonb, + '{}'::jsonb, + '{}'::jsonb, + '{}'::jsonb, + '{}'::jsonb FROM ownership_check oc WHERE oc.is_active = true AND oc.is_public = true @@ -457,7 +493,13 @@ pub async fn acquire_realm_prop<'e>( is_portable, is_droppable, origin, - acquired_at + acquired_at, + server_state, + realm_state, + user_state, + server_private_state, + realm_private_state, + user_private_state ) SELECT * FROM inserted "#, @@ -547,3 +589,103 @@ pub async fn get_realm_prop_acquisition_error<'e>( )), } } + +use crate::models::{StateScope, StateVisibility}; + +/// Update state on an inventory item. +/// +/// Only the item owner can update state. +/// If `merge` is true, the new state is deep-merged with existing state. +/// If `merge` is false, the new state replaces existing state. +/// +/// Returns the updated state value. +pub async fn update_inventory_item_state<'e>( + executor: impl PgExecutor<'e>, + user_id: Uuid, + item_id: Uuid, + scope: StateScope, + visibility: StateVisibility, + state: serde_json::Value, + merge: bool, +) -> Result { + // Determine which column to update based on scope and visibility + let column_name = match (scope, visibility) { + (StateScope::Server, StateVisibility::Public) => "server_state", + (StateScope::Server, StateVisibility::Private) => "server_private_state", + (StateScope::Realm, StateVisibility::Public) => "realm_state", + (StateScope::Realm, StateVisibility::Private) => "realm_private_state", + (StateScope::User, StateVisibility::Public) => "user_state", + (StateScope::User, StateVisibility::Private) => "user_private_state", + }; + + // Build the update expression based on merge flag + // If merge is true, use jsonb_strip_nulls(old || new) for deep merge + // If merge is false, just replace with new value + let update_expr = if merge { + format!("{} = jsonb_strip_nulls({} || $3)", column_name, column_name) + } else { + format!("{} = $3", column_name) + }; + + // Build the full query + let query = format!( + r#" + UPDATE auth.inventory + SET {} + WHERE id = $1 AND user_id = $2 + RETURNING {} + "#, + update_expr, column_name + ); + + let result: Option<(serde_json::Value,)> = sqlx::query_as(&query) + .bind(item_id) + .bind(user_id) + .bind(&state) + .fetch_optional(executor) + .await?; + + match result { + Some((new_state,)) => Ok(new_state), + None => Err(AppError::NotFound( + "Inventory item not found or not owned by user".to_string(), + )), + } +} + +/// Get an inventory item by ID with ownership verification. +pub async fn get_inventory_item<'e>( + executor: impl PgExecutor<'e>, + item_id: Uuid, + user_id: Uuid, +) -> Result, AppError> { + let item = sqlx::query_as::<_, InventoryItem>( + r#" + SELECT + id, + COALESCE(server_prop_id, realm_prop_id) as prop_id, + prop_name, + prop_asset_path, + layer, + is_transferable, + is_portable, + is_droppable, + origin, + acquired_at, + server_state, + realm_state, + user_state, + server_private_state, + realm_private_state, + user_private_state + FROM auth.inventory + WHERE id = $1 AND user_id = $2 + "#, + ) + .bind(item_id) + .bind(user_id) + .fetch_optional(executor) + .await?; + + Ok(item) +} diff --git a/crates/chattyness-db/src/queries/loose_props.rs b/crates/chattyness-db/src/queries/loose_props.rs index 0d94981..9d72d04 100644 --- a/crates/chattyness-db/src/queries/loose_props.rs +++ b/crates/chattyness-db/src/queries/loose_props.rs @@ -53,7 +53,10 @@ pub async fn list_channel_loose_props<'e>( COALESCE(sp.name, rp.name) as prop_name, COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path, lp.is_locked, - lp.locked_by + lp.locked_by, + lp.server_state, + lp.realm_state, + lp.user_state FROM scene.loose_props lp LEFT JOIN server.props sp ON lp.server_prop_id = sp.id LEFT JOIN realm.props rp ON lp.realm_prop_id = rp.id @@ -69,11 +72,37 @@ pub async fn list_channel_loose_props<'e>( Ok(rows.into_iter().map(LooseProp::from).collect()) } +/// Result row type for drop_prop_to_canvas query. +/// Uses a struct instead of tuple because sqlx has a limit on tuple size. +#[derive(Debug, sqlx::FromRow)] +struct DropPropResult { + item_existed: bool, + was_droppable: bool, + was_deleted: bool, + id: Option, + channel_id: Option, + server_prop_id: Option, + realm_prop_id: Option, + position_x: Option, + position_y: Option, + scale: Option, + dropped_by: Option, + expires_at: Option>, + created_at: Option>, + prop_name: Option, + prop_asset_path: Option, + server_state: Option, + realm_state: Option, + user_state: Option, +} + /// Drop a prop from inventory to the canvas. /// /// Deletes from inventory and inserts into loose_props with 30-minute expiry. /// Returns the created loose prop. /// Returns an error if the prop is non-droppable (essential prop). +/// Note: Public state (server_state, realm_state, user_state) is transferred to the loose prop. +/// Private state is NOT transferred (cleared when dropped). pub async fn drop_prop_to_canvas<'e>( executor: impl PgExecutor<'e>, inventory_item_id: Uuid, @@ -85,23 +114,8 @@ pub async fn drop_prop_to_canvas<'e>( // Single CTE that checks existence/droppability and performs the operation atomically. // Returns status flags plus the LooseProp data (if successful). // Includes scale inherited from the source prop's default_scale. - let result: Option<( - bool, - bool, - bool, - Option, - Option, - Option, - Option, - Option, - Option, - Option, - Option, - Option>, - Option>, - Option, - Option, - )> = sqlx::query_as( + // Transfers public state columns (server_state, realm_state, user_state). + let result: Option = sqlx::query_as( r#" WITH item_info AS ( SELECT @@ -111,6 +125,9 @@ pub async fn drop_prop_to_canvas<'e>( inv.realm_prop_id, inv.prop_name, inv.prop_asset_path, + inv.server_state, + inv.realm_state, + inv.user_state, COALESCE(sp.default_scale, rp.default_scale, 1.0) as default_scale FROM auth.inventory inv LEFT JOIN server.props sp ON inv.server_prop_id = sp.id @@ -120,7 +137,8 @@ pub async fn drop_prop_to_canvas<'e>( deleted_item AS ( DELETE FROM auth.inventory WHERE id = $1 AND user_id = $2 AND is_droppable = true - RETURNING id, server_prop_id, realm_prop_id, prop_name, prop_asset_path + RETURNING id, server_prop_id, realm_prop_id, prop_name, prop_asset_path, + server_state, realm_state, user_state ), inserted_prop AS ( INSERT INTO scene.loose_props ( @@ -130,7 +148,10 @@ pub async fn drop_prop_to_canvas<'e>( position, scale, dropped_by, - expires_at + expires_at, + server_state, + realm_state, + user_state ) SELECT $3, @@ -139,7 +160,10 @@ pub async fn drop_prop_to_canvas<'e>( public.make_virtual_point($4::real, $5::real), (SELECT default_scale FROM item_info), $2, - now() + interval '30 minutes' + now() + interval '30 minutes', + di.server_state, + di.realm_state, + di.user_state FROM deleted_item di RETURNING id, @@ -151,7 +175,10 @@ pub async fn drop_prop_to_canvas<'e>( scale, dropped_by, expires_at, - created_at + created_at, + server_state, + realm_state, + user_state ) SELECT EXISTS(SELECT 1 FROM item_info) AS item_existed, @@ -168,7 +195,10 @@ pub async fn drop_prop_to_canvas<'e>( ip.expires_at, ip.created_at, di.prop_name, - di.prop_asset_path + di.prop_asset_path, + ip.server_state, + ip.realm_state, + ip.user_state FROM (SELECT 1) AS dummy LEFT JOIN inserted_prop ip ON true LEFT JOIN deleted_item di ON true @@ -189,41 +219,44 @@ pub async fn drop_prop_to_canvas<'e>( "Unexpected error dropping prop to canvas".to_string(), )) } - Some((false, _, _, _, _, _, _, _, _, _, _, _, _, _, _)) => { + Some(r) if !r.item_existed => { // Item didn't exist Err(AppError::NotFound( "Inventory item not found or not owned by user".to_string(), )) } - Some((true, false, _, _, _, _, _, _, _, _, _, _, _, _, _)) => { + Some(r) if r.item_existed && !r.was_droppable => { // Item existed but is not droppable Err(AppError::Forbidden( "This prop cannot be dropped - it is an essential prop".to_string(), )) } - Some((true, true, false, _, _, _, _, _, _, _, _, _, _, _, _)) => { + Some(r) if r.item_existed && r.was_droppable && !r.was_deleted => { // Item was droppable but delete failed (shouldn't happen) Err(AppError::Internal( "Unexpected error dropping prop to canvas".to_string(), )) } - Some(( - true, - true, - true, - Some(id), - Some(channel_id), + Some(DropPropResult { + item_existed: true, + was_droppable: true, + was_deleted: true, + id: Some(id), + channel_id: Some(channel_id), server_prop_id, realm_prop_id, - Some(position_x), - Some(position_y), - Some(scale), + position_x: Some(position_x), + position_y: Some(position_y), + scale: Some(scale), dropped_by, - Some(expires_at), - Some(created_at), - Some(prop_name), - Some(prop_asset_path), - )) => { + expires_at: Some(expires_at), + created_at: Some(created_at), + prop_name: Some(prop_name), + prop_asset_path: Some(prop_asset_path), + server_state: Some(server_state), + realm_state: Some(realm_state), + user_state: Some(user_state), + }) => { // Construct PropSource from the nullable columns let source = if let Some(sid) = server_prop_id { PropSource::Server(sid) @@ -250,6 +283,9 @@ pub async fn drop_prop_to_canvas<'e>( prop_asset_path, is_locked: false, locked_by: None, + server_state, + realm_state, + user_state, }) } _ => { @@ -264,19 +300,23 @@ pub async fn drop_prop_to_canvas<'e>( /// Pick up a loose prop (delete from loose_props, insert to inventory). /// /// Returns the created inventory item. +/// Note: Public state (server_state, realm_state, user_state) is transferred from the loose prop. +/// Private state is initialized to empty (cleared for new owner). pub async fn pick_up_loose_prop<'e>( executor: impl PgExecutor<'e>, loose_prop_id: Uuid, user_id: Uuid, ) -> Result { // Use a CTE to delete from loose_props and insert to inventory + // Public state is transferred, private state is initialized to empty let item = sqlx::query_as::<_, InventoryItem>( r#" WITH deleted_prop AS ( DELETE FROM scene.loose_props WHERE id = $1 AND (expires_at IS NULL OR expires_at > now()) - RETURNING id, server_prop_id, realm_prop_id + RETURNING id, server_prop_id, realm_prop_id, + server_state, realm_state, user_state ), source_info AS ( SELECT @@ -287,7 +327,10 @@ pub async fn pick_up_loose_prop<'e>( COALESCE(sp.is_portable, true) as is_portable, COALESCE(sp.is_droppable, rp.is_droppable, true) as is_droppable, dp.server_prop_id, - dp.realm_prop_id + dp.realm_prop_id, + dp.server_state, + dp.realm_state, + dp.user_state FROM deleted_prop dp LEFT JOIN server.props sp ON dp.server_prop_id = sp.id LEFT JOIN realm.props rp ON dp.realm_prop_id = rp.id @@ -305,7 +348,15 @@ pub async fn pick_up_loose_prop<'e>( is_portable, is_droppable, provenance, - acquired_at + acquired_at, + -- Transfer public state from loose prop + server_state, + realm_state, + user_state, + -- Initialize private state to empty (cleared for new owner) + server_private_state, + realm_private_state, + user_private_state ) SELECT $2, @@ -319,9 +370,20 @@ pub async fn pick_up_loose_prop<'e>( COALESCE(si.is_portable, true), COALESCE(si.is_droppable, true), '[]'::jsonb, - now() + now(), + -- Transfer public state + si.server_state, + si.realm_state, + si.user_state, + -- Private state cleared for new owner + '{}'::jsonb, + '{}'::jsonb, + '{}'::jsonb FROM source_info si - RETURNING id, server_prop_id, realm_prop_id, prop_name, prop_asset_path, layer, is_transferable, is_portable, is_droppable, origin, acquired_at + RETURNING id, server_prop_id, realm_prop_id, prop_name, prop_asset_path, layer, + is_transferable, is_portable, is_droppable, origin, acquired_at, + server_state, realm_state, user_state, + server_private_state, realm_private_state, user_private_state ) SELECT ii.id, @@ -333,7 +395,13 @@ pub async fn pick_up_loose_prop<'e>( ii.is_portable, ii.is_droppable, ii.origin, - ii.acquired_at + ii.acquired_at, + ii.server_state, + ii.realm_state, + ii.user_state, + ii.server_private_state, + ii.realm_private_state, + ii.user_private_state FROM inserted_item ii "#, ) @@ -381,7 +449,10 @@ pub async fn update_loose_prop_scale<'e>( expires_at, created_at, is_locked, - locked_by + locked_by, + server_state, + realm_state, + user_state ) SELECT u.id, @@ -397,7 +468,10 @@ pub async fn update_loose_prop_scale<'e>( COALESCE(sp.name, rp.name) as prop_name, COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path, u.is_locked, - u.locked_by + u.locked_by, + u.server_state, + u.realm_state, + u.user_state FROM updated u LEFT JOIN server.props sp ON u.server_prop_id = sp.id LEFT JOIN realm.props rp ON u.realm_prop_id = rp.id @@ -433,7 +507,10 @@ pub async fn get_loose_prop_by_id<'e>( COALESCE(sp.name, rp.name) as prop_name, COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path, lp.is_locked, - lp.locked_by + lp.locked_by, + lp.server_state, + lp.realm_state, + lp.user_state FROM scene.loose_props lp LEFT JOIN server.props sp ON lp.server_prop_id = sp.id LEFT JOIN realm.props rp ON lp.realm_prop_id = rp.id @@ -474,7 +551,10 @@ pub async fn move_loose_prop<'e>( expires_at, created_at, is_locked, - locked_by + locked_by, + server_state, + realm_state, + user_state ) SELECT u.id, @@ -490,7 +570,10 @@ pub async fn move_loose_prop<'e>( COALESCE(sp.name, rp.name) as prop_name, COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path, u.is_locked, - u.locked_by + u.locked_by, + u.server_state, + u.realm_state, + u.user_state FROM updated u LEFT JOIN server.props sp ON u.server_prop_id = sp.id LEFT JOIN realm.props rp ON u.realm_prop_id = rp.id @@ -531,7 +614,10 @@ pub async fn lock_loose_prop<'e>( expires_at, created_at, is_locked, - locked_by + locked_by, + server_state, + realm_state, + user_state ) SELECT u.id, @@ -547,7 +633,10 @@ pub async fn lock_loose_prop<'e>( COALESCE(sp.name, rp.name) as prop_name, COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path, u.is_locked, - u.locked_by + u.locked_by, + u.server_state, + u.realm_state, + u.user_state FROM updated u LEFT JOIN server.props sp ON u.server_prop_id = sp.id LEFT JOIN realm.props rp ON u.realm_prop_id = rp.id @@ -586,7 +675,10 @@ pub async fn unlock_loose_prop<'e>( expires_at, created_at, is_locked, - locked_by + locked_by, + server_state, + realm_state, + user_state ) SELECT u.id, @@ -602,7 +694,10 @@ pub async fn unlock_loose_prop<'e>( COALESCE(sp.name, rp.name) as prop_name, COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path, u.is_locked, - u.locked_by + u.locked_by, + u.server_state, + u.realm_state, + u.user_state FROM updated u LEFT JOIN server.props sp ON u.server_prop_id = sp.id LEFT JOIN realm.props rp ON u.realm_prop_id = rp.id diff --git a/crates/chattyness-db/src/ws_messages.rs b/crates/chattyness-db/src/ws_messages.rs index 821209b..6a27208 100644 --- a/crates/chattyness-db/src/ws_messages.rs +++ b/crates/chattyness-db/src/ws_messages.rs @@ -5,7 +5,10 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::models::{AvatarRenderData, ChannelMemberInfo, ChannelMemberWithAvatar, ForcedAvatarReason, LooseProp}; +use crate::models::{ + AvatarRenderData, ChannelMemberInfo, ChannelMemberWithAvatar, ForcedAvatarReason, LooseProp, + PropStateView, StateScope, StateVisibility, +}; /// Default function for serde that returns true (for is_same_scene field). /// Must be pub for serde derive macro to access via full path. @@ -146,6 +149,29 @@ pub enum ClientMessage { /// The loose prop ID to delete. loose_prop_id: Uuid, }, + + /// Update state on an inventory item. + UpdateItemState { + /// The inventory item ID to update. + inventory_item_id: Uuid, + /// Which scope to update (server, realm, or user). + scope: StateScope, + /// Whether to update public or private state. + visibility: StateVisibility, + /// The new state value. + state: serde_json::Value, + /// If true, merge with existing state; if false, replace. + #[serde(default)] + merge: bool, + }, + + /// Request to view a prop's state. + ViewPropState { + /// The prop ID to view (either inventory item or loose prop). + prop_id: Uuid, + /// Whether this is a loose prop (true) or inventory item (false). + is_loose_prop: bool, + }, } /// Server-to-client WebSocket messages. @@ -348,4 +374,22 @@ pub enum ServerMessage { /// Display name of who cleared the forced avatar (if mod command). cleared_by: Option, }, + + /// Response with prop state for viewing. + PropStateViewResponse { + /// The prop state view data. + view: PropStateView, + }, + + /// Confirmation of item state update. + ItemStateUpdated { + /// The inventory item ID that was updated. + inventory_item_id: Uuid, + /// Which scope was updated. + scope: StateScope, + /// Whether public or private state was updated. + visibility: StateVisibility, + /// The new state value. + new_state: serde_json::Value, + }, } diff --git a/crates/chattyness-user-ui/src/api/websocket.rs b/crates/chattyness-user-ui/src/api/websocket.rs index 270886a..62690a8 100644 --- a/crates/chattyness-user-ui/src/api/websocket.rs +++ b/crates/chattyness-user-ui/src/api/websocket.rs @@ -1703,6 +1703,126 @@ async fn handle_socket( } } } + + ClientMessage::UpdateItemState { + inventory_item_id, + scope, + visibility, + state, + merge, + } => { + // Update state on an inventory item + // Need to acquire connection with RLS context set + let result = async { + let mut conn = pool.acquire().await?; + set_rls_user_id(&mut conn, user_id).await?; + inventory::update_inventory_item_state( + &mut *conn, + user_id, + inventory_item_id, + scope, + visibility, + state, + merge, + ).await + }.await; + + match result { + Ok(new_state) => { + tracing::debug!( + "[WS] User {} updated {}:{} state on item {}", + user_id, scope, visibility, inventory_item_id + ); + let _ = direct_tx.send(ServerMessage::ItemStateUpdated { + inventory_item_id, + scope, + visibility, + new_state, + }).await; + } + Err(e) => { + tracing::error!("[WS] Update item state failed: {:?}", e); + let _ = direct_tx.send(ServerMessage::Error { + code: "UPDATE_STATE_FAILED".to_string(), + message: format!("{:?}", e), + }).await; + } + } + } + + ClientMessage::ViewPropState { prop_id, is_loose_prop } => { + // View state of a prop (loose prop or inventory item) + use chattyness_db::models::{PropStateView, PrivateStateBundle}; + + let result = if is_loose_prop { + // Get loose prop state + match loose_props::get_loose_prop_by_id(&pool, prop_id).await { + Ok(Some(prop)) => { + let action_hint = PropStateView::detect_action_hint(&prop.server_state); + // Get owner display name if prop was dropped by someone + let owner_name = if let Some(dropped_by) = prop.dropped_by { + users::get_user_by_id(&pool, dropped_by).await + .ok() + .flatten() + .map(|u| u.display_name) + } else { + None + }; + Ok(PropStateView { + prop_id, + prop_name: prop.prop_name, + owner_display_name: owner_name, + server_state: prop.server_state, + // Realm state visible if user is in same realm + realm_state: Some(prop.realm_state), + user_state: prop.user_state, + // No private state for loose props + private_state: None, + action_hint, + }) + } + Ok(None) => Err(AppError::NotFound("Loose prop not found".to_string())), + Err(e) => Err(e), + } + } else { + // Get inventory item state + match inventory::get_inventory_item(&pool, prop_id, user_id).await { + Ok(Some(item)) => { + let action_hint = PropStateView::detect_action_hint(&item.server_state); + Ok(PropStateView { + prop_id, + prop_name: item.prop_name, + owner_display_name: None, // It's the user's own item + server_state: item.server_state, + realm_state: Some(item.realm_state), + user_state: item.user_state, + // Include private state since viewer is owner + private_state: Some(PrivateStateBundle { + server_private_state: item.server_private_state, + realm_private_state: item.realm_private_state, + user_private_state: item.user_private_state, + }), + action_hint, + }) + } + Ok(None) => Err(AppError::NotFound("Inventory item not found".to_string())), + Err(e) => Err(e), + } + }; + + match result { + Ok(view) => { + let _ = direct_tx.send(ServerMessage::PropStateViewResponse { view }).await; + } + Err(e) => { + tracing::error!("[WS] View prop state failed: {:?}", e); + let _ = direct_tx.send(ServerMessage::Error { + code: "VIEW_STATE_FAILED".to_string(), + message: format!("{:?}", e), + }).await; + } + } + } } } Message::Close(close_frame) => { diff --git a/crates/chattyness-user-ui/src/components/inventory.rs b/crates/chattyness-user-ui/src/components/inventory.rs index 9157aaf..1e7c8a1 100644 --- a/crates/chattyness-user-ui/src/components/inventory.rs +++ b/crates/chattyness-user-ui/src/components/inventory.rs @@ -4,9 +4,10 @@ use leptos::prelude::*; use leptos::reactive::owner::LocalStorage; use uuid::Uuid; -use chattyness_db::models::{InventoryItem, PropAcquisitionInfo}; +use chattyness_db::models::{InventoryItem, PropAcquisitionInfo, StateScope, StateVisibility}; #[cfg(feature = "hydrate")] use chattyness_db::ws_messages::ClientMessage; +use leptos::ev::Event; use super::modals::{ConfirmModal, GuestLockedOverlay, Modal}; use super::tabs::{Tab, TabBar}; @@ -372,6 +373,7 @@ pub fn InventoryPopup( set_delete_confirm_item.set(Some((id, name))); }) on_delete_immediate=Callback::new(handle_delete) + ws_sender=ws_sender /> @@ -457,6 +459,8 @@ fn MyInventoryTab( #[prop(into)] on_delete_request: Callback<(Uuid, String)>, /// Callback for immediate delete (Shift+Delete, no confirmation) #[prop(into)] on_delete_immediate: Callback, + /// WebSocket sender for updating item state + ws_sender: StoredValue, LocalStorage>, ) -> impl IntoView { // NodeRef to maintain focus on container after item removal let container_ref = NodeRef::::new(); @@ -678,6 +682,21 @@ fn MyInventoryTab( + + // Show BusinessCardEditor for business card props + { + let is_business_card = item.prop_name.to_lowercase().contains("businesscard"); + if is_business_card { + Some(view! { + + }) + } else { + None + } + } }) }} @@ -687,6 +706,164 @@ fn MyInventoryTab( } } +/// Helper function to get string value from Event target. +fn event_target_value(ev: &Event) -> String { + use leptos::wasm_bindgen::JsCast; + ev.target() + .and_then(|t| t.dyn_into::().ok()) + .map(|input| input.value()) + .unwrap_or_default() +} + +/// Business card editor component for customizing business card props. +/// +/// Allows editing profile information (name, title, social links) that will +/// be displayed when other users view the prop. +#[component] +fn BusinessCardEditor( + item: InventoryItem, + ws_sender: StoredValue, LocalStorage>, +) -> impl IntoView { + // Extract existing profile data from server_state + let profile = item + .server_state + .get("profile") + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + + let (name, set_name) = signal( + profile + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + ); + let (title, set_title) = signal( + profile + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + ); + let (linkedin, set_linkedin) = signal( + profile + .get("linkedin") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + ); + let (github, set_github) = signal( + profile + .get("github") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + ); + let (website, set_website) = signal( + profile + .get("website") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + ); + let (saving, set_saving) = signal(false); + let (save_message, set_save_message) = signal(Option::<(bool, String)>::None); + + let item_id = item.id; + + let handle_save = move |_| { + set_saving.set(true); + set_save_message.set(None); + + let state = serde_json::json!({ + "profile": { + "name": name.get(), + "title": title.get(), + "linkedin": linkedin.get(), + "github": github.get(), + "website": website.get() + } + }); + + #[cfg(feature = "hydrate")] + ws_sender.with_value(|sender| { + if let Some(send_fn) = sender { + send_fn(ClientMessage::UpdateItemState { + inventory_item_id: item_id, + scope: StateScope::Server, + visibility: StateVisibility::Public, + state, + merge: false, // Replace entire server_state + }); + set_save_message.set(Some((true, "Saved!".to_string()))); + } else { + set_save_message.set(Some((false, "Not connected".to_string()))); + } + }); + + set_saving.set(false); + }; + + view! { +
+

"Business Card Details"

+
+ + + + + + + {move || save_message.get().map(|(success, msg)| { + let class = if success { + "text-green-400 text-sm mt-2" + } else { + "text-red-400 text-sm mt-2" + }; + view! {

{msg}

} + })} +
+
+ } +} + /// Acquisition props tab content with acquire functionality. #[component] fn AcquisitionPropsTab( diff --git a/crates/chattyness-user-ui/src/components/modals.rs b/crates/chattyness-user-ui/src/components/modals.rs index 3bcde67..86b7dfd 100644 --- a/crates/chattyness-user-ui/src/components/modals.rs +++ b/crates/chattyness-user-ui/src/components/modals.rs @@ -356,3 +356,251 @@ pub fn GuestLockedOverlay() -> impl IntoView { } } + +// ============================================================================ +// Prop State View Modal +// ============================================================================ + +use chattyness_db::models::{PropActionHint, PropStateView}; + +/// Modal for viewing prop state (business cards, info panels, etc.). +/// +/// Automatically renders appropriate view based on action_hint: +/// - BusinessCard: Shows profile with social links +/// - ExternalLinks: Shows list of clickable links +/// - InfoPanel: Shows formatted JSON state +#[component] +pub fn PropInfoModal( + #[prop(into)] open: Signal, + #[prop(into)] prop_state: Signal>, + on_close: Callback<()>, +) -> impl IntoView { + use crate::utils::use_escape_key; + + // Handle escape key + use_escape_key(open, on_close.clone()); + + let on_close_backdrop = on_close.clone(); + let on_close_button = on_close.clone(); + + view! { + +