Initial copy of business card prop with profile

This commit is contained in:
Evan Carroll 2026-01-24 10:03:10 -06:00
parent 4f0f88504a
commit 9541fb1927
16 changed files with 1193 additions and 71 deletions

View file

@ -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"]

View file

@ -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<String>,
/// 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<serde_json::Value>,
/// 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<PrivateStateBundle>,
/// Detected action hint based on state content.
pub action_hint: Option<PropActionHint>,
}
/// 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<PropActionHint> {
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<Utc>,
// 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<Uuid>,
// 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<LoosePropRow> 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<Uuid>,
// 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).

View file

@ -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<serde_json::Value, AppError> {
// 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<Option<InventoryItem>, 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)
}

View file

@ -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<Uuid>,
channel_id: Option<Uuid>,
server_prop_id: Option<Uuid>,
realm_prop_id: Option<Uuid>,
position_x: Option<f32>,
position_y: Option<f32>,
scale: Option<f32>,
dropped_by: Option<Uuid>,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
created_at: Option<chrono::DateTime<chrono::Utc>>,
prop_name: Option<String>,
prop_asset_path: Option<String>,
server_state: Option<serde_json::Value>,
realm_state: Option<serde_json::Value>,
user_state: Option<serde_json::Value>,
}
/// 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<Uuid>,
Option<Uuid>,
Option<Uuid>,
Option<Uuid>,
Option<f32>,
Option<f32>,
Option<f32>,
Option<Uuid>,
Option<chrono::DateTime<chrono::Utc>>,
Option<chrono::DateTime<chrono::Utc>>,
Option<String>,
Option<String>,
)> = sqlx::query_as(
// Transfers public state columns (server_state, realm_state, user_state).
let result: Option<DropPropResult> = 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<InventoryItem, AppError> {
// 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

View file

@ -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<String>,
},
/// 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,
},
}

View file

@ -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) => {

View file

@ -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
/>
</Show>
@ -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<Uuid>,
/// WebSocket sender for updating item state
ws_sender: StoredValue<Option<WsSender>, LocalStorage>,
) -> impl IntoView {
// NodeRef to maintain focus on container after item removal
let container_ref = NodeRef::<leptos::html::Div>::new();
@ -678,6 +682,21 @@ fn MyInventoryTab(
</Show>
</div>
</div>
// Show BusinessCardEditor for business card props
{
let is_business_card = item.prop_name.to_lowercase().contains("businesscard");
if is_business_card {
Some(view! {
<BusinessCardEditor
item=item.clone()
ws_sender=ws_sender
/>
})
} else {
None
}
}
</div>
})
}}
@ -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::<leptos::web_sys::HtmlInputElement>().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<Option<WsSender>, 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! {
<div class="mt-4 pt-4 border-t border-gray-700">
<h4 class="text-white font-medium mb-3">"Business Card Details"</h4>
<div class="space-y-3">
<input
type="text"
placeholder="Your Name"
prop:value=move || name.get()
on:input=move |e| set_name.set(event_target_value(&e))
class="w-full px-3 py-2 bg-gray-700 rounded text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<input
type="text"
placeholder="Title / Position"
prop:value=move || title.get()
on:input=move |e| set_title.set(event_target_value(&e))
class="w-full px-3 py-2 bg-gray-700 rounded text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<input
type="text"
placeholder="LinkedIn username"
prop:value=move || linkedin.get()
on:input=move |e| set_linkedin.set(event_target_value(&e))
class="w-full px-3 py-2 bg-gray-700 rounded text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<input
type="text"
placeholder="GitHub username"
prop:value=move || github.get()
on:input=move |e| set_github.set(event_target_value(&e))
class="w-full px-3 py-2 bg-gray-700 rounded text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<input
type="text"
placeholder="Website URL"
prop:value=move || website.get()
on:input=move |e| set_website.set(event_target_value(&e))
class="w-full px-3 py-2 bg-gray-700 rounded text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="button"
class="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors disabled:opacity-50"
on:click=handle_save
disabled=move || saving.get()
>
{move || if saving.get() { "Saving..." } else { "Save Business Card" }}
</button>
{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! { <p class=class>{msg}</p> }
})}
</div>
</div>
}
}
/// Acquisition props tab content with acquire functionality.
#[component]
fn AcquisitionPropsTab(

View file

@ -356,3 +356,251 @@ pub fn GuestLockedOverlay() -> impl IntoView {
</div>
}
}
// ============================================================================
// 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<bool>,
#[prop(into)] prop_state: Signal<Option<PropStateView>>,
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! {
<Show when=move || open.get() && prop_state.get().is_some()>
<div
class="fixed inset-0 z-50 flex items-center justify-center"
role="dialog"
aria-modal="true"
aria-labelledby="prop-info-modal-title"
>
// Backdrop
<div
class="absolute inset-0 bg-black/70 backdrop-blur-sm"
on:click=move |_| on_close_backdrop.run(())
aria-hidden="true"
/>
// Modal content
<div class="relative bg-gray-800 rounded-lg shadow-2xl max-w-md w-full mx-4 p-6 border border-gray-700">
// Close button
<button
type="button"
class="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
on:click=move |_| on_close_button.run(())
aria-label="Close"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
{move || {
let Some(state) = prop_state.get() else { return view! { <div/> }.into_any() };
view! {
<div>
// Header
<h2 id="prop-info-modal-title" class="text-xl font-bold text-white mb-2">
{state.prop_name.clone()}
</h2>
{state.owner_display_name.clone().map(|name| view! {
<p class="text-gray-400 text-sm mb-4">
"From: " <span class="text-blue-400">{name}</span>
</p>
})}
// Content based on action hint
{match state.action_hint {
Some(PropActionHint::BusinessCard) => {
view! {
<BusinessCardView server_state=state.server_state />
}.into_any()
}
Some(PropActionHint::ExternalLinks) => {
view! {
<ExternalLinksView server_state=state.server_state />
}.into_any()
}
Some(PropActionHint::InfoPanel) | None => {
view! {
<InfoPanelView server_state=state.server_state />
}.into_any()
}
}}
</div>
}.into_any()
}}
</div>
</div>
</Show>
}
}
/// Business card view for props with profile information.
///
/// Displays profile fields and social media link buttons.
#[component]
pub fn BusinessCardView(
server_state: serde_json::Value,
) -> impl IntoView {
// Extract profile from server_state
let profile = server_state.get("profile").cloned().unwrap_or(serde_json::Value::Null);
let name = profile.get("name").and_then(|v| v.as_str()).map(|s| s.to_string());
let title = profile.get("title").and_then(|v| v.as_str()).map(|s| s.to_string());
let company = profile.get("company").and_then(|v| v.as_str()).map(|s| s.to_string());
let linkedin = profile.get("linkedin").and_then(|v| v.as_str()).map(|s| s.to_string());
let github = profile.get("github").and_then(|v| v.as_str()).map(|s| s.to_string());
let twitter = profile.get("twitter").and_then(|v| v.as_str()).map(|s| s.to_string());
let website = profile.get("website").and_then(|v| v.as_str()).map(|s| s.to_string());
view! {
<div class="space-y-4">
// Profile info
<div class="text-center">
{name.clone().map(|n| view! {
<h3 class="text-2xl font-bold text-white">{n}</h3>
})}
{title.clone().map(|t| view! {
<p class="text-gray-300">{t}</p>
})}
{company.clone().map(|c| view! {
<p class="text-gray-400 text-sm">{c}</p>
})}
</div>
// Social links
<div class="flex flex-wrap justify-center gap-2 pt-4 border-t border-gray-700">
{linkedin.clone().map(|username| {
let url = format!("https://linkedin.com/in/{}", username);
view! {
<a
href=url
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-2 px-4 py-2 bg-blue-700 hover:bg-blue-600 rounded-lg text-white text-sm transition-colors"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
"LinkedIn"
</a>
}
})}
{github.clone().map(|username| {
let url = format!("https://github.com/{}", username);
view! {
<a
href=url
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm transition-colors"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"/>
</svg>
"GitHub"
</a>
}
})}
{twitter.clone().map(|username| {
let url = format!("https://twitter.com/{}", username);
view! {
<a
href=url
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-2 px-4 py-2 bg-sky-600 hover:bg-sky-500 rounded-lg text-white text-sm transition-colors"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84"/>
</svg>
"Twitter"
</a>
}
})}
{website.clone().map(|url| {
view! {
<a
href=url.clone()
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-500 rounded-lg text-white text-sm transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/>
</svg>
"Website"
</a>
}
})}
</div>
</div>
}
}
/// External links view for props with link arrays.
#[component]
pub fn ExternalLinksView(
server_state: serde_json::Value,
) -> impl IntoView {
let links = server_state
.get("links")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
view! {
<div class="space-y-2">
{links.into_iter().filter_map(|link| {
let label = link.get("label").and_then(|v| v.as_str()).map(|s| s.to_string())?;
let url = link.get("url").and_then(|v| v.as_str()).map(|s| s.to_string())?;
Some(view! {
<a
href=url
target="_blank"
rel="noopener noreferrer"
class="block w-full px-4 py-3 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-center transition-colors"
>
{label}
</a>
})
}).collect_view()}
</div>
}
}
/// Info panel view for props with arbitrary state.
#[component]
pub fn InfoPanelView(
server_state: serde_json::Value,
) -> impl IntoView {
let formatted = serde_json::to_string_pretty(&server_state).unwrap_or_default();
view! {
<div class="bg-gray-900 rounded-lg p-4 overflow-auto max-h-64">
<pre class="text-gray-300 text-sm font-mono whitespace-pre-wrap">{formatted}</pre>
</div>
}
}

View file

@ -62,6 +62,7 @@ pub fn RealmSceneViewer(
#[prop(optional, into)] on_prop_move: Option<Callback<(Uuid, f64, f64)>>,
#[prop(optional, into)] on_prop_lock_toggle: Option<Callback<(Uuid, bool)>>,
#[prop(optional, into)] on_prop_delete: Option<Callback<Uuid>>,
#[prop(optional, into)] on_view_prop_state: Option<Callback<Uuid>>,
) -> impl IntoView {
let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false));
let settings = settings.unwrap_or_else(|| Signal::derive(ViewerSettings::default));
@ -608,6 +609,8 @@ pub fn RealmSceneViewer(
let is_mod = is_moderator.map(|s| s.get()).unwrap_or(false);
let is_locked = prop_context_is_locked.get();
let mut items = Vec::new();
// Always show View Info for props (to view state/business cards)
items.push(ContextMenuItem { label: "View Info".to_string(), action: "view_info".to_string() });
if !is_locked || is_mod {
items.push(ContextMenuItem { label: "Pick Up".to_string(), action: "pick_up".to_string() });
items.push(ContextMenuItem { label: "Move".to_string(), action: "move".to_string() });
@ -626,8 +629,16 @@ pub fn RealmSceneViewer(
let on_prop_lock_toggle = on_prop_lock_toggle.clone();
let on_prop_click = on_prop_click.clone();
let on_prop_delete = on_prop_delete.clone();
let on_view_prop_state = on_view_prop_state.clone();
move |action: String| {
match action.as_str() {
"view_info" => {
if let Some(prop_id) = prop_context_menu_target.get() {
if let Some(ref callback) = on_view_prop_state {
callback.run(prop_id);
}
}
}
"pick_up" => {
if let Some(prop_id) = prop_context_menu_target.get() {
on_prop_click.run(prop_id);

View file

@ -8,7 +8,7 @@ use leptos::reactive::owner::LocalStorage;
#[cfg(feature = "hydrate")]
use chattyness_db::models::EmotionState;
use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp};
use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp, PropStateView};
use chattyness_db::ws_messages::{close_codes, ClientMessage};
#[cfg(feature = "hydrate")]
use chattyness_db::ws_messages::{DisconnectReason, ServerMessage};
@ -155,6 +155,8 @@ pub enum WsEvent {
ModCommandResult(ModCommandResultInfo),
/// Member identity updated (e.g., guest → user).
MemberIdentityUpdated(MemberIdentityInfo),
/// Prop state view response received.
PropStateView(PropStateView),
}
/// Consolidated internal state to reduce Rc<RefCell<>> proliferation.
@ -528,6 +530,7 @@ fn handle_server_message(
TeleportApproved(TeleportInfo),
Summoned(SummonInfo),
ModCommandResult(ModCommandResultInfo),
PropStateView(PropStateView),
}
let action = {
@ -761,6 +764,13 @@ fn handle_server_message(
}
PostAction::UpdateMembers(state.members.clone())
}
ServerMessage::PropStateViewResponse { view } => {
PostAction::PropStateView(view)
}
ServerMessage::ItemStateUpdated { .. } => {
// State update confirmed - could refresh inventory if needed
PostAction::None
}
}
}; // state borrow is dropped here
@ -805,6 +815,9 @@ fn handle_server_message(
PostAction::ModCommandResult(info) => {
on_event.run(WsEvent::ModCommandResult(info));
}
PostAction::PropStateView(view) => {
on_event.run(WsEvent::PropStateView(view));
}
}
}

View file

@ -14,7 +14,7 @@ use uuid::Uuid;
use crate::components::{
ActiveBubble, AvatarEditorPopup, AvatarStorePopup, Card, ChatInput, ConversationModal,
EmotionKeybindings, FadingMember, HotkeyHelp, InventoryPopup, KeybindingsPopup, LogPopup,
MessageLog, NotificationMessage, NotificationToast, RealmHeader, RealmSceneViewer,
MessageLog, NotificationMessage, NotificationToast, PropInfoModal, RealmHeader, RealmSceneViewer,
ReconnectionOverlay, RegisterModal, SettingsPopup, ViewerSettings,
};
#[cfg(feature = "hydrate")]
@ -26,8 +26,8 @@ use crate::utils::LocalStoragePersist;
#[cfg(feature = "hydrate")]
use crate::utils::parse_bounds_dimensions;
use chattyness_db::models::{
AvatarWithPaths, ChannelMemberWithAvatar, EmotionAvailability, LooseProp, RealmRole,
RealmWithUserRole, Scene, SceneSummary,
AvatarWithPaths, ChannelMemberWithAvatar, EmotionAvailability, LooseProp, PropStateView,
RealmRole, RealmWithUserRole, Scene, SceneSummary,
};
#[cfg(feature = "hydrate")]
use chattyness_db::ws_messages::{close_codes, ClientMessage};
@ -152,6 +152,10 @@ pub fn RealmPage() -> impl IntoView {
// Mod notification state (for summon notifications, command results)
let (mod_notification, set_mod_notification) = signal(Option::<(bool, String)>::None);
// Prop info modal state (for viewing prop state/business cards)
let (prop_info_modal_open, set_prop_info_modal_open) = signal(false);
let (prop_info_state, set_prop_info_state) = signal(Option::<PropStateView>::None);
let realm_data = LocalResource::new(move || {
let slug = slug.get();
async move {
@ -484,6 +488,10 @@ pub fn RealmPage() -> impl IntoView {
}
});
}
WsEvent::PropStateView(view) => {
set_prop_info_state.set(Some(view));
set_prop_info_modal_open.set(true);
}
}
});
@ -711,6 +719,22 @@ pub fn RealmPage() -> impl IntoView {
#[cfg(not(feature = "hydrate"))]
let on_prop_click = Callback::new(move |_prop_id: Uuid| {});
// Handle prop state view request (View Info) via WebSocket
#[cfg(feature = "hydrate")]
let on_view_prop_state = Callback::new(move |prop_id: Uuid| {
ws_sender.with_value(|sender| {
if let Some(send_fn) = sender {
send_fn(ClientMessage::ViewPropState {
prop_id,
is_loose_prop: true,
});
}
});
});
#[cfg(not(feature = "hydrate"))]
let on_view_prop_state = Callback::new(move |_prop_id: Uuid| {});
// Handle global keyboard shortcuts (e+0-9 for emotions, : for chat focus)
#[cfg(feature = "hydrate")]
{
@ -1254,6 +1278,7 @@ pub fn RealmPage() -> impl IntoView {
}
});
});
let on_view_prop_state_cb = on_view_prop_state.clone();
view! {
<div class="relative w-full">
<RealmSceneViewer
@ -1280,6 +1305,7 @@ pub fn RealmPage() -> impl IntoView {
on_prop_move=on_prop_move_cb
on_prop_lock_toggle=on_prop_lock_toggle_cb
on_prop_delete=on_prop_delete_cb
on_view_prop_state=on_view_prop_state_cb
/>
<div class="absolute bottom-0 left-0 right-0 z-10 pb-4 px-4 pointer-events-none">
<ChatInput
@ -1570,6 +1596,16 @@ pub fn RealmPage() -> impl IntoView {
}
}
// Prop info modal (for viewing business cards, etc.)
<PropInfoModal
open=Signal::derive(move || prop_info_modal_open.get())
prop_state=Signal::derive(move || prop_info_state.get())
on_close=Callback::new(move |_| {
set_prop_info_modal_open.set(false);
set_prop_info_state.set(None);
})
/>
// Reconnection overlay - shown when WebSocket disconnects
{
#[cfg(feature = "hydrate")]

View file

@ -282,6 +282,9 @@ CREATE TABLE server.props (
-- Default scale factor for dropped props (10% - 1000%)
default_scale REAL NOT NULL DEFAULT 1.0 CHECK (default_scale >= 0.1 AND default_scale <= 10.0),
-- Optional JSON Schema for validating state structure
state_schema JSONB,
is_unique BOOLEAN NOT NULL DEFAULT false,
is_transferable BOOLEAN NOT NULL DEFAULT true,
is_portable BOOLEAN NOT NULL DEFAULT true,
@ -312,6 +315,7 @@ CREATE TABLE server.props (
);
COMMENT ON TABLE server.props IS 'Global prop library (64x64 pixels, center-anchored)';
COMMENT ON COLUMN server.props.state_schema IS 'Optional JSON Schema defining valid state structure for this prop';
CREATE INDEX idx_server_props_tags ON server.props USING GIN (tags);
CREATE INDEX idx_server_props_active ON server.props (is_active) WHERE is_active = true;
@ -435,7 +439,18 @@ CREATE TABLE auth.inventory (
is_portable BOOLEAN NOT NULL DEFAULT true,
is_droppable BOOLEAN NOT NULL DEFAULT true,
-- Public state columns (persist through transfer)
server_state JSONB NOT NULL DEFAULT '{}',
realm_state JSONB NOT NULL DEFAULT '{}',
user_state JSONB NOT NULL DEFAULT '{}',
-- Private state columns (cleared on transfer)
server_private_state JSONB NOT NULL DEFAULT '{}',
realm_private_state JSONB NOT NULL DEFAULT '{}',
user_private_state JSONB NOT NULL DEFAULT '{}',
acquired_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- At least one source must be present
CONSTRAINT chk_auth_inventory_has_source CHECK (
@ -445,12 +460,23 @@ CREATE TABLE auth.inventory (
COMMENT ON TABLE auth.inventory IS 'User-owned props (denormalized for performance)';
COMMENT ON COLUMN auth.inventory.provenance IS 'Array of {from_user, timestamp, method} objects';
COMMENT ON COLUMN auth.inventory.server_state IS 'Public state visible to everyone, persists through transfer';
COMMENT ON COLUMN auth.inventory.realm_state IS 'Public state visible to realm members, persists through transfer';
COMMENT ON COLUMN auth.inventory.user_state IS 'Public state visible to item inspectors, persists through transfer';
COMMENT ON COLUMN auth.inventory.server_private_state IS 'Private owner state, cleared on transfer';
COMMENT ON COLUMN auth.inventory.realm_private_state IS 'Private owner state, cleared on transfer';
COMMENT ON COLUMN auth.inventory.user_private_state IS 'Private owner state, cleared on transfer';
COMMENT ON COLUMN auth.inventory.updated_at IS 'Timestamp of last modification (auto-updated by trigger)';
CREATE INDEX idx_auth_inventory_user ON auth.inventory (user_id);
CREATE INDEX idx_auth_inventory_server_prop ON auth.inventory (server_prop_id)
WHERE server_prop_id IS NOT NULL;
CREATE INDEX idx_auth_inventory_realm_prop ON auth.inventory (realm_prop_id)
WHERE realm_prop_id IS NOT NULL;
CREATE INDEX idx_inventory_server_state ON auth.inventory USING GIN (server_state)
WHERE server_state != '{}';
CREATE INDEX idx_inventory_realm_state ON auth.inventory USING GIN (realm_state)
WHERE realm_state != '{}';
-- =============================================================================
-- User Avatars (moved from props.avatars)

View file

@ -197,6 +197,9 @@ CREATE TABLE realm.props (
-- Default scale factor for dropped props (10% - 1000%)
default_scale REAL NOT NULL DEFAULT 1.0 CHECK (default_scale >= 0.1 AND default_scale <= 10.0),
-- Optional JSON Schema for validating state structure
state_schema JSONB,
is_unique BOOLEAN NOT NULL DEFAULT false,
is_transferable BOOLEAN NOT NULL DEFAULT true,
is_droppable BOOLEAN NOT NULL DEFAULT true,
@ -223,6 +226,7 @@ CREATE TABLE realm.props (
);
COMMENT ON TABLE realm.props IS 'Realm-specific prop library';
COMMENT ON COLUMN realm.props.state_schema IS 'Optional JSON Schema defining valid state structure for this prop';
CREATE INDEX idx_realm_props_realm ON realm.props (realm_id);
CREATE INDEX idx_realm_props_tags ON realm.props USING GIN (tags);

View file

@ -219,6 +219,15 @@ CREATE TABLE scene.loose_props (
-- Auto-decay
expires_at TIMESTAMPTZ, -- NULL = permanent
-- Moderator lock (prevents non-moderators from interacting)
is_locked BOOLEAN NOT NULL DEFAULT false,
locked_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
-- Public state columns (loose props only have public state)
server_state JSONB NOT NULL DEFAULT '{}',
realm_state JSONB NOT NULL DEFAULT '{}',
user_state JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Must reference exactly one source
@ -231,6 +240,11 @@ CREATE TABLE scene.loose_props (
COMMENT ON TABLE scene.loose_props IS 'Props dropped in instances that can be picked up';
COMMENT ON COLUMN scene.loose_props.position IS 'Location in scene as PostGIS point (SRID 0)';
COMMENT ON COLUMN scene.loose_props.expires_at IS 'When prop auto-decays (NULL = permanent)';
COMMENT ON COLUMN scene.loose_props.is_locked IS 'If true, only moderators can move/scale/pickup this prop';
COMMENT ON COLUMN scene.loose_props.locked_by IS 'User ID of the moderator who locked this prop';
COMMENT ON COLUMN scene.loose_props.server_state IS 'Public state visible to everyone';
COMMENT ON COLUMN scene.loose_props.realm_state IS 'Public state visible to realm members';
COMMENT ON COLUMN scene.loose_props.user_state IS 'Public state from previous owner';
CREATE INDEX idx_scene_loose_props_instance ON scene.loose_props (instance_id);
CREATE INDEX idx_scene_loose_props_expires ON scene.loose_props (expires_at)
@ -240,6 +254,10 @@ CREATE INDEX idx_scene_loose_props_expires ON scene.loose_props (expires_at)
CREATE INDEX idx_scene_loose_props_position ON scene.loose_props
USING GIST (position);
-- GIN index for state queries
CREATE INDEX idx_loose_props_server_state ON scene.loose_props USING GIN (server_state)
WHERE server_state != '{}';
-- =============================================================================
-- Scene Decorations (moved from props.scene_decorations)
-- =============================================================================

View file

@ -557,7 +557,7 @@
coffee: ['espresso', 'latte', 'iced', 'frenchpress', 'pourover', 'turkish', 'cup-empty'],
soda: ['cola', 'lemonlime', 'orange', 'grape', 'rootbeer'],
tea: ['iced', 'pot', 'cup', 'cup-empty', 'bag'],
misc: ['iou', 'signed-dollar', 'thankyou', 'yousuck'],
misc: ['iou', 'signed-dollar', 'thankyou', 'yousuck', 'businesscard'],
goodpol: ['cccp', 'china', 'palestine'],
screen: ['projector', 'projector-with-stand'],
keyboard: ['media']

View file

@ -0,0 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
<g transform="scale(2.5)">
<defs>
<linearGradient id="cardface" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#FFFFFF"/>
<stop offset="100%" stop-color="#F0F0F0"/>
</linearGradient>
<filter id="cardshadow" x="-10%" y="-10%" width="120%" height="120%">
<feDropShadow dx="1" dy="2" stdDeviation="1.5" flood-color="#000000" flood-opacity="0.3"/>
</filter>
</defs>
<!-- Business card with slight tilt -->
<g transform="rotate(-3, 24, 24)">
<!-- Card background - standard 3.5:2 ratio scaled -->
<rect x="5" y="12" width="38" height="22" rx="1.5" fill="url(#cardface)" filter="url(#cardshadow)" stroke="#DDD" stroke-width="0.3"/>
<!-- Face silhouette -->
<circle cx="10" cy="16.5" r="2" fill="#555"/>
<ellipse cx="10" cy="21" rx="3" ry="2" fill="#555"/>
<!-- Name text -->
<text x="16" y="18" font-family="Georgia, serif" font-size="4" font-weight="bold" fill="#1a1a2e">John Doe</text>
<!-- Title text -->
<text x="16" y="22" font-family="Arial, sans-serif" font-size="2.5" fill="#555">Software Engineer</text>
<!-- Contact line -->
<line x1="16" y1="25" x2="40" y2="25" stroke="#E0E0E0" stroke-width="0.3"/>
<!-- Email/phone placeholder -->
<text x="16" y="28" font-family="Arial, sans-serif" font-size="2" fill="#777">john@example.com</text>
<text x="16" y="31" font-family="Arial, sans-serif" font-size="2" fill="#777">+1 555-123-4567</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB