From 5f543ca6c41950868fc7f1e16b76279ccbf018e790cf43f73cb736d9be9abae5 Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Fri, 23 Jan 2026 21:06:54 -0600 Subject: [PATCH 1/4] fix: making picking up a prop a right click feature --- .../src/components/scene_viewer.rs | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/crates/chattyness-user-ui/src/components/scene_viewer.rs b/crates/chattyness-user-ui/src/components/scene_viewer.rs index e0a6b33..211c964 100644 --- a/crates/chattyness-user-ui/src/components/scene_viewer.rs +++ b/crates/chattyness-user-ui/src/components/scene_viewer.rs @@ -132,11 +132,10 @@ pub fn RealmSceneViewer( let (move_mode_prop_scale, set_move_mode_prop_scale) = signal(1.0_f32); let (prop_context_is_locked, set_prop_context_is_locked) = signal(false); - // Click handler for movement or prop pickup + // Click handler for movement (props are now handled via context menu) #[cfg(feature = "hydrate")] let on_overlay_click = { let on_move = on_move.clone(); - let on_prop_click = on_prop_click.clone(); move |ev: web_sys::MouseEvent| { use wasm_bindgen::JsCast; @@ -164,9 +163,7 @@ pub fn RealmSceneViewer( } } - if let Some(prop_id) = clicked_prop { - on_prop_click.run(prop_id); - } else { + if clicked_prop.is_none() { let target = ev.current_target().unwrap(); let element: web_sys::HtmlElement = target.dyn_into().unwrap(); let rect = element.get_bounding_client_rect(); @@ -208,12 +205,20 @@ pub fn RealmSceneViewer( if let Some(prop_id_str) = canvas.get_attribute("data-prop-id") { if hit_test_canvas(&canvas, client_x, client_y) { if let Ok(prop_id) = prop_id_str.parse::() { - ev.prevent_default(); - set_prop_context_menu_position.set((client_x, client_y)); - set_prop_context_menu_target.set(Some(prop_id)); - set_prop_context_menu_open.set(true); - if let Some(prop) = loose_props.get().iter().find(|p| p.id == prop_id) { + let is_mod = is_moderator.map(|s| s.get()).unwrap_or(false); + + // Don't show menu if prop is locked and user is not a moderator + if prop.is_locked && !is_mod { + ev.prevent_default(); + return; + } + + ev.prevent_default(); + set_prop_context_menu_position.set((client_x, client_y)); + set_prop_context_menu_target.set(Some(prop_id)); + set_prop_context_menu_open.set(true); + set_scale_mode_initial_scale.set(prop.scale); set_prop_context_is_locked.set(prop.is_locked); set_move_mode_prop_scale.set(prop.scale); @@ -599,6 +604,7 @@ pub fn RealmSceneViewer( let is_locked = prop_context_is_locked.get(); let mut items = Vec::new(); 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() }); } if is_mod { @@ -612,8 +618,14 @@ pub fn RealmSceneViewer( }) on_select=Callback::new({ let on_prop_lock_toggle = on_prop_lock_toggle.clone(); + let on_prop_click = on_prop_click.clone(); move |action: String| { match action.as_str() { + "pick_up" => { + if let Some(prop_id) = prop_context_menu_target.get() { + on_prop_click.run(prop_id); + } + } "set_scale" => { if let Some(prop_id) = prop_context_menu_target.get() { set_scale_mode_prop_id.set(Some(prop_id)); From 5e1448171444fc01deca7fb2c16662fb469e7030aef543136db8d6965e5b80c6 Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Sat, 24 Jan 2026 00:18:54 -0600 Subject: [PATCH 2/4] fix: inventory interface and hot keys --- crates/chattyness-db/src/models.rs | 3 + crates/chattyness-db/src/queries/inventory.rs | 3 + .../src/components/inventory.rs | 240 ++++++++++++++++-- 3 files changed, 221 insertions(+), 25 deletions(-) diff --git a/crates/chattyness-db/src/models.rs b/crates/chattyness-db/src/models.rs index 3c0f37c..f4eed0e 100644 --- a/crates/chattyness-db/src/models.rs +++ b/crates/chattyness-db/src/models.rs @@ -827,12 +827,15 @@ impl std::fmt::Display for PropOrigin { #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct InventoryItem { pub id: Uuid, + /// The source prop ID (use `origin` to determine which table: Server or Realm) + pub prop_id: Option, pub prop_name: String, pub prop_asset_path: String, pub layer: Option, pub is_transferable: bool, pub is_portable: bool, pub is_droppable: bool, + /// The source of this prop (ServerLibrary, RealmLibrary, or UserUpload) pub origin: PropOrigin, pub acquired_at: DateTime, } diff --git a/crates/chattyness-db/src/queries/inventory.rs b/crates/chattyness-db/src/queries/inventory.rs index 4c9d854..82efdcc 100644 --- a/crates/chattyness-db/src/queries/inventory.rs +++ b/crates/chattyness-db/src/queries/inventory.rs @@ -15,6 +15,7 @@ pub async fn list_user_inventory<'e>( r#" SELECT id, + COALESCE(server_prop_id, realm_prop_id) as prop_id, prop_name, prop_asset_path, layer, @@ -271,6 +272,7 @@ pub async fn acquire_server_prop<'e>( AND oc.is_claimed = false RETURNING id, + server_prop_id as prop_id, prop_name, prop_asset_path, layer, @@ -447,6 +449,7 @@ pub async fn acquire_realm_prop<'e>( AND oc.is_claimed = false RETURNING id, + realm_prop_id as prop_id, prop_name, prop_asset_path, layer, diff --git a/crates/chattyness-user-ui/src/components/inventory.rs b/crates/chattyness-user-ui/src/components/inventory.rs index ff18d4f..9157aaf 100644 --- a/crates/chattyness-user-ui/src/components/inventory.rs +++ b/crates/chattyness-user-ui/src/components/inventory.rs @@ -217,6 +217,33 @@ pub fn InventoryPopup( }); } + // Helper to update ownership status in server/realm props when an item is removed + let update_prop_ownership = move |item: &InventoryItem| { + use chattyness_db::models::PropOrigin; + + if let Some(prop_id) = item.prop_id { + match item.origin { + PropOrigin::ServerLibrary => { + set_server_props.update(|props| { + if let Some(prop) = props.iter_mut().find(|p| p.id == prop_id) { + prop.user_owns = false; + } + }); + } + PropOrigin::RealmLibrary => { + set_realm_props.update(|props| { + if let Some(prop) = props.iter_mut().find(|p| p.id == prop_id) { + prop.user_owns = false; + } + }); + } + PropOrigin::UserUpload => { + // User uploads don't appear in server/realm catalogs + } + } + } + }; + // Handle drop action via WebSocket #[cfg(feature = "hydrate")] let handle_drop = { @@ -229,11 +256,28 @@ pub fn InventoryPopup( send_fn(ClientMessage::DropProp { inventory_item_id: item_id, }); + // Find item index and update ownership before removing + let current_items = items.get(); + let removed_index = current_items.iter().position(|i| i.id == item_id); + if let Some(item) = current_items.iter().find(|i| i.id == item_id).cloned() { + update_prop_ownership(&item); + } // Optimistically remove from local list set_items.update(|items| { items.retain(|i| i.id != item_id); }); - set_selected_item.set(None); + // Select the next item (or previous if at end) + let updated_items = items.get(); + if let Some(idx) = removed_index { + if !updated_items.is_empty() { + let next_idx = idx.min(updated_items.len() - 1); + set_selected_item.set(Some(updated_items[next_idx].id)); + } else { + set_selected_item.set(None); + } + } else { + set_selected_item.set(None); + } } else { set_error.set(Some("Not connected to server".to_string())); } @@ -256,11 +300,28 @@ pub fn InventoryPopup( send_fn(ClientMessage::DeleteProp { inventory_item_id: item_id, }); + // Find item index and update ownership before removing + let current_items = items.get(); + let removed_index = current_items.iter().position(|i| i.id == item_id); + if let Some(item) = current_items.iter().find(|i| i.id == item_id).cloned() { + update_prop_ownership(&item); + } // Optimistically remove from local list set_items.update(|items| { items.retain(|i| i.id != item_id); }); - set_selected_item.set(None); + // Select the next item (or previous if at end) + let updated_items = items.get(); + if let Some(idx) = removed_index { + if !updated_items.is_empty() { + let next_idx = idx.min(updated_items.len() - 1); + set_selected_item.set(Some(updated_items[next_idx].id)); + } else { + set_selected_item.set(None); + } + } else { + set_selected_item.set(None); + } set_delete_confirm_item.set(None); } else { set_error.set(Some("Not connected to server".to_string())); @@ -310,6 +371,7 @@ pub fn InventoryPopup( on_delete_request=Callback::new(move |(id, name)| { set_delete_confirm_item.set(Some((id, name))); }) + on_delete_immediate=Callback::new(handle_delete) /> @@ -325,7 +387,7 @@ pub fn InventoryPopup( is_guest=is_guest acquire_endpoint="/api/server/inventory/request" on_acquired=Callback::new(move |_| { - // Trigger inventory refresh and reset loaded state + // Trigger inventory refresh set_my_inventory_loaded.set(false); set_inventory_refresh_trigger.update(|n| *n += 1); }) @@ -345,7 +407,7 @@ pub fn InventoryPopup( acquire_endpoint_is_realm=true realm_slug=realm_slug on_acquired=Callback::new(move |_| { - // Trigger inventory refresh and reset loaded state + // Trigger inventory refresh set_my_inventory_loaded.set(false); set_inventory_refresh_trigger.update(|n| *n += 1); }) @@ -393,24 +455,95 @@ fn MyInventoryTab( #[prop(into)] on_drop: Callback, #[prop(into)] deleting: Signal, #[prop(into)] on_delete_request: Callback<(Uuid, String)>, + /// Callback for immediate delete (Shift+Delete, no confirmation) + #[prop(into)] on_delete_immediate: Callback, ) -> impl IntoView { + // NodeRef to maintain focus on container after item removal + let container_ref = NodeRef::::new(); + + // Refocus container when selected item changes (after drop/delete) + #[cfg(feature = "hydrate")] + Effect::new(move |prev_selected: Option>| { + let current = selected_item.get(); + // If selection changed and we have a new selection, refocus container + if prev_selected.is_some() && current.is_some() { + if let Some(el) = container_ref.get() { + let _ = el.focus(); + } + } + current + }); + + // Keyboard shortcut handler + let handle_keydown = move |ev: leptos::web_sys::KeyboardEvent| { + // Don't handle if in an input field + #[cfg(feature = "hydrate")] + { + use wasm_bindgen::JsCast; + if let Some(target) = ev.target() { + if let Ok(element) = target.dyn_into::() { + let tag = element.tag_name().to_lowercase(); + if tag == "input" || tag == "textarea" { + return; + } + } + } + } + + // Get selected item info + let Some(item_id) = selected_item.get() else { return }; + let Some(item) = items.get().into_iter().find(|i| i.id == item_id) else { return }; + + // Only allow actions on droppable items + if !item.is_droppable { + return; + } + + let key = ev.key(); + let shift = ev.shift_key(); + + match key.as_str() { + "d" | "D" => { + ev.prevent_default(); + on_drop.run(item_id); + } + "Delete" => { + ev.prevent_default(); + if shift { + // Shift+Delete: immediate delete without confirmation + on_delete_immediate.run(item_id); + } else { + // Delete: delete with confirmation + on_delete_request.run((item_id, item.prop_name.clone())); + } + } + _ => {} + } + }; + view! { - // Loading state - -
-

"Loading inventory..."

-
-
+
+ // Loading state + +
+

"Loading inventory..."

+
+
- // Error state - -
-

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

-
-
+ // Error state + +
+

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

+
+
- // Empty state - + // Empty state +
"D""rop" }.into_any() + }} // Delete button - only shown for droppable props @@ -546,6 +683,7 @@ fn MyInventoryTab( }}
+
} } @@ -614,12 +752,37 @@ fn AcquisitionPropsTab( match response { Ok(resp) if resp.ok() => { - // Update local state to mark prop as owned + // Find the index of the acquired prop before updating + let current_props = props.get(); + let acquired_index = current_props.iter().position(|p| p.id == prop_id); + + // Update local state to mark prop as owned (shows green border) set_props.update(|props| { if let Some(prop) = props.iter_mut().find(|p| p.id == prop_id) { prop.user_owns = true; } }); + + // Select the next available (non-owned) prop + let updated_props = props.get(); + if let Some(idx) = acquired_index { + // Look for next non-owned prop starting from current position + let next_available = updated_props.iter() + .skip(idx + 1) + .find(|p| !p.user_owns && p.is_available && !(p.is_unique && p.is_claimed)) + .or_else(|| { + // If none after, look from the beginning + updated_props.iter() + .take(idx) + .find(|p| !p.user_owns && p.is_available && !(p.is_unique && p.is_claimed)) + }); + + if let Some(next_prop) = next_available { + set_selected_prop.set(Some(next_prop.id)); + } + // If no more available props, keep current selection (now shows as owned) + } + // Notify parent to refresh inventory on_acquired.run(()); } @@ -647,7 +810,31 @@ fn AcquisitionPropsTab( } }); + // Keyboard shortcut handler for Enter to acquire + let handle_keydown = { + let do_acquire = do_acquire.clone(); + move |ev: leptos::web_sys::KeyboardEvent| { + let key = ev.key(); + if key == "Enter" { + // Get selected prop info + let Some(prop_id) = selected_prop.get() else { return }; + let Some(prop) = props.get().into_iter().find(|p| p.id == prop_id) else { return }; + + // Only acquire if not already owned, not claimed (if unique), available, and not a guest + if !is_guest.get() && !prop.user_owns && prop.is_available && !(prop.is_unique && prop.is_claimed) { + ev.prevent_default(); + do_acquire.run(prop_id); + } + } + } + }; + view! { +
// Loading state
@@ -703,18 +890,20 @@ fn AcquisitionPropsTab( format!("/static/{}", prop.asset_path) }; - // Visual indicator for ownership status - let user_owns = prop.user_owns; + // Reactive lookup for ownership status (updates when props signal changes) + let user_owns = move || { + props.get().iter().find(|p| p.id == prop_id).map(|p| p.user_owns).unwrap_or(false) + }; let is_claimed = prop.is_claimed; view! { @@ -797,6 +986,7 @@ fn AcquisitionPropsTab( }}
+
} } From 475d1ef90a813aa1f28e62acc2ce6254b87d7e7be888024bb745e723c4319377 Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Sat, 24 Jan 2026 00:55:59 -0600 Subject: [PATCH 3/4] fix: all remaining bugs with props --- .../chattyness-db/src/queries/loose_props.rs | 29 +++++++++++- crates/chattyness-db/src/ws_messages.rs | 14 ++++++ .../chattyness-user-ui/src/api/websocket.rs | 45 +++++++++++++++++++ .../src/components/loose_prop_canvas.rs | 11 +---- .../src/components/scene_viewer.rs | 17 ++++++- .../src/components/scene_viewer/overlays.rs | 8 ++-- .../src/components/ws_client.rs | 4 ++ crates/chattyness-user-ui/src/pages/realm.rs | 11 +++++ 8 files changed, 123 insertions(+), 16 deletions(-) diff --git a/crates/chattyness-db/src/queries/loose_props.rs b/crates/chattyness-db/src/queries/loose_props.rs index 70eb3cb..b103937 100644 --- a/crates/chattyness-db/src/queries/loose_props.rs +++ b/crates/chattyness-db/src/queries/loose_props.rs @@ -311,17 +311,18 @@ pub async fn pick_up_loose_prop<'e>( '[]'::jsonb, now() FROM source_info si - RETURNING id, prop_name, prop_asset_path, layer, is_transferable, is_portable, is_droppable, 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 ) SELECT ii.id, + COALESCE(ii.server_prop_id, ii.realm_prop_id) as prop_id, ii.prop_name, ii.prop_asset_path, ii.layer, ii.is_transferable, ii.is_portable, ii.is_droppable, - 'server_library'::server.prop_origin as origin, + ii.origin, ii.acquired_at FROM inserted_item ii "#, @@ -620,3 +621,27 @@ pub async fn cleanup_expired_props<'e>(executor: impl PgExecutor<'e>) -> Result< Ok(result.rows_affected()) } + +/// Delete a loose prop from the scene (moderator action). +/// +/// This permanently removes the prop without adding it to anyone's inventory. +pub async fn delete_loose_prop<'e>( + executor: impl PgExecutor<'e>, + loose_prop_id: Uuid, +) -> Result<(), AppError> { + let result = sqlx::query( + r#" + DELETE FROM scene.loose_props + WHERE id = $1 + "#, + ) + .bind(loose_prop_id) + .execute(executor) + .await?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound("Loose prop not found".to_string())); + } + + Ok(()) +} diff --git a/crates/chattyness-db/src/ws_messages.rs b/crates/chattyness-db/src/ws_messages.rs index 5b7bc83..821209b 100644 --- a/crates/chattyness-db/src/ws_messages.rs +++ b/crates/chattyness-db/src/ws_messages.rs @@ -140,6 +140,12 @@ pub enum ClientMessage { /// Inventory item ID to delete. inventory_item_id: Uuid, }, + + /// Delete a loose prop from the scene (moderator only). + DeleteLooseProp { + /// The loose prop ID to delete. + loose_prop_id: Uuid, + }, } /// Server-to-client WebSocket messages. @@ -263,6 +269,14 @@ pub enum ServerMessage { inventory_item_id: Uuid, }, + /// A loose prop was deleted from the scene (by a moderator). + LoosePropDeleted { + /// ID of the deleted loose prop. + prop_id: Uuid, + /// User ID who deleted it. + deleted_by_user_id: Uuid, + }, + /// A prop was updated (scale changed) - clients should update their local copy. PropRefresh { /// The updated prop with all current values. diff --git a/crates/chattyness-user-ui/src/api/websocket.rs b/crates/chattyness-user-ui/src/api/websocket.rs index c053192..270886a 100644 --- a/crates/chattyness-user-ui/src/api/websocket.rs +++ b/crates/chattyness-user-ui/src/api/websocket.rs @@ -1658,6 +1658,51 @@ async fn handle_socket( } } } + ClientMessage::DeleteLooseProp { loose_prop_id } => { + // 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::Error { + code: "NOT_MODERATOR".to_string(), + message: "You do not have permission to delete props".to_string(), + }).await; + continue; + } + + // Delete the prop + match loose_props::delete_loose_prop( + &mut *recv_conn, + loose_prop_id, + ).await { + Ok(()) => { + #[cfg(debug_assertions)] + tracing::debug!( + "[WS] User {} deleted prop {}", + user_id, + loose_prop_id + ); + // Broadcast the deletion to all users in the channel + let _ = tx.send(ServerMessage::LoosePropDeleted { + prop_id: loose_prop_id, + deleted_by_user_id: user_id, + }); + } + Err(e) => { + tracing::error!("[WS] Delete prop failed: {:?}", e); + let _ = direct_tx.send(ServerMessage::Error { + code: "DELETE_PROP_FAILED".to_string(), + message: format!("{:?}", e), + }).await; + } + } + } } } Message::Close(close_frame) => { diff --git a/crates/chattyness-user-ui/src/components/loose_prop_canvas.rs b/crates/chattyness-user-ui/src/components/loose_prop_canvas.rs index f93c00b..536b00b 100644 --- a/crates/chattyness-user-ui/src/components/loose_prop_canvas.rs +++ b/crates/chattyness-user-ui/src/components/loose_prop_canvas.rs @@ -65,13 +65,6 @@ pub fn LoosePropCanvas( let canvas_x = screen_x - prop_size / 2.0; let canvas_y = screen_y - prop_size / 2.0; - // Add amber dashed border for locked props - let border_style = if p.is_locked { - "border: 2px dashed #f59e0b; box-sizing: border-box;" - } else { - "" - }; - format!( "position: absolute; \ left: 0; top: 0; \ @@ -79,8 +72,8 @@ pub fn LoosePropCanvas( z-index: {}; \ pointer-events: auto; \ width: {}px; \ - height: {}px; {}", - canvas_x, canvas_y, z_index, prop_size, prop_size, border_style + height: {}px;", + canvas_x, canvas_y, z_index, prop_size, prop_size ) }; diff --git a/crates/chattyness-user-ui/src/components/scene_viewer.rs b/crates/chattyness-user-ui/src/components/scene_viewer.rs index 211c964..a69feae 100644 --- a/crates/chattyness-user-ui/src/components/scene_viewer.rs +++ b/crates/chattyness-user-ui/src/components/scene_viewer.rs @@ -61,6 +61,7 @@ pub fn RealmSceneViewer( #[prop(optional, into)] on_prop_scale_update: Option>, #[prop(optional, into)] on_prop_move: Option>, #[prop(optional, into)] on_prop_lock_toggle: Option>, + #[prop(optional, into)] on_prop_delete: Option>, ) -> impl IntoView { let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false)); let settings = settings.unwrap_or_else(|| Signal::derive(ViewerSettings::default)); @@ -131,6 +132,7 @@ pub fn RealmSceneViewer( let move_mode_preview_position = RwSignal::new((0.0_f64, 0.0_f64)); let (move_mode_prop_scale, set_move_mode_prop_scale) = signal(1.0_f32); let (prop_context_is_locked, set_prop_context_is_locked) = signal(false); + let (prop_context_name, set_prop_context_name) = signal(Option::::None); // Click handler for movement (props are now handled via context menu) #[cfg(feature = "hydrate")] @@ -221,6 +223,7 @@ pub fn RealmSceneViewer( set_scale_mode_initial_scale.set(prop.scale); set_prop_context_is_locked.set(prop.is_locked); + set_prop_context_name.set(Some(prop.prop_name.clone())); set_move_mode_prop_scale.set(prop.scale); let rect = canvas.get_bounding_client_rect(); set_scale_mode_prop_center.set(( @@ -598,7 +601,9 @@ pub fn RealmSceneViewer( { @@ -649,6 +656,13 @@ pub fn RealmSceneViewer( } } } + "delete" => { + if let Some(prop_id) = prop_context_menu_target.get() { + if let Some(ref callback) = on_prop_delete { + callback.run(prop_id); + } + } + } _ => {} } set_prop_context_menu_open.set(false); @@ -657,6 +671,7 @@ pub fn RealmSceneViewer( on_close=Callback::new(move |_: ()| { set_prop_context_menu_open.set(false); set_prop_context_menu_target.set(None); + set_prop_context_name.set(None); }) /> { + // Treat deleted props the same as picked up (remove from display) + PostAction::PropPickedUp(prop_id) + } ServerMessage::PropRefresh { prop } => { PostAction::PropRefresh(prop) } diff --git a/crates/chattyness-user-ui/src/pages/realm.rs b/crates/chattyness-user-ui/src/pages/realm.rs index 5686a4f..35125d5 100644 --- a/crates/chattyness-user-ui/src/pages/realm.rs +++ b/crates/chattyness-user-ui/src/pages/realm.rs @@ -1244,6 +1244,16 @@ pub fn RealmPage() -> impl IntoView { } }); }); + #[cfg(feature = "hydrate")] + let ws_for_prop_delete = ws_sender_clone.clone(); + let on_prop_delete_cb = Callback::new(move |prop_id: Uuid| { + #[cfg(feature = "hydrate")] + ws_for_prop_delete.with_value(|sender| { + if let Some(send_fn) = sender { + send_fn(ClientMessage::DeleteLooseProp { loose_prop_id: prop_id }); + } + }); + }); view! {
impl IntoView { on_prop_scale_update=on_prop_scale_update_cb on_prop_move=on_prop_move_cb on_prop_lock_toggle=on_prop_lock_toggle_cb + on_prop_delete=on_prop_delete_cb />
Date: Sat, 24 Jan 2026 01:42:52 -0600 Subject: [PATCH 4/4] cleanup: make the modeling of props better --- crates/chattyness-db/src/models.rs | 118 +++++++++++++++++- .../chattyness-db/src/queries/loose_props.rs | 40 +++--- 2 files changed, 139 insertions(+), 19 deletions(-) diff --git a/crates/chattyness-db/src/models.rs b/crates/chattyness-db/src/models.rs index f4eed0e..ee1688e 100644 --- a/crates/chattyness-db/src/models.rs +++ b/crates/chattyness-db/src/models.rs @@ -822,6 +822,50 @@ impl std::fmt::Display for PropOrigin { } } +/// Source of a prop, combining origin type and ID. +/// +/// This replaces the pattern of two nullable UUID columns (server_prop_id, realm_prop_id) +/// with a single enum that encodes both the source type and ID. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PropSource { + /// Prop from the server-wide library (server.props) + Server(Uuid), + /// Prop from a realm-specific library (realm.props) + Realm(Uuid), + /// Prop uploaded by a user (auth.uploads) - future use + Upload(Uuid), +} + +impl PropSource { + /// Extract the UUID from any variant. + pub fn id(&self) -> Uuid { + match self { + PropSource::Server(id) | PropSource::Realm(id) | PropSource::Upload(id) => *id, + } + } +} + +impl std::fmt::Display for PropSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PropSource::Server(id) => write!(f, "server:{}", id), + PropSource::Realm(id) => write!(f, "realm:{}", id), + PropSource::Upload(id) => write!(f, "upload:{}", id), + } + } +} + +impl From<&PropSource> for PropOrigin { + fn from(source: &PropSource) -> Self { + match source { + PropSource::Server(_) => PropOrigin::ServerLibrary, + PropSource::Realm(_) => PropOrigin::RealmLibrary, + PropSource::Upload(_) => PropOrigin::UserUpload, + } + } +} + /// An inventory item (user-owned prop). #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] @@ -840,6 +884,19 @@ pub struct InventoryItem { pub acquired_at: DateTime, } +impl InventoryItem { + /// Get the prop source combining `prop_id` and `origin` into a `PropSource`. + /// + /// Returns `None` if `prop_id` is `None` (shouldn't happen in practice). + pub fn prop_source(&self) -> Option { + self.prop_id.map(|id| match self.origin { + PropOrigin::ServerLibrary => PropSource::Server(id), + PropOrigin::RealmLibrary => PropSource::Realm(id), + PropOrigin::UserUpload => PropSource::Upload(id), + }) + } +} + /// Response for inventory list. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InventoryResponse { @@ -882,16 +939,69 @@ pub struct PropAcquisitionListResponse { pub props: Vec, } -/// A prop dropped in a channel, available for pickup. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] -pub struct LooseProp { +/// Intermediate row type for database queries returning loose props. +/// +/// This maps directly to database columns (with nullable server_prop_id/realm_prop_id) +/// and is converted to `LooseProp` with a `PropSource` enum. +#[cfg(feature = "ssr")] +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct LoosePropRow { pub id: Uuid, pub channel_id: Uuid, pub server_prop_id: Option, pub realm_prop_id: Option, pub position_x: f64, pub position_y: f64, + pub scale: f32, + pub dropped_by: Option, + pub expires_at: Option>, + pub created_at: DateTime, + pub prop_name: String, + pub prop_asset_path: String, + pub is_locked: bool, + pub locked_by: Option, +} + +#[cfg(feature = "ssr")] +impl From for LooseProp { + fn from(row: LoosePropRow) -> Self { + let source = if let Some(id) = row.server_prop_id { + PropSource::Server(id) + } else if let Some(id) = row.realm_prop_id { + PropSource::Realm(id) + } else { + // Database CHECK constraint ensures exactly one is set, + // but we need a fallback. This should never happen. + panic!("LoosePropRow has neither server_prop_id nor realm_prop_id set") + }; + + LooseProp { + id: row.id, + channel_id: row.channel_id, + source, + position_x: row.position_x, + position_y: row.position_y, + scale: row.scale, + dropped_by: row.dropped_by, + expires_at: row.expires_at, + created_at: row.created_at, + prop_name: row.prop_name, + prop_asset_path: row.prop_asset_path, + is_locked: row.is_locked, + locked_by: row.locked_by, + } + } +} + +/// A prop dropped in a channel, available for pickup. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LooseProp { + pub id: Uuid, + pub channel_id: Uuid, + /// The source of this prop (server library, realm library, or user upload). + pub source: PropSource, + pub position_x: f64, + pub position_y: f64, /// Scale factor (0.1 - 10.0) inherited from prop definition at drop time. pub scale: f32, pub dropped_by: Option, diff --git a/crates/chattyness-db/src/queries/loose_props.rs b/crates/chattyness-db/src/queries/loose_props.rs index b103937..0d94981 100644 --- a/crates/chattyness-db/src/queries/loose_props.rs +++ b/crates/chattyness-db/src/queries/loose_props.rs @@ -5,7 +5,7 @@ use sqlx::PgExecutor; use uuid::Uuid; -use crate::models::{InventoryItem, LooseProp}; +use crate::models::{InventoryItem, LooseProp, LoosePropRow, PropSource}; use chattyness_error::{AppError, OptionExt}; /// Ensure an instance exists for a scene. @@ -37,7 +37,7 @@ pub async fn list_channel_loose_props<'e>( executor: impl PgExecutor<'e>, channel_id: Uuid, ) -> Result, AppError> { - let props = sqlx::query_as::<_, LooseProp>( + let rows = sqlx::query_as::<_, LoosePropRow>( r#" SELECT lp.id, @@ -66,7 +66,7 @@ pub async fn list_channel_loose_props<'e>( .fetch_all(executor) .await?; - Ok(props) + Ok(rows.into_iter().map(LooseProp::from).collect()) } /// Drop a prop from inventory to the canvas. @@ -224,12 +224,22 @@ pub async fn drop_prop_to_canvas<'e>( Some(prop_name), Some(prop_asset_path), )) => { + // Construct PropSource from the nullable columns + let source = if let Some(sid) = server_prop_id { + PropSource::Server(sid) + } else if let Some(rid) = realm_prop_id { + PropSource::Realm(rid) + } else { + return Err(AppError::Internal( + "Dropped prop has neither server_prop_id nor realm_prop_id".to_string(), + )); + }; + // Success! Convert f32 positions to f64. Ok(LooseProp { id, channel_id, - server_prop_id, - realm_prop_id, + source, position_x: position_x.into(), position_y: position_y.into(), scale, @@ -352,7 +362,7 @@ pub async fn update_loose_prop_scale<'e>( )); } - let prop = sqlx::query_as::<_, LooseProp>( + let row = sqlx::query_as::<_, LoosePropRow>( r#" WITH updated AS ( UPDATE scene.loose_props @@ -399,7 +409,7 @@ pub async fn update_loose_prop_scale<'e>( .await? .or_not_found("Loose prop (may have expired)")?; - Ok(prop) + Ok(LooseProp::from(row)) } /// Get a loose prop by ID. @@ -407,7 +417,7 @@ pub async fn get_loose_prop_by_id<'e>( executor: impl PgExecutor<'e>, loose_prop_id: Uuid, ) -> Result, AppError> { - let prop = sqlx::query_as::<_, LooseProp>( + let row = sqlx::query_as::<_, LoosePropRow>( r#" SELECT lp.id, @@ -435,7 +445,7 @@ pub async fn get_loose_prop_by_id<'e>( .fetch_optional(executor) .await?; - Ok(prop) + Ok(row.map(LooseProp::from)) } /// Move a loose prop to a new position. @@ -445,7 +455,7 @@ pub async fn move_loose_prop<'e>( x: f64, y: f64, ) -> Result { - let prop = sqlx::query_as::<_, LooseProp>( + let row = sqlx::query_as::<_, LoosePropRow>( r#" WITH updated AS ( UPDATE scene.loose_props @@ -493,7 +503,7 @@ pub async fn move_loose_prop<'e>( .await? .or_not_found("Loose prop (may have expired)")?; - Ok(prop) + Ok(LooseProp::from(row)) } /// Lock a loose prop (moderator only). @@ -502,7 +512,7 @@ pub async fn lock_loose_prop<'e>( loose_prop_id: Uuid, locked_by: Uuid, ) -> Result { - let prop = sqlx::query_as::<_, LooseProp>( + let row = sqlx::query_as::<_, LoosePropRow>( r#" WITH updated AS ( UPDATE scene.loose_props @@ -549,7 +559,7 @@ pub async fn lock_loose_prop<'e>( .await? .or_not_found("Loose prop (may have expired)")?; - Ok(prop) + Ok(LooseProp::from(row)) } /// Unlock a loose prop (moderator only). @@ -557,7 +567,7 @@ pub async fn unlock_loose_prop<'e>( executor: impl PgExecutor<'e>, loose_prop_id: Uuid, ) -> Result { - let prop = sqlx::query_as::<_, LooseProp>( + let row = sqlx::query_as::<_, LoosePropRow>( r#" WITH updated AS ( UPDATE scene.loose_props @@ -603,7 +613,7 @@ pub async fn unlock_loose_prop<'e>( .await? .or_not_found("Loose prop (may have expired)")?; - Ok(prop) + Ok(LooseProp::from(row)) } /// Delete expired loose props.