From 710985638fb07b5495259428f7fdb8773ac990abf18dbe42ac981e23e83791e9 Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Sun, 25 Jan 2026 10:50:10 -0600 Subject: [PATCH] feat: profiles and `/set profile`, and id cards * New functionality to set meta data on businesscards. * Can develop a user profile. * Business cards link to user profile. --- Cargo.toml | 3 + apps/chattyness-app/src/app.rs | 4 +- crates/chattyness-admin-ui/src/api/config.rs | 11 +- crates/chattyness-admin-ui/src/api/routes.rs | 6 +- crates/chattyness-db/Cargo.toml | 1 + crates/chattyness-db/src/models.rs | 502 +++++- .../src/queries/channel_members.rs | 2 + crates/chattyness-db/src/queries/inventory.rs | 140 +- .../chattyness-db/src/queries/loose_props.rs | 489 ++++- .../chattyness-db/src/queries/owner/config.rs | 33 +- crates/chattyness-db/src/queries/users.rs | 458 +++++ crates/chattyness-db/src/ws_messages.rs | 10 + crates/chattyness-user-ui/src/api.rs | 1 + crates/chattyness-user-ui/src/api/profile.rs | 286 +++ crates/chattyness-user-ui/src/api/routes.rs | 31 +- .../chattyness-user-ui/src/api/websocket.rs | 101 +- .../chattyness-user-ui/src/components/chat.rs | 72 + .../src/components/forms.rs | 438 +++++ .../src/components/inventory.rs | 285 +-- .../src/components/modals.rs | 184 +- .../src/components/scene_viewer.rs | 35 +- .../src/components/ws_client.rs | 5 + crates/chattyness-user-ui/src/pages.rs | 4 + .../chattyness-user-ui/src/pages/profile.rs | 1605 +++++++++++++++++ crates/chattyness-user-ui/src/pages/realm.rs | 32 +- .../src/pages/user_profile.rs | 339 ++++ crates/chattyness-user-ui/src/routes.rs | 4 +- db/schema/policies/001_rls.sql | 52 + db/schema/tables/020_auth.sql | 76 + db/schema/tables/045_scene.sql | 10 +- db/schema/triggers/001_updated_at.sql | 8 + db/schema/types/001_enums.sql | 57 + db/schema/types/002_domains.sql | 25 + stock/index.html | 28 +- stock/props/misc-businesscard.svg | 30 - 35 files changed, 4932 insertions(+), 435 deletions(-) create mode 100644 crates/chattyness-user-ui/src/api/profile.rs create mode 100644 crates/chattyness-user-ui/src/pages/profile.rs create mode 100644 crates/chattyness-user-ui/src/pages/user_profile.rs delete mode 100644 stock/props/misc-businesscard.svg diff --git a/Cargo.toml b/Cargo.toml index d0c600a..7e97cf3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,9 @@ serde_json = "1" # Error handling thiserror = "2" +# Enum utilities +strum = { version = "0.26", features = ["derive"] } + # CLI clap = { version = "4", features = ["derive", "env"] } diff --git a/apps/chattyness-app/src/app.rs b/apps/chattyness-app/src/app.rs index 73b8f73..e7ea3b9 100644 --- a/apps/chattyness-app/src/app.rs +++ b/apps/chattyness-app/src/app.rs @@ -11,7 +11,7 @@ use leptos_router::{ }; // Re-export user pages for inline route definitions -use chattyness_user_ui::pages::{HomePage, LoginPage, PasswordResetPage, RealmPage, SignupPage}; +use chattyness_user_ui::pages::{HomePage, LoginPage, PasswordResetPage, ProfilePage, RealmPage, SignupPage, UserProfilePage}; // Lazy-load admin pages to split WASM bundle // Each lazy function includes the admin CSS stylesheet for on-demand loading @@ -257,7 +257,9 @@ pub fn CombinedApp() -> impl IntoView { + + , + Json(req): Json, +) -> Result, AppError> { + queries::update_server_default_avatars(&pool, &req).await?; + Ok(Json(())) +} diff --git a/crates/chattyness-admin-ui/src/api/routes.rs b/crates/chattyness-admin-ui/src/api/routes.rs index a305074..d629dc6 100644 --- a/crates/chattyness-admin-ui/src/api/routes.rs +++ b/crates/chattyness-admin-ui/src/api/routes.rs @@ -2,7 +2,7 @@ use axum::{ Router, - routing::{delete, get, post, put}, + routing::{delete, get, patch, post, put}, }; use super::{auth, avatars, config, dashboard, loose_props, props, realms, scenes, spots, staff, users}; @@ -26,6 +26,10 @@ pub fn admin_api_router() -> Router { "/config", get(config::get_config).put(config::update_config), ) + .route( + "/config/default-avatars", + patch(config::update_default_avatars), + ) // API - Staff .route("/staff", get(staff::list_staff).post(staff::create_staff)) .route("/staff/{user_id}", delete(staff::delete_staff)) diff --git a/crates/chattyness-db/Cargo.toml b/crates/chattyness-db/Cargo.toml index 9da525b..93211f0 100644 --- a/crates/chattyness-db/Cargo.toml +++ b/crates/chattyness-db/Cargo.toml @@ -8,6 +8,7 @@ chattyness-error = { workspace = true, optional = true } chattyness-shared = { workspace = true, optional = true } serde.workspace = true serde_json.workspace = true +strum.workspace = true uuid.workspace = true chrono.workspace = true diff --git a/crates/chattyness-db/src/models.rs b/crates/chattyness-db/src/models.rs index 5cea354..de0867d 100644 --- a/crates/chattyness-db/src/models.rs +++ b/crates/chattyness-db/src/models.rs @@ -581,6 +581,182 @@ impl std::str::FromStr for AgeCategory { } } +/// Avatar source for profile pictures. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[derive(strum::Display, strum::EnumString)] +#[strum(serialize_all = "snake_case", ascii_case_insensitive)] +#[cfg_attr(feature = "ssr", derive(sqlx::Type))] +#[cfg_attr( + feature = "ssr", + sqlx(type_name = "avatar_source", rename_all = "snake_case") +)] +#[serde(rename_all = "snake_case")] +pub enum AvatarSource { + #[default] + Local, + Discord, + Github, + GoogleScholar, + Libravatar, + Gravatar, +} + +/// Profile visibility levels. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[derive(strum::Display, strum::EnumString)] +#[strum(serialize_all = "lowercase", ascii_case_insensitive)] +#[cfg_attr(feature = "ssr", derive(sqlx::Type))] +#[cfg_attr( + feature = "ssr", + sqlx(type_name = "profile_visibility", rename_all = "lowercase") +)] +#[serde(rename_all = "lowercase")] +pub enum ProfileVisibility { + #[default] + Public, + Members, + Friends, + Private, +} + +/// Contact/social platform identifiers. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(strum::Display, strum::EnumString)] +#[strum(serialize_all = "snake_case", ascii_case_insensitive)] +#[cfg_attr(feature = "ssr", derive(sqlx::Type))] +#[cfg_attr( + feature = "ssr", + sqlx(type_name = "contact_platform", rename_all = "snake_case") +)] +#[serde(rename_all = "snake_case")] +pub enum ContactPlatform { + // Social + Discord, + Linkedin, + Facebook, + Twitter, + Instagram, + Threads, + Pinterest, + // Media + Youtube, + Spotify, + Substack, + Patreon, + Linktree, + // Development + Github, + Gitlab, + Gitea, + Codeberg, + Stackexchange, + CratesIo, + PauseCpan, + Npm, + Devpost, + Huggingface, + // Academic + GoogleScholar, + Wikidata, + WikimediaCommons, + Wikipedia, + // Other + Steam, + AmazonAuthor, + Openstreetmap, + Phone, + EmailAlt, + Website, +} + +impl ContactPlatform { + /// Get the display label for this platform. + pub fn label(&self) -> &'static str { + match self { + ContactPlatform::Discord => "Discord", + ContactPlatform::Linkedin => "LinkedIn", + ContactPlatform::Facebook => "Facebook", + ContactPlatform::Twitter => "Twitter/X", + ContactPlatform::Instagram => "Instagram", + ContactPlatform::Threads => "Threads", + ContactPlatform::Pinterest => "Pinterest", + ContactPlatform::Youtube => "YouTube", + ContactPlatform::Spotify => "Spotify", + ContactPlatform::Substack => "Substack", + ContactPlatform::Patreon => "Patreon", + ContactPlatform::Linktree => "Linktree", + ContactPlatform::Github => "GitHub", + ContactPlatform::Gitlab => "GitLab", + ContactPlatform::Gitea => "Gitea", + ContactPlatform::Codeberg => "Codeberg", + ContactPlatform::Stackexchange => "Stack Exchange", + ContactPlatform::CratesIo => "crates.io", + ContactPlatform::PauseCpan => "PAUSE/CPAN", + ContactPlatform::Npm => "npm", + ContactPlatform::Devpost => "Devpost", + ContactPlatform::Huggingface => "Hugging Face", + ContactPlatform::GoogleScholar => "Google Scholar", + ContactPlatform::Wikidata => "Wikidata", + ContactPlatform::WikimediaCommons => "Wikimedia Commons", + ContactPlatform::Wikipedia => "Wikipedia", + ContactPlatform::Steam => "Steam", + ContactPlatform::AmazonAuthor => "Amazon Author", + ContactPlatform::Openstreetmap => "OpenStreetMap", + ContactPlatform::Phone => "Phone", + ContactPlatform::EmailAlt => "Email", + ContactPlatform::Website => "Website", + } + } + + /// Get all platforms grouped by category. + pub fn grouped() -> Vec<(&'static str, Vec)> { + vec![ + ("Social", vec![ + ContactPlatform::Discord, + ContactPlatform::Linkedin, + ContactPlatform::Facebook, + ContactPlatform::Twitter, + ContactPlatform::Instagram, + ContactPlatform::Threads, + ContactPlatform::Pinterest, + ]), + ("Media", vec![ + ContactPlatform::Youtube, + ContactPlatform::Spotify, + ContactPlatform::Substack, + ContactPlatform::Patreon, + ContactPlatform::Linktree, + ]), + ("Development", vec![ + ContactPlatform::Github, + ContactPlatform::Gitlab, + ContactPlatform::Gitea, + ContactPlatform::Codeberg, + ContactPlatform::Stackexchange, + ContactPlatform::CratesIo, + ContactPlatform::PauseCpan, + ContactPlatform::Npm, + ContactPlatform::Devpost, + ContactPlatform::Huggingface, + ]), + ("Academic", vec![ + ContactPlatform::GoogleScholar, + ContactPlatform::Wikidata, + ContactPlatform::WikimediaCommons, + ContactPlatform::Wikipedia, + ]), + ("Other", vec![ + ContactPlatform::Steam, + ContactPlatform::AmazonAuthor, + ContactPlatform::Openstreetmap, + ContactPlatform::Phone, + ContactPlatform::EmailAlt, + ContactPlatform::Website, + ]), + ] + } +} + // ============================================================================= // User Models // ============================================================================= @@ -625,6 +801,256 @@ pub struct UserSummary { pub avatar_url: Option, } +/// Full user profile for editing. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] +pub struct UserProfile { + pub id: Uuid, + pub username: String, + pub email: Option, + pub phone: Option, + pub display_name: String, + pub name_first: Option, + pub name_last: Option, + pub summary: Option, + pub homepage: Option, + pub bio: Option, + pub avatar_source: AvatarSource, + pub profile_visibility: ProfileVisibility, + pub contacts_visibility: ProfileVisibility, + pub organizations_visibility: ProfileVisibility, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// Public profile view - used for displaying profile to others. +/// Fields are optional because visibility settings may hide them. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PublicProfile { + pub id: Uuid, + pub username: String, + pub email: Option, + pub phone: Option, + pub display_name: String, + pub summary: Option, + pub homepage: Option, + pub bio: Option, + pub avatar_source: AvatarSource, + pub contacts: Option>, + pub organizations: Option>, + pub member_since: DateTime, + /// Whether the viewer is the profile owner (can edit) + pub is_owner: bool, +} + +/// A user's contact/social link. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] +pub struct UserContact { + pub id: Uuid, + pub user_id: Uuid, + pub platform: ContactPlatform, + pub value: String, + pub label: Option, + pub sort_order: i16, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// A user's organization affiliation. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] +pub struct UserOrganization { + pub id: Uuid, + pub user_id: Uuid, + pub wikidata_qid: Option, + pub name: String, + pub role: Option, + pub department: Option, + pub start_date: Option, + pub end_date: Option, + pub is_current: bool, + pub sort_order: i16, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// Request to update basic profile fields. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateProfileRequest { + pub display_name: String, + pub name_first: Option, + pub name_last: Option, + pub summary: Option, + pub homepage: Option, + pub bio: Option, + pub phone: Option, +} + +#[cfg(feature = "ssr")] +impl UpdateProfileRequest { + /// Validate the update profile request. + pub fn validate(&self) -> Result<(), AppError> { + validation::validate_non_empty(&self.display_name, "Display name")?; + validation::validate_length(&self.display_name, "Display name", 1, 50)?; + if let Some(ref summary) = self.summary { + validation::validate_length(summary, "Summary", 0, 200)?; + } + if let Some(ref homepage) = self.homepage { + if !homepage.is_empty() { + validation::validate_length(homepage, "Homepage URL", 0, 500)?; + // Basic URL validation: must start with http:// or https:// + if !homepage.starts_with("http://") && !homepage.starts_with("https://") { + return Err(AppError::Validation("Homepage must be a valid URL starting with http:// or https://".to_string())); + } + } + } + if let Some(ref bio) = self.bio { + validation::validate_length(bio, "Bio", 0, 2000)?; + } + Ok(()) + } +} + +/// Request to update visibility settings. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateVisibilityRequest { + pub profile_visibility: ProfileVisibility, + pub contacts_visibility: ProfileVisibility, + pub organizations_visibility: ProfileVisibility, + pub avatar_source: AvatarSource, +} + +/// Request to create a new contact. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateContactRequest { + pub platform: ContactPlatform, + pub value: String, + pub label: Option, +} + +/// Request to update an existing contact. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateContactRequest { + pub platform: ContactPlatform, + pub value: String, + pub label: Option, + pub sort_order: i16, +} + +/// Validate contact fields (shared between create and update). +#[cfg(feature = "ssr")] +fn validate_contact_fields(value: &str, label: Option<&str>) -> Result<(), AppError> { + validation::validate_non_empty(value, "Contact value")?; + validation::validate_length(value, "Contact value", 1, 500)?; + if let Some(l) = label { + validation::validate_length(l, "Label", 0, 100)?; + } + Ok(()) +} + +#[cfg(feature = "ssr")] +impl CreateContactRequest { + /// Validate the create contact request. + pub fn validate(&self) -> Result<(), AppError> { + validate_contact_fields(&self.value, self.label.as_deref()) + } +} + +#[cfg(feature = "ssr")] +impl UpdateContactRequest { + /// Validate the update contact request. + pub fn validate(&self) -> Result<(), AppError> { + validate_contact_fields(&self.value, self.label.as_deref()) + } +} + +/// Request to create a new organization affiliation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateOrganizationRequest { + pub wikidata_qid: Option, + pub name: String, + pub role: Option, + pub department: Option, + pub start_date: Option, + pub end_date: Option, + pub is_current: bool, +} + +/// Request to update an existing organization affiliation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateOrganizationRequest { + pub wikidata_qid: Option, + pub name: String, + pub role: Option, + pub department: Option, + pub start_date: Option, + pub end_date: Option, + pub is_current: bool, + pub sort_order: i16, +} + +/// Validate organization fields (shared between create and update). +#[cfg(feature = "ssr")] +fn validate_organization_fields( + name: &str, + role: Option<&str>, + department: Option<&str>, + wikidata_qid: Option<&str>, + start_date: Option, + end_date: Option, +) -> Result<(), AppError> { + validation::validate_non_empty(name, "Organization name")?; + validation::validate_length(name, "Organization name", 1, 200)?; + if let Some(r) = role { + validation::validate_length(r, "Role", 0, 100)?; + } + if let Some(d) = department { + validation::validate_length(d, "Department", 0, 100)?; + } + if let Some(qid) = wikidata_qid { + if !qid.is_empty() && !qid.starts_with('Q') { + return Err(AppError::Validation("Wikidata QID must start with 'Q'".to_string())); + } + } + if let (Some(start), Some(end)) = (start_date, end_date) { + if start > end { + return Err(AppError::Validation("Start date must be before end date".to_string())); + } + } + Ok(()) +} + +#[cfg(feature = "ssr")] +impl CreateOrganizationRequest { + /// Validate the create organization request. + pub fn validate(&self) -> Result<(), AppError> { + validate_organization_fields( + &self.name, + self.role.as_deref(), + self.department.as_deref(), + self.wikidata_qid.as_deref(), + self.start_date, + self.end_date, + ) + } +} + +#[cfg(feature = "ssr")] +impl UpdateOrganizationRequest { + /// Validate the update organization request. + pub fn validate(&self) -> Result<(), AppError> { + validate_organization_fields( + &self.name, + self.role.as_deref(), + self.department.as_deref(), + self.wikidata_qid.as_deref(), + self.start_date, + self.end_date, + ) + } +} + // ============================================================================= // Realm Models // ============================================================================= @@ -955,16 +1381,40 @@ pub struct PrivateStateBundle { impl PropStateView { /// Detect action hint from state content. pub fn detect_action_hint(server_state: &serde_json::Value) -> Option { - use serde_json::Value; + Self::detect_action_hint_full(server_state, None, None) + } - // 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() - { + /// Detect action hint from state content, including private state. + pub fn detect_action_hint_with_private( + server_state: &serde_json::Value, + server_private_state: Option<&serde_json::Value>, + ) -> Option { + Self::detect_action_hint_full(server_state, server_private_state, None) + } + + /// Detect action hint from state content, private state, and prop name. + pub fn detect_action_hint_full( + server_state: &serde_json::Value, + server_private_state: Option<&serde_json::Value>, + prop_name: Option<&str>, + ) -> Option { + // Check for business card flag (is_owner_card: true) + if let Some(is_owner) = server_state.get("is_owner_card") { + if is_owner.as_bool() == Some(true) { + return Some(PropActionHint::BusinessCard); + } + } + + // Check for business card snapshot in private state (received card) + if let Some(private) = server_private_state { + if private.get("snapshot").is_some() { + return Some(PropActionHint::BusinessCard); + } + } + + // Check for business card name pattern (dropped cards: "Name's Business Card") + if let Some(name) = prop_name { + if name.ends_with("'s Business Card") { return Some(PropActionHint::BusinessCard); } } @@ -985,6 +1435,24 @@ impl PropStateView { } } +/// Snapshot of a user's profile captured when a business card is transferred. +/// +/// This captures the giver's profile at the moment they dropped the card, +/// so the recipient can see the giver's information even if it changes later. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BusinessCardSnapshot { + pub user_id: Uuid, + pub username: String, + pub display_name: String, + pub name_first: Option, + pub name_last: Option, + pub email: Option, + pub phone: Option, + pub summary: Option, + pub homepage: Option, + pub captured_at: DateTime, +} + /// An inventory item (user-owned prop). #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] @@ -998,6 +1466,9 @@ pub struct InventoryItem { pub is_transferable: bool, pub is_portable: bool, pub is_droppable: bool, + /// Whether this prop is unique (only one can exist in the world). + /// Unique props cannot be copied via CopyAndDropProp. + pub is_unique: bool, /// The source of this prop (ServerLibrary, RealmLibrary, or UserUpload) pub origin: PropOrigin, pub acquired_at: DateTime, @@ -2588,6 +3059,17 @@ impl UpdateServerConfigRequest { } } +/// Request to update server default avatars. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateServerDefaultAvatarsRequest { + pub default_avatar_neutral_child: Option, + pub default_avatar_neutral_adult: Option, + pub default_avatar_male_child: Option, + pub default_avatar_male_adult: Option, + pub default_avatar_female_child: Option, + pub default_avatar_female_adult: Option, +} + // ============================================================================= // Staff Models // ============================================================================= @@ -3362,6 +3844,8 @@ pub struct ChannelMemberInfo { pub user_id: Uuid, /// Display name (user's display_name) pub display_name: String, + /// Username for profile URL + pub username: String, /// X coordinate in scene space pub position_x: f64, /// Y coordinate in scene space diff --git a/crates/chattyness-db/src/queries/channel_members.rs b/crates/chattyness-db/src/queries/channel_members.rs index 877604f..bed95cc 100644 --- a/crates/chattyness-db/src/queries/channel_members.rs +++ b/crates/chattyness-db/src/queries/channel_members.rs @@ -169,6 +169,7 @@ pub async fn get_channel_members<'e>( cm.instance_id as channel_id, cm.user_id, COALESCE(u.display_name, 'Anonymous') as display_name, + COALESCE(u.username, 'anonymous') as username, ST_X(cm.position) as position_x, ST_Y(cm.position) as position_y, cm.facing_direction, @@ -206,6 +207,7 @@ pub async fn get_channel_member<'e>( cm.instance_id as channel_id, cm.user_id, COALESCE(u.display_name, 'Anonymous') as display_name, + COALESCE(u.username, 'anonymous') as username, ST_X(cm.position) as position_x, ST_Y(cm.position) as position_y, cm.facing_direction, diff --git a/crates/chattyness-db/src/queries/inventory.rs b/crates/chattyness-db/src/queries/inventory.rs index a45c91e..84fd91b 100644 --- a/crates/chattyness-db/src/queries/inventory.rs +++ b/crates/chattyness-db/src/queries/inventory.rs @@ -14,25 +14,28 @@ pub async fn list_user_inventory<'e>( let items = 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 user_id = $1 - ORDER BY acquired_at DESC + inv.id, + COALESCE(inv.server_prop_id, inv.realm_prop_id) as prop_id, + inv.prop_name, + inv.prop_asset_path, + inv.layer, + inv.is_transferable, + inv.is_portable, + inv.is_droppable, + COALESCE(sp.is_unique, rp.is_unique, false) as is_unique, + inv.origin, + inv.acquired_at, + inv.server_state, + inv.realm_state, + inv.user_state, + inv.server_private_state, + inv.realm_private_state, + inv.user_private_state + FROM auth.inventory inv + LEFT JOIN server.props sp ON inv.server_prop_id = sp.id + LEFT JOIN realm.props rp ON inv.realm_prop_id = rp.id + WHERE inv.user_id = $1 + ORDER BY inv.acquired_at DESC "#, ) .bind(user_id) @@ -205,6 +208,7 @@ pub async fn list_realm_props<'e>( /// - For unique props: checks no one owns it yet /// - For non-unique props: checks user doesn't already own it /// - Inserts into `auth.inventory` with `origin = server_library` +/// - For business cards: renames to "My Business Card" and sets `is_owner_card` flag /// /// Returns the created inventory item or an appropriate error. pub async fn acquire_server_prop<'e>( @@ -213,6 +217,7 @@ pub async fn acquire_server_prop<'e>( user_id: Uuid, ) -> Result { // Use a CTE to atomically check conditions and insert + // Business cards are renamed to "My Business Card" and marked with is_owner_card flag let result: Option = sqlx::query_as( r#" WITH prop_check AS ( @@ -228,7 +233,8 @@ pub async fn acquire_server_prop<'e>( p.is_active, p.is_public, (p.available_from IS NULL OR p.available_from <= now()) AS available_from_ok, - (p.available_until IS NULL OR p.available_until > now()) AS available_until_ok + (p.available_until IS NULL OR p.available_until > now()) AS available_until_ok, + lower(p.name) LIKE '%businesscard%' AS is_business_card FROM server.props p WHERE p.id = $1 ), @@ -268,14 +274,14 @@ pub async fn acquire_server_prop<'e>( SELECT $2, oc.id, - oc.name, + CASE WHEN oc.is_business_card THEN 'My Business Card' ELSE oc.name END, oc.asset_path, oc.default_layer, 'server_library'::server.prop_origin, oc.is_transferable, oc.is_portable, oc.is_droppable, - '{}'::jsonb, + CASE WHEN oc.is_business_card THEN '{"is_owner_card": true}'::jsonb ELSE '{}'::jsonb END, '{}'::jsonb, '{}'::jsonb, '{}'::jsonb, @@ -306,7 +312,26 @@ pub async fn acquire_server_prop<'e>( realm_private_state, user_private_state ) - SELECT * FROM inserted + SELECT + ins.id, + ins.prop_id, + ins.prop_name, + ins.prop_asset_path, + ins.layer, + ins.is_transferable, + ins.is_portable, + ins.is_droppable, + oc.is_unique, + ins.origin, + ins.acquired_at, + ins.server_state, + ins.realm_state, + ins.user_state, + ins.server_private_state, + ins.realm_private_state, + ins.user_private_state + FROM inserted ins + CROSS JOIN ownership_check oc "#, ) .bind(prop_id) @@ -400,6 +425,7 @@ pub async fn get_server_prop_acquisition_error<'e>( /// - For unique props: checks no one owns it yet /// - For non-unique props: checks user doesn't already own it /// - Inserts into `auth.inventory` with `origin = realm_library` +/// - For business cards: renames to "My Business Card" and sets `is_owner_card` flag /// /// Returns the created inventory item or an appropriate error. pub async fn acquire_realm_prop<'e>( @@ -409,6 +435,7 @@ pub async fn acquire_realm_prop<'e>( user_id: Uuid, ) -> Result { // Use a CTE to atomically check conditions and insert + // Business cards are renamed to "My Business Card" and marked with is_owner_card flag let result: Option = sqlx::query_as( r#" WITH prop_check AS ( @@ -423,7 +450,8 @@ pub async fn acquire_realm_prop<'e>( p.is_active, p.is_public, (p.available_from IS NULL OR p.available_from <= now()) AS available_from_ok, - (p.available_until IS NULL OR p.available_until > now()) AS available_until_ok + (p.available_until IS NULL OR p.available_until > now()) AS available_until_ok, + lower(p.name) LIKE '%businesscard%' AS is_business_card FROM realm.props p WHERE p.id = $1 AND p.realm_id = $2 ), @@ -463,14 +491,14 @@ pub async fn acquire_realm_prop<'e>( SELECT $3, oc.id, - oc.name, + CASE WHEN oc.is_business_card THEN 'My Business Card' ELSE oc.name END, oc.asset_path, oc.default_layer, 'realm_library'::server.prop_origin, oc.is_transferable, true, -- realm props are portable by default oc.is_droppable, - '{}'::jsonb, + CASE WHEN oc.is_business_card THEN '{"is_owner_card": true}'::jsonb ELSE '{}'::jsonb END, '{}'::jsonb, '{}'::jsonb, '{}'::jsonb, @@ -501,7 +529,26 @@ pub async fn acquire_realm_prop<'e>( realm_private_state, user_private_state ) - SELECT * FROM inserted + SELECT + ins.id, + ins.prop_id, + ins.prop_name, + ins.prop_asset_path, + ins.layer, + ins.is_transferable, + ins.is_portable, + ins.is_droppable, + oc.is_unique, + ins.origin, + ins.acquired_at, + ins.server_state, + ins.realm_state, + ins.user_state, + ins.server_private_state, + ins.realm_private_state, + ins.user_private_state + FROM inserted ins + CROSS JOIN ownership_check oc "#, ) .bind(prop_id) @@ -662,24 +709,27 @@ pub async fn get_inventory_item<'e>( 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 + inv.id, + COALESCE(inv.server_prop_id, inv.realm_prop_id) as prop_id, + inv.prop_name, + inv.prop_asset_path, + inv.layer, + inv.is_transferable, + inv.is_portable, + inv.is_droppable, + COALESCE(sp.is_unique, rp.is_unique, false) as is_unique, + inv.origin, + inv.acquired_at, + inv.server_state, + inv.realm_state, + inv.user_state, + inv.server_private_state, + inv.realm_private_state, + inv.user_private_state + FROM auth.inventory inv + LEFT JOIN server.props sp ON inv.server_prop_id = sp.id + LEFT JOIN realm.props rp ON inv.realm_prop_id = rp.id + WHERE inv.id = $1 AND inv.user_id = $2 "#, ) .bind(item_id) diff --git a/crates/chattyness-db/src/queries/loose_props.rs b/crates/chattyness-db/src/queries/loose_props.rs index 9d72d04..91c1a3c 100644 --- a/crates/chattyness-db/src/queries/loose_props.rs +++ b/crates/chattyness-db/src/queries/loose_props.rs @@ -50,7 +50,7 @@ pub async fn list_channel_loose_props<'e>( lp.dropped_by, lp.expires_at, lp.created_at, - COALESCE(sp.name, rp.name) as prop_name, + COALESCE(lp.prop_name, sp.name, rp.name) as prop_name, COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path, lp.is_locked, lp.locked_by, @@ -102,7 +102,7 @@ struct DropPropResult { /// 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). +/// For business cards: captures dropper's profile snapshot at drop time. pub async fn drop_prop_to_canvas<'e>( executor: impl PgExecutor<'e>, inventory_item_id: Uuid, @@ -115,9 +115,24 @@ pub async fn drop_prop_to_canvas<'e>( // Returns status flags plus the LooseProp data (if successful). // Includes scale inherited from the source prop's default_scale. // Transfers public state columns (server_state, realm_state, user_state). + // For business cards: builds snapshot of dropper's profile at drop time. let result: Option = sqlx::query_as( r#" - WITH item_info AS ( + WITH dropper_info AS ( + -- Get full profile for snapshot (business cards) + SELECT + id, + username, + display_name, + name_first, + name_last, + email, + phone, + summary, + homepage + FROM auth.users WHERE id = $2 + ), + item_info AS ( SELECT inv.id, inv.is_droppable, @@ -128,7 +143,8 @@ pub async fn drop_prop_to_canvas<'e>( inv.server_state, inv.realm_state, inv.user_state, - COALESCE(sp.default_scale, rp.default_scale, 1.0) as default_scale + COALESCE(sp.default_scale, rp.default_scale, 1.0) as default_scale, + (inv.server_state->>'is_owner_card')::boolean = true AS is_business_card FROM auth.inventory inv LEFT JOIN server.props sp ON inv.server_prop_id = sp.id LEFT JOIN realm.props rp ON inv.realm_prop_id = rp.id @@ -151,7 +167,9 @@ pub async fn drop_prop_to_canvas<'e>( expires_at, server_state, realm_state, - user_state + user_state, + server_private_state, + prop_name ) SELECT $3, @@ -161,10 +179,37 @@ pub async fn drop_prop_to_canvas<'e>( (SELECT default_scale FROM item_info), $2, now() + interval '30 minutes', - di.server_state, + -- Clear is_owner_card flag for business cards when dropped + CASE WHEN (SELECT is_business_card FROM item_info) + THEN di.server_state - 'is_owner_card' + ELSE di.server_state + END, di.realm_state, - di.user_state + di.user_state, + -- Build snapshot for business cards at drop time + CASE WHEN (SELECT is_business_card FROM item_info) THEN + jsonb_build_object( + 'snapshot', jsonb_build_object( + 'user_id', dr.id, + 'username', dr.username, + 'display_name', dr.display_name, + 'name_first', dr.name_first, + 'name_last', dr.name_last, + 'email', dr.email, + 'phone', dr.phone, + 'summary', dr.summary, + 'homepage', dr.homepage, + 'captured_at', now() + ) + ) + ELSE '{}'::jsonb END, + -- Rename business cards to "{dropper}'s Business Card" + CASE WHEN (SELECT is_business_card FROM item_info) + THEN dr.display_name || '''s Business Card' + ELSE di.prop_name + END FROM deleted_item di + CROSS JOIN dropper_info dr RETURNING id, instance_id as channel_id, @@ -178,7 +223,8 @@ pub async fn drop_prop_to_canvas<'e>( created_at, server_state, realm_state, - user_state + user_state, + prop_name ) SELECT EXISTS(SELECT 1 FROM item_info) AS item_existed, @@ -194,7 +240,7 @@ pub async fn drop_prop_to_canvas<'e>( ip.dropped_by, ip.expires_at, ip.created_at, - di.prop_name, + ip.prop_name, di.prop_asset_path, ip.server_state, ip.realm_state, @@ -297,44 +343,364 @@ pub async fn drop_prop_to_canvas<'e>( } } +/// Result row type for copy_and_drop_prop query. +#[derive(Debug, sqlx::FromRow)] +struct CopyDropPropResult { + item_existed: bool, + was_droppable: bool, + was_unique: 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, +} + +/// Copy a prop from inventory and drop it to the canvas (keeping original in inventory). +/// +/// Only works for props where `is_unique == false` and `is_droppable == true`. +/// Creates a new loose prop with 30-minute expiry without removing from inventory. +/// Note: Public state (server_state, realm_state, user_state) is copied to the loose prop. +/// For business cards: captures dropper's profile snapshot at drop time. +pub async fn copy_and_drop_prop<'e>( + executor: impl PgExecutor<'e>, + inventory_item_id: Uuid, + user_id: Uuid, + channel_id: Uuid, + position_x: f64, + position_y: f64, +) -> Result { + // Single CTE that checks existence/droppability/uniqueness and performs the operation atomically. + // Returns status flags plus the LooseProp data (if successful). + // Includes scale inherited from the source prop's default_scale. + // Copies public state columns (server_state, realm_state, user_state). + // For business cards: builds snapshot of dropper's profile at copy time. + let result: Option = sqlx::query_as( + r#" + WITH dropper_info AS ( + -- Get full profile for snapshot (business cards) + SELECT + id, + username, + display_name, + name_first, + name_last, + email, + phone, + summary, + homepage + FROM auth.users WHERE id = $2 + ), + item_info AS ( + SELECT + inv.id, + inv.is_droppable, + inv.server_prop_id, + 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, + COALESCE(sp.is_unique, rp.is_unique, false) as is_unique, + (inv.server_state->>'is_owner_card')::boolean = true AS is_business_card + FROM auth.inventory inv + LEFT JOIN server.props sp ON inv.server_prop_id = sp.id + LEFT JOIN realm.props rp ON inv.realm_prop_id = rp.id + WHERE inv.id = $1 AND inv.user_id = $2 + ), + inserted_prop AS ( + INSERT INTO scene.loose_props ( + instance_id, + server_prop_id, + realm_prop_id, + position, + scale, + dropped_by, + expires_at, + server_state, + realm_state, + user_state, + server_private_state, + prop_name + ) + SELECT + $3, + ii.server_prop_id, + ii.realm_prop_id, + public.make_virtual_point($4::real, $5::real), + ii.default_scale, + $2, + now() + interval '30 minutes', + -- Clear is_owner_card flag for business cards when dropped + CASE WHEN ii.is_business_card + THEN ii.server_state - 'is_owner_card' + ELSE ii.server_state + END, + ii.realm_state, + ii.user_state, + -- Build snapshot for business cards at copy time + CASE WHEN ii.is_business_card THEN + jsonb_build_object( + 'snapshot', jsonb_build_object( + 'user_id', dr.id, + 'username', dr.username, + 'display_name', dr.display_name, + 'name_first', dr.name_first, + 'name_last', dr.name_last, + 'email', dr.email, + 'phone', dr.phone, + 'summary', dr.summary, + 'homepage', dr.homepage, + 'captured_at', now() + ) + ) + ELSE '{}'::jsonb END, + -- Rename business cards to "{dropper}'s Business Card" + CASE WHEN ii.is_business_card + THEN dr.display_name || '''s Business Card' + ELSE ii.prop_name + END + FROM item_info ii + CROSS JOIN dropper_info dr + WHERE ii.is_droppable = true AND ii.is_unique = false + RETURNING + id, + instance_id as channel_id, + server_prop_id, + realm_prop_id, + ST_X(position)::real as position_x, + ST_Y(position)::real as position_y, + scale, + dropped_by, + expires_at, + created_at, + server_state, + realm_state, + user_state, + prop_name + ) + SELECT + EXISTS(SELECT 1 FROM item_info) AS item_existed, + COALESCE((SELECT is_droppable FROM item_info), false) AS was_droppable, + COALESCE((SELECT is_unique FROM item_info), true) AS was_unique, + ip.id, + ip.channel_id, + ip.server_prop_id, + ip.realm_prop_id, + ip.position_x, + ip.position_y, + ip.scale, + ip.dropped_by, + ip.expires_at, + ip.created_at, + ip.prop_name, + ii.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 item_info ii ON true + "#, + ) + .bind(inventory_item_id) + .bind(user_id) + .bind(channel_id) + .bind(position_x as f32) + .bind(position_y as f32) + .fetch_optional(executor) + .await?; + + match result { + None => { + // Query returned no rows (shouldn't happen with our dummy table) + Err(AppError::Internal( + "Unexpected error copying prop to canvas".to_string(), + )) + } + 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(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(r) if r.item_existed && r.was_unique => { + // Item is unique and cannot be copied + Err(AppError::Forbidden( + "This prop is unique and cannot be copied".to_string(), + )) + } + Some(CopyDropPropResult { + item_existed: true, + was_droppable: true, + was_unique: false, + id: Some(id), + channel_id: Some(channel_id), + server_prop_id, + realm_prop_id, + position_x: Some(position_x), + position_y: Some(position_y), + scale: Some(scale), + dropped_by, + 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) + } else if let Some(rid) = realm_prop_id { + PropSource::Realm(rid) + } else { + return Err(AppError::Internal( + "Copied prop has neither server_prop_id nor realm_prop_id".to_string(), + )); + }; + + // Success! Convert f32 positions to f64. + Ok(LooseProp { + id, + channel_id, + source, + position_x: position_x.into(), + position_y: position_y.into(), + scale, + dropped_by, + expires_at: Some(expires_at), + created_at, + prop_name, + prop_asset_path, + is_locked: false, + locked_by: None, + server_state, + realm_state, + user_state, + }) + } + _ => { + // Some fields were unexpectedly null + Err(AppError::Internal( + "Unexpected null values in copy drop prop result".to_string(), + )) + } + } +} + /// 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). +/// Note: Public state is transferred from the loose prop. +/// Private state (snapshot) is transferred from loose prop (captured at drop time). +/// +/// For business cards: +/// - Transfers snapshot that was captured when the card was dropped +/// - Updates existing card from same giver instead of creating duplicate +/// - Self-pickup: restores is_owner_card flag and clears snapshot 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 + // Simplified pickup query: + // - Snapshot was already captured at drop time, just transfer it + // - Handle self-pickup (picker == dropper): restore owner state + // - Handle deduplication for business cards from same giver 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, - server_state, realm_state, user_state + RETURNING id, server_prop_id, realm_prop_id, prop_name, + server_state, realm_state, user_state, + server_private_state, dropped_by ), source_info AS ( SELECT - COALESCE(sp.name, rp.name) as prop_name, + dp.*, + COALESCE(dp.prop_name, sp.name, rp.name) as final_prop_name, COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path, COALESCE(sp.default_layer, rp.default_layer) as layer, COALESCE(sp.is_transferable, rp.is_transferable) as is_transferable, 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.server_state, - dp.realm_state, - dp.user_state + COALESCE(sp.is_unique, rp.is_unique, false) as is_unique, + -- Detect business card by presence of snapshot + (dp.server_private_state->'snapshot') IS NOT NULL AS is_business_card, + -- Self-pickup: picker is the original dropper + dp.dropped_by = $2 AS is_self_pickup, + -- Get giver's user_id from snapshot for deduplication + (dp.server_private_state->'snapshot'->>'user_id')::uuid AS giver_id 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 ), + pickup_data AS ( + SELECT + si.*, + -- Self-pickup: restore "My Business Card" name + CASE WHEN si.is_self_pickup AND si.is_business_card + THEN 'My Business Card' + ELSE si.final_prop_name + END AS resolved_prop_name, + -- Self-pickup: restore is_owner_card flag + CASE WHEN si.is_self_pickup AND si.is_business_card + THEN si.server_state || '{"is_owner_card": true}'::jsonb + ELSE si.server_state + END AS resolved_server_state, + -- Self-pickup: clear snapshot (owner doesn't need it) + CASE WHEN si.is_self_pickup AND si.is_business_card + THEN '{}'::jsonb + ELSE si.server_private_state + END AS resolved_private_state + FROM source_info si + ), + -- Check for existing business card from same giver (not self-pickup) + existing_card AS ( + SELECT inv.id + FROM auth.inventory inv, pickup_data pd + WHERE inv.user_id = $2 + AND pd.is_business_card = true + AND NOT pd.is_self_pickup + AND pd.giver_id IS NOT NULL + AND (inv.server_private_state->'snapshot'->>'user_id')::uuid = pd.giver_id + ), + -- Update existing card if found (refresh snapshot) + updated_card AS ( + UPDATE auth.inventory inv + SET + server_private_state = pd.resolved_private_state, + acquired_at = now() + FROM pickup_data pd, existing_card ec + WHERE inv.id = ec.id + RETURNING inv.id, inv.server_prop_id, inv.realm_prop_id, inv.prop_name, inv.prop_asset_path, inv.layer, + inv.is_transferable, inv.is_portable, inv.is_droppable, inv.origin, inv.acquired_at, + inv.server_state, inv.realm_state, inv.user_state, + inv.server_private_state, inv.realm_private_state, inv.user_private_state + ), + -- Insert new item only if no existing card was updated inserted_item AS ( INSERT INTO auth.inventory ( user_id, @@ -349,60 +715,63 @@ pub async fn pick_up_loose_prop<'e>( is_droppable, provenance, 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, - si.server_prop_id, - si.realm_prop_id, - si.prop_name, - si.prop_asset_path, - si.layer, + pd.server_prop_id, + pd.realm_prop_id, + pd.resolved_prop_name, + pd.prop_asset_path, + pd.layer, 'server_library'::server.prop_origin, - COALESCE(si.is_transferable, true), - COALESCE(si.is_portable, true), - COALESCE(si.is_droppable, true), + COALESCE(pd.is_transferable, true), + COALESCE(pd.is_portable, true), + COALESCE(pd.is_droppable, true), '[]'::jsonb, now(), - -- Transfer public state - si.server_state, - si.realm_state, - si.user_state, - -- Private state cleared for new owner - '{}'::jsonb, + pd.resolved_server_state, + pd.realm_state, + pd.user_state, + pd.resolved_private_state, '{}'::jsonb, '{}'::jsonb - FROM source_info si + FROM pickup_data pd + WHERE NOT EXISTS (SELECT 1 FROM updated_card) 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, - 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, - ii.origin, - 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 + fr.id, + COALESCE(fr.server_prop_id, fr.realm_prop_id) as prop_id, + fr.prop_name, + fr.prop_asset_path, + fr.layer, + fr.is_transferable, + fr.is_portable, + fr.is_droppable, + pd.is_unique, + fr.origin, + fr.acquired_at, + fr.server_state, + fr.realm_state, + fr.user_state, + fr.server_private_state, + fr.realm_private_state, + fr.user_private_state + FROM ( + SELECT * FROM updated_card + UNION ALL + SELECT * FROM inserted_item + ) fr + CROSS JOIN pickup_data pd "#, ) .bind(loose_prop_id) @@ -465,7 +834,7 @@ pub async fn update_loose_prop_scale<'e>( u.dropped_by, u.expires_at, u.created_at, - COALESCE(sp.name, rp.name) as prop_name, + COALESCE(lp.prop_name, sp.name, rp.name) as prop_name, COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path, u.is_locked, u.locked_by, @@ -504,7 +873,7 @@ pub async fn get_loose_prop_by_id<'e>( lp.dropped_by, lp.expires_at, lp.created_at, - COALESCE(sp.name, rp.name) as prop_name, + COALESCE(lp.prop_name, sp.name, rp.name) as prop_name, COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path, lp.is_locked, lp.locked_by, @@ -567,7 +936,7 @@ pub async fn move_loose_prop<'e>( u.dropped_by, u.expires_at, u.created_at, - COALESCE(sp.name, rp.name) as prop_name, + COALESCE(lp.prop_name, sp.name, rp.name) as prop_name, COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path, u.is_locked, u.locked_by, @@ -630,7 +999,7 @@ pub async fn lock_loose_prop<'e>( u.dropped_by, u.expires_at, u.created_at, - COALESCE(sp.name, rp.name) as prop_name, + COALESCE(lp.prop_name, sp.name, rp.name) as prop_name, COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path, u.is_locked, u.locked_by, @@ -691,7 +1060,7 @@ pub async fn unlock_loose_prop<'e>( u.dropped_by, u.expires_at, u.created_at, - COALESCE(sp.name, rp.name) as prop_name, + COALESCE(lp.prop_name, sp.name, rp.name) as prop_name, COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path, u.is_locked, u.locked_by, diff --git a/crates/chattyness-db/src/queries/owner/config.rs b/crates/chattyness-db/src/queries/owner/config.rs index bac41bd..7eb50ad 100644 --- a/crates/chattyness-db/src/queries/owner/config.rs +++ b/crates/chattyness-db/src/queries/owner/config.rs @@ -5,7 +5,7 @@ use sqlx::PgPool; use uuid::Uuid; -use crate::models::{ServerConfig, UpdateServerConfigRequest}; +use crate::models::{ServerConfig, UpdateServerConfigRequest, UpdateServerDefaultAvatarsRequest}; use chattyness_error::AppError; /// The fixed UUID for the singleton server config row. @@ -91,3 +91,34 @@ pub async fn update_server_config( Ok(config) } + +/// Update server default avatars. +pub async fn update_server_default_avatars( + pool: &PgPool, + req: &UpdateServerDefaultAvatarsRequest, +) -> Result<(), AppError> { + sqlx::query( + r#" + UPDATE server.config SET + default_avatar_neutral_child = $1, + default_avatar_neutral_adult = $2, + default_avatar_male_child = $3, + default_avatar_male_adult = $4, + default_avatar_female_child = $5, + default_avatar_female_adult = $6, + updated_at = now() + WHERE id = $7 + "#, + ) + .bind(req.default_avatar_neutral_child) + .bind(req.default_avatar_neutral_adult) + .bind(req.default_avatar_male_child) + .bind(req.default_avatar_male_adult) + .bind(req.default_avatar_female_child) + .bind(req.default_avatar_female_adult) + .bind(server_config_id()) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/crates/chattyness-db/src/queries/users.rs b/crates/chattyness-db/src/queries/users.rs index 877f96a..92ea695 100644 --- a/crates/chattyness-db/src/queries/users.rs +++ b/crates/chattyness-db/src/queries/users.rs @@ -724,3 +724,461 @@ pub fn generate_guest_name() -> String { let number: u32 = rng.gen_range(10000..100000); format!("Guest_{}", number) } + +// ============================================================================= +// Profile Queries +// ============================================================================= + +use crate::models::{ + AvatarSource, BusinessCardSnapshot, CreateContactRequest, CreateOrganizationRequest, + ProfileVisibility, UpdateContactRequest, UpdateOrganizationRequest, + UpdateProfileRequest, UserContact, UserOrganization, UserProfile, +}; + +/// Get the full user profile for editing. +pub async fn get_user_profile(pool: &PgPool, user_id: Uuid) -> Result, AppError> { + let profile = sqlx::query_as::<_, UserProfile>( + r#" + SELECT + id, + username, + email, + phone, + display_name, + name_first, + name_last, + summary, + homepage, + bio, + avatar_source, + profile_visibility, + contacts_visibility, + organizations_visibility, + created_at, + updated_at + FROM auth.users + WHERE id = $1 AND status = 'active' + "#, + ) + .bind(user_id) + .fetch_optional(pool) + .await?; + + Ok(profile) +} + +/// Get a user's public profile by username. +/// Returns the full profile data - the caller is responsible for filtering +/// based on visibility settings and viewer authentication. +pub async fn get_public_profile_by_username( + pool: &PgPool, + username: &str, +) -> Result, AppError> { + let profile = sqlx::query_as::<_, UserProfile>( + r#" + SELECT + id, + username, + email, + phone, + display_name, + name_first, + name_last, + summary, + homepage, + bio, + avatar_source, + profile_visibility, + contacts_visibility, + organizations_visibility, + created_at, + updated_at + FROM auth.users + WHERE username = $1 AND status = 'active' + "#, + ) + .bind(username) + .fetch_optional(pool) + .await?; + + Ok(profile) +} + +/// Update basic profile fields using a connection (for RLS support). +pub async fn update_user_profile_conn( + conn: &mut PgConnection, + user_id: Uuid, + req: &UpdateProfileRequest, +) -> Result<(), AppError> { + sqlx::query( + r#" + UPDATE auth.users + SET + display_name = $2, + name_first = $3, + name_last = $4, + summary = $5, + homepage = $6, + bio = $7, + phone = $8, + updated_at = now() + WHERE id = $1 + "#, + ) + .bind(user_id) + .bind(&req.display_name) + .bind(&req.name_first) + .bind(&req.name_last) + .bind(&req.summary) + .bind(&req.homepage) + .bind(&req.bio) + .bind(&req.phone) + .execute(conn) + .await?; + + Ok(()) +} + +/// Update visibility settings using a connection (for RLS support). +pub async fn update_visibility_conn( + conn: &mut PgConnection, + user_id: Uuid, + profile_visibility: ProfileVisibility, + contacts_visibility: ProfileVisibility, + organizations_visibility: ProfileVisibility, + avatar_source: AvatarSource, +) -> Result<(), AppError> { + sqlx::query( + r#" + UPDATE auth.users + SET + profile_visibility = $2, + contacts_visibility = $3, + organizations_visibility = $4, + avatar_source = $5, + updated_at = now() + WHERE id = $1 + "#, + ) + .bind(user_id) + .bind(profile_visibility) + .bind(contacts_visibility) + .bind(organizations_visibility) + .bind(avatar_source) + .execute(conn) + .await?; + + Ok(()) +} + +/// Get a user's profile snapshot for use in business cards. +/// +/// Captures the essential profile fields at a point in time. +/// Returns None if the user doesn't exist or is not active. +pub async fn get_user_business_card_snapshot( + pool: &PgPool, + user_id: Uuid, +) -> Result, AppError> { + #[derive(sqlx::FromRow)] + struct SnapshotRow { + id: Uuid, + username: String, + display_name: String, + name_first: Option, + name_last: Option, + email: Option, + phone: Option, + summary: Option, + homepage: Option, + } + + let row = sqlx::query_as::<_, SnapshotRow>( + r#" + SELECT + id, + username, + display_name, + name_first, + name_last, + email, + phone, + summary, + homepage + FROM auth.users + WHERE id = $1 AND status = 'active' + "#, + ) + .bind(user_id) + .fetch_optional(pool) + .await?; + + Ok(row.map(|r| BusinessCardSnapshot { + user_id: r.id, + username: r.username, + display_name: r.display_name, + name_first: r.name_first, + name_last: r.name_last, + email: r.email, + phone: r.phone, + summary: r.summary, + homepage: r.homepage, + captured_at: chrono::Utc::now(), + })) +} + +// ============================================================================= +// Contact Queries +// ============================================================================= + +/// List all contacts for a user. +pub async fn list_user_contacts(pool: &PgPool, user_id: Uuid) -> Result, AppError> { + let contacts = sqlx::query_as::<_, UserContact>( + r#" + SELECT + id, + user_id, + platform, + value, + label, + sort_order, + created_at, + updated_at + FROM auth.user_contacts + WHERE user_id = $1 + ORDER BY sort_order ASC, created_at ASC + "#, + ) + .bind(user_id) + .fetch_all(pool) + .await?; + + Ok(contacts) +} + +/// Create a new contact using a connection (for RLS support). +pub async fn create_contact_conn( + conn: &mut PgConnection, + user_id: Uuid, + req: &CreateContactRequest, +) -> Result { + // Get the next sort order + let (max_order,): (Option,) = sqlx::query_as( + "SELECT MAX(sort_order) FROM auth.user_contacts WHERE user_id = $1", + ) + .bind(user_id) + .fetch_one(&mut *conn) + .await?; + + let sort_order = max_order.unwrap_or(0) + 1; + + let (contact_id,): (Uuid,) = sqlx::query_as( + r#" + INSERT INTO auth.user_contacts (user_id, platform, value, label, sort_order) + VALUES ($1, $2, $3, $4, $5) + RETURNING id + "#, + ) + .bind(user_id) + .bind(req.platform) + .bind(&req.value) + .bind(&req.label) + .bind(sort_order) + .fetch_one(&mut *conn) + .await?; + + Ok(contact_id) +} + +/// Update an existing contact using a connection (for RLS support). +pub async fn update_contact_conn( + conn: &mut PgConnection, + user_id: Uuid, + contact_id: Uuid, + req: &UpdateContactRequest, +) -> Result<(), AppError> { + let result = sqlx::query( + r#" + UPDATE auth.user_contacts + SET + platform = $3, + value = $4, + label = $5, + sort_order = $6, + updated_at = now() + WHERE id = $1 AND user_id = $2 + "#, + ) + .bind(contact_id) + .bind(user_id) + .bind(req.platform) + .bind(&req.value) + .bind(&req.label) + .bind(req.sort_order) + .execute(conn) + .await?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound("Contact not found".to_string())); + } + + Ok(()) +} + +/// Delete a contact using a connection (for RLS support). +pub async fn delete_contact_conn( + conn: &mut PgConnection, + user_id: Uuid, + contact_id: Uuid, +) -> Result<(), AppError> { + let result = sqlx::query( + "DELETE FROM auth.user_contacts WHERE id = $1 AND user_id = $2", + ) + .bind(contact_id) + .bind(user_id) + .execute(conn) + .await?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound("Contact not found".to_string())); + } + + Ok(()) +} + +// ============================================================================= +// Organization Queries +// ============================================================================= + +/// List all organizations for a user. +pub async fn list_user_organizations(pool: &PgPool, user_id: Uuid) -> Result, AppError> { + let organizations = sqlx::query_as::<_, UserOrganization>( + r#" + SELECT + id, + user_id, + wikidata_qid, + name, + role, + department, + start_date, + end_date, + is_current, + sort_order, + created_at, + updated_at + FROM auth.user_organizations + WHERE user_id = $1 + ORDER BY is_current DESC, sort_order ASC, start_date DESC NULLS LAST + "#, + ) + .bind(user_id) + .fetch_all(pool) + .await?; + + Ok(organizations) +} + +/// Create a new organization using a connection (for RLS support). +pub async fn create_organization_conn( + conn: &mut PgConnection, + user_id: Uuid, + req: &CreateOrganizationRequest, +) -> Result { + // Get the next sort order + let (max_order,): (Option,) = sqlx::query_as( + "SELECT MAX(sort_order) FROM auth.user_organizations WHERE user_id = $1", + ) + .bind(user_id) + .fetch_one(&mut *conn) + .await?; + + let sort_order = max_order.unwrap_or(0) + 1; + + let (org_id,): (Uuid,) = sqlx::query_as( + r#" + INSERT INTO auth.user_organizations ( + user_id, wikidata_qid, name, role, department, + start_date, end_date, is_current, sort_order + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id + "#, + ) + .bind(user_id) + .bind(&req.wikidata_qid) + .bind(&req.name) + .bind(&req.role) + .bind(&req.department) + .bind(req.start_date) + .bind(req.end_date) + .bind(req.is_current) + .bind(sort_order) + .fetch_one(&mut *conn) + .await?; + + Ok(org_id) +} + +/// Update an existing organization using a connection (for RLS support). +pub async fn update_organization_conn( + conn: &mut PgConnection, + user_id: Uuid, + org_id: Uuid, + req: &UpdateOrganizationRequest, +) -> Result<(), AppError> { + let result = sqlx::query( + r#" + UPDATE auth.user_organizations + SET + wikidata_qid = $3, + name = $4, + role = $5, + department = $6, + start_date = $7, + end_date = $8, + is_current = $9, + sort_order = $10, + updated_at = now() + WHERE id = $1 AND user_id = $2 + "#, + ) + .bind(org_id) + .bind(user_id) + .bind(&req.wikidata_qid) + .bind(&req.name) + .bind(&req.role) + .bind(&req.department) + .bind(req.start_date) + .bind(req.end_date) + .bind(req.is_current) + .bind(req.sort_order) + .execute(conn) + .await?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound("Organization not found".to_string())); + } + + Ok(()) +} + +/// Delete an organization using a connection (for RLS support). +pub async fn delete_organization_conn( + conn: &mut PgConnection, + user_id: Uuid, + org_id: Uuid, +) -> Result<(), AppError> { + let result = sqlx::query( + "DELETE FROM auth.user_organizations WHERE id = $1 AND user_id = $2", + ) + .bind(org_id) + .bind(user_id) + .execute(conn) + .await?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound("Organization not found".to_string())); + } + + Ok(()) +} diff --git a/crates/chattyness-db/src/ws_messages.rs b/crates/chattyness-db/src/ws_messages.rs index 6a27208..6945820 100644 --- a/crates/chattyness-db/src/ws_messages.rs +++ b/crates/chattyness-db/src/ws_messages.rs @@ -81,6 +81,14 @@ pub enum ClientMessage { inventory_item_id: Uuid, }, + /// Copy and drop a prop from inventory to the canvas. + /// Creates a copy on the scene while keeping the original in inventory. + /// Only works for props where `is_unique == false` and `is_droppable == true`. + CopyAndDropProp { + /// Inventory item ID to copy and drop. + inventory_item_id: Uuid, + }, + /// Pick up a loose prop from the canvas. PickUpProp { /// Loose prop ID to pick up. @@ -347,6 +355,8 @@ pub enum ServerMessage { MemberIdentityUpdated { /// User ID of the member. user_id: Uuid, + /// New username (for profile URLs). + username: String, /// New display name. display_name: String, /// Whether the member is still a guest. diff --git a/crates/chattyness-user-ui/src/api.rs b/crates/chattyness-user-ui/src/api.rs index 7bba15f..e259b8f 100644 --- a/crates/chattyness-user-ui/src/api.rs +++ b/crates/chattyness-user-ui/src/api.rs @@ -3,6 +3,7 @@ pub mod auth; pub mod avatars; pub mod inventory; +pub mod profile; pub mod realms; pub mod routes; pub mod scenes; diff --git a/crates/chattyness-user-ui/src/api/profile.rs b/crates/chattyness-user-ui/src/api/profile.rs new file mode 100644 index 0000000..1b0ceb1 --- /dev/null +++ b/crates/chattyness-user-ui/src/api/profile.rs @@ -0,0 +1,286 @@ +//! Profile API handlers. +//! +//! Endpoints for user profile management including basic info, +//! visibility settings, contacts, and organizations. + +use axum::{Json, extract::{Path, State}}; +use sqlx::PgPool; +use uuid::Uuid; + +use chattyness_db::{ + models::{ + CreateContactRequest, CreateOrganizationRequest, ProfileVisibility, PublicProfile, + UpdateContactRequest, UpdateOrganizationRequest, UpdateProfileRequest, + UpdateVisibilityRequest, UserContact, UserOrganization, UserProfile, + }, + queries::users, +}; +use chattyness_error::AppError; + +use crate::auth::{AuthUser, OptionalAuthUser, RlsConn}; + +// ============================================================================= +// Response Types +// ============================================================================= + +/// Success response for profile operations. +#[derive(Debug, serde::Serialize)] +pub struct SuccessResponse { + pub success: bool, +} + +/// Response with created resource ID. +#[derive(Debug, serde::Serialize)] +pub struct CreatedResponse { + pub id: Uuid, +} + +// ============================================================================= +// Profile Endpoints +// ============================================================================= + +/// Get the current user's profile. +pub async fn get_profile( + State(pool): State, + AuthUser(user): AuthUser, +) -> Result, AppError> { + let profile = users::get_user_profile(&pool, user.id) + .await? + .ok_or_else(|| AppError::NotFound("Profile not found".to_string()))?; + + Ok(Json(profile)) +} + +/// Update the current user's basic profile fields. +pub async fn update_profile( + rls_conn: RlsConn, + AuthUser(user): AuthUser, + Json(req): Json, +) -> Result, AppError> { + // Validate the request + req.validate()?; + + // Update profile using RLS connection + let mut conn = rls_conn.acquire().await; + users::update_user_profile_conn(&mut *conn, user.id, &req).await?; + + Ok(Json(SuccessResponse { success: true })) +} + +/// Update the current user's visibility settings. +pub async fn update_visibility( + rls_conn: RlsConn, + AuthUser(user): AuthUser, + Json(req): Json, +) -> Result, AppError> { + // Update visibility using RLS connection + let mut conn = rls_conn.acquire().await; + users::update_visibility_conn( + &mut *conn, + user.id, + req.profile_visibility, + req.contacts_visibility, + req.organizations_visibility, + req.avatar_source, + ) + .await?; + + Ok(Json(SuccessResponse { success: true })) +} + +// ============================================================================= +// Contact Endpoints +// ============================================================================= + +/// List all contacts for the current user. +pub async fn list_contacts( + State(pool): State, + AuthUser(user): AuthUser, +) -> Result>, AppError> { + let contacts = users::list_user_contacts(&pool, user.id).await?; + Ok(Json(contacts)) +} + +/// Create a new contact. +pub async fn create_contact( + rls_conn: RlsConn, + AuthUser(user): AuthUser, + Json(req): Json, +) -> Result, AppError> { + // Validate the request + req.validate()?; + + // Create contact using RLS connection + let mut conn = rls_conn.acquire().await; + let contact_id = users::create_contact_conn(&mut *conn, user.id, &req).await?; + + Ok(Json(CreatedResponse { id: contact_id })) +} + +/// Update an existing contact. +pub async fn update_contact( + rls_conn: RlsConn, + AuthUser(user): AuthUser, + Path(contact_id): Path, + Json(req): Json, +) -> Result, AppError> { + // Validate the request + req.validate()?; + + // Update contact using RLS connection + let mut conn = rls_conn.acquire().await; + users::update_contact_conn(&mut *conn, user.id, contact_id, &req).await?; + + Ok(Json(SuccessResponse { success: true })) +} + +/// Delete a contact. +pub async fn delete_contact( + rls_conn: RlsConn, + AuthUser(user): AuthUser, + Path(contact_id): Path, +) -> Result, AppError> { + // Delete contact using RLS connection + let mut conn = rls_conn.acquire().await; + users::delete_contact_conn(&mut *conn, user.id, contact_id).await?; + + Ok(Json(SuccessResponse { success: true })) +} + +// ============================================================================= +// Organization Endpoints +// ============================================================================= + +/// List all organizations for the current user. +pub async fn list_organizations( + State(pool): State, + AuthUser(user): AuthUser, +) -> Result>, AppError> { + let organizations = users::list_user_organizations(&pool, user.id).await?; + Ok(Json(organizations)) +} + +/// Create a new organization. +pub async fn create_organization( + rls_conn: RlsConn, + AuthUser(user): AuthUser, + Json(req): Json, +) -> Result, AppError> { + // Validate the request + req.validate()?; + + // Create organization using RLS connection + let mut conn = rls_conn.acquire().await; + let org_id = users::create_organization_conn(&mut *conn, user.id, &req).await?; + + Ok(Json(CreatedResponse { id: org_id })) +} + +/// Update an existing organization. +pub async fn update_organization( + rls_conn: RlsConn, + AuthUser(user): AuthUser, + Path(org_id): Path, + Json(req): Json, +) -> Result, AppError> { + // Validate the request + req.validate()?; + + // Update organization using RLS connection + let mut conn = rls_conn.acquire().await; + users::update_organization_conn(&mut *conn, user.id, org_id, &req).await?; + + Ok(Json(SuccessResponse { success: true })) +} + +/// Delete an organization. +pub async fn delete_organization( + rls_conn: RlsConn, + AuthUser(user): AuthUser, + Path(org_id): Path, +) -> Result, AppError> { + // Delete organization using RLS connection + let mut conn = rls_conn.acquire().await; + users::delete_organization_conn(&mut *conn, user.id, org_id).await?; + + Ok(Json(SuccessResponse { success: true })) +} + +// ============================================================================= +// Public Profile Endpoints +// ============================================================================= + +/// Get a user's public profile by username. +/// Visibility settings determine what data is returned: +/// - Public: visible to everyone +/// - Members: visible to logged-in users +/// - Friends: visible to user's friends (not yet implemented, treated as private) +/// - Private: visible only to the profile owner +pub async fn get_public_profile( + State(pool): State, + OptionalAuthUser(maybe_user): OptionalAuthUser, + Path(username): Path, +) -> Result, AppError> { + // Fetch the user profile + let profile = users::get_public_profile_by_username(&pool, &username) + .await? + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; + + let viewer_id = maybe_user.as_ref().map(|u| u.id); + let is_owner = viewer_id == Some(profile.id); + let is_authenticated = viewer_id.is_some(); + + // Check if viewer can see the profile at all + if !can_view(&profile.profile_visibility, is_owner, is_authenticated) { + return Err(AppError::NotFound("User not found".to_string())); + } + + // Determine what to show based on visibility settings + let contacts = if can_view(&profile.contacts_visibility, is_owner, is_authenticated) { + Some(users::list_user_contacts(&pool, profile.id).await?) + } else { + None + }; + + let organizations = if can_view(&profile.organizations_visibility, is_owner, is_authenticated) { + Some(users::list_user_organizations(&pool, profile.id).await?) + } else { + None + }; + + // Email and phone follow contacts visibility + let (email, phone) = if can_view(&profile.contacts_visibility, is_owner, is_authenticated) { + (profile.email, profile.phone) + } else { + (None, None) + }; + + Ok(Json(PublicProfile { + id: profile.id, + username: profile.username, + email, + phone, + display_name: profile.display_name, + summary: profile.summary, + homepage: profile.homepage, + bio: profile.bio, + avatar_source: profile.avatar_source, + contacts, + organizations, + member_since: profile.created_at, + is_owner, + })) +} + +/// Check if a viewer can see content based on visibility setting. +fn can_view(visibility: &ProfileVisibility, is_owner: bool, is_authenticated: bool) -> bool { + if is_owner { + return true; + } + match visibility { + ProfileVisibility::Public => true, + ProfileVisibility::Members => is_authenticated, + ProfileVisibility::Friends => false, // TODO: implement friend checking + ProfileVisibility::Private => false, + } +} diff --git a/crates/chattyness-user-ui/src/api/routes.rs b/crates/chattyness-user-ui/src/api/routes.rs index 060ee54..d519903 100644 --- a/crates/chattyness-user-ui/src/api/routes.rs +++ b/crates/chattyness-user-ui/src/api/routes.rs @@ -6,7 +6,7 @@ use axum::{Router, routing::get}; -use super::{auth, avatars, inventory, realms, scenes, websocket}; +use super::{auth, avatars, inventory, profile, realms, scenes, websocket}; use crate::app::AppState; /// Build the API router for user UI. @@ -35,6 +35,35 @@ pub fn api_router() -> Router { "/auth/preferences", axum::routing::put(auth::update_preferences), ) + // Profile routes + .route( + "/profile", + get(profile::get_profile).put(profile::update_profile), + ) + .route( + "/profile/visibility", + axum::routing::put(profile::update_visibility), + ) + .route( + "/profile/contacts", + get(profile::list_contacts).post(profile::create_contact), + ) + .route( + "/profile/contacts/{id}", + axum::routing::put(profile::update_contact) + .delete(profile::delete_contact), + ) + .route( + "/profile/organizations", + get(profile::list_organizations).post(profile::create_organization), + ) + .route( + "/profile/organizations/{id}", + axum::routing::put(profile::update_organization) + .delete(profile::delete_organization), + ) + // Public profile route (no auth required for public profiles) + .route("/users/{username}", get(profile::get_public_profile)) // Realm routes (READ-ONLY) .route("/realms", get(realms::list_realms)) .route("/realms/{slug}", get(realms::get_realm)) diff --git a/crates/chattyness-user-ui/src/api/websocket.rs b/crates/chattyness-user-ui/src/api/websocket.rs index 62690a8..586a2f3 100644 --- a/crates/chattyness-user-ui/src/api/websocket.rs +++ b/crates/chattyness-user-ui/src/api/websocket.rs @@ -711,6 +711,80 @@ async fn handle_socket( } } } + ClientMessage::CopyAndDropProp { inventory_item_id } => { + // Ensure instance exists for this scene (required for loose_props FK) + // In this system, channel_id = scene_id + if let Err(e) = loose_props::ensure_scene_instance( + &mut *recv_conn, + channel_id, + ) + .await + { + tracing::error!( + "[WS] Failed to ensure scene instance: {:?}", + e + ); + } + + // Get user's current position for random offset + let member_info = channel_members::get_channel_member( + &mut *recv_conn, + channel_id, + user_id, + realm_id, + ) + .await; + + if let Ok(Some(member)) = member_info { + // Generate random offset (within ~50 pixels) + let offset_x = (rand::random::() - 0.5) * 100.0; + let offset_y = (rand::random::() - 0.5) * 100.0; + let pos_x = member.position_x + offset_x; + let pos_y = member.position_y + offset_y; + + match loose_props::copy_and_drop_prop( + &mut *recv_conn, + inventory_item_id, + user_id, + channel_id, + pos_x, + pos_y, + ) + .await + { + Ok(prop) => { + #[cfg(debug_assertions)] + tracing::debug!( + "[WS] User {} copied and dropped prop {} at ({}, {})", + user_id, + prop.id, + pos_x, + pos_y + ); + let _ = + tx.send(ServerMessage::PropDropped { prop }); + } + Err(e) => { + tracing::error!("[WS] Copy and drop prop failed: {:?}", e); + let (code, message) = match &e { + chattyness_error::AppError::Forbidden(msg) => ( + "PROP_NOT_COPYABLE".to_string(), + msg.clone(), + ), + chattyness_error::AppError::NotFound(msg) => { + ("PROP_NOT_FOUND".to_string(), msg.clone()) + } + _ => ( + "COPY_DROP_FAILED".to_string(), + format!("{:?}", e), + ), + }; + let _ = + tx.send(ServerMessage::Error { code, message }); + } + } + } + } ClientMessage::DeleteProp { inventory_item_id } => { match inventory::drop_inventory_item( &mut *recv_conn, @@ -1432,11 +1506,13 @@ async fn handle_socket( // Update the is_guest flag - critical for allowing // newly registered users to send whispers is_guest = updated_user.is_guest(); + let username = updated_user.username.clone(); let display_name = updated_user.display_name.clone(); tracing::info!( - "[WS] User {} refreshed identity: display_name={}, is_guest={}", + "[WS] User {} refreshed identity: username={}, display_name={}, is_guest={}", user_id, + username, display_name, is_guest ); @@ -1457,6 +1533,7 @@ async fn handle_socket( // Broadcast identity update to all channel members let _ = tx.send(ServerMessage::MemberIdentityUpdated { user_id, + username, display_name, is_guest, }); @@ -1758,7 +1835,11 @@ async fn handle_socket( // 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); + let action_hint = PropStateView::detect_action_hint_full( + &prop.server_state, + None, + Some(&prop.prop_name), + ); // 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 @@ -1768,11 +1849,18 @@ async fn handle_socket( } else { None }; + // Clear is_owner_card flag for loose props - this flag + // is only meaningful for inventory items (the owner's copy). + // Loose props should never show "this is your card" message. + let mut server_state = prop.server_state; + if let Some(obj) = server_state.as_object_mut() { + obj.remove("is_owner_card"); + } Ok(PropStateView { prop_id, prop_name: prop.prop_name, owner_display_name: owner_name, - server_state: prop.server_state, + server_state, // Realm state visible if user is in same realm realm_state: Some(prop.realm_state), user_state: prop.user_state, @@ -1788,7 +1876,12 @@ async fn handle_socket( // 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); + // Use detect_action_hint_with_private to detect business cards + // from server_private_state (received cards have snapshot there) + let action_hint = PropStateView::detect_action_hint_with_private( + &item.server_state, + Some(&item.server_private_state), + ); Ok(PropStateView { prop_id, prop_name: item.prop_name, diff --git a/crates/chattyness-user-ui/src/components/chat.rs b/crates/chattyness-user-ui/src/components/chat.rs index 04409e0..f6a0185 100644 --- a/crates/chattyness-user-ui/src/components/chat.rs +++ b/crates/chattyness-user-ui/src/components/chat.rs @@ -21,6 +21,8 @@ enum CommandMode { ShowingSlashHint, /// Showing mod command hints only (`/mod summon [nick|*]`). ShowingModHint, + /// Showing set command hints (`/set profile`). + ShowingSetHint, /// Showing emotion list popup. ShowingList, /// Showing scene list popup for teleport. @@ -183,6 +185,9 @@ pub fn ChatInput( /// Callback to open registration modal. #[prop(optional)] on_open_register: Option>, + /// Callback to open profile page (non-guests only). + #[prop(optional)] + on_open_profile: Option>, ) -> impl IntoView { let (message, set_message) = signal(String::new()); let (command_mode, set_command_mode) = signal(CommandMode::None); @@ -382,9 +387,25 @@ pub fn ChatInput( && !cmd.is_empty() && "register".starts_with(&cmd); + // Check if typing set command (only for non-guests) + // Show set hint when typing "/set" or "/set ..." + let is_typing_set = !is_guest.get_untracked() + && (cmd == "set" || cmd.starts_with("set ")); + // Show /set in slash hints when just starting to type it (but not if closer to /setting) + // Only show when typing exactly "se" to avoid conflict with /setting + let is_partial_set = !is_guest.get_untracked() + && !cmd.is_empty() + && cmd.len() >= 2 + && "set".starts_with(&cmd) + && cmd != "set" + && cmd != "s"; // /s goes to /setting + if is_complete_whisper || is_complete_teleport { // User is typing the argument part, no hint needed set_command_mode.set(CommandMode::None); + } else if is_typing_set { + // Show set-specific hint bar + set_command_mode.set(CommandMode::ShowingSetHint); } else if is_typing_mod { // Show mod-specific hint bar set_command_mode.set(CommandMode::ShowingModHint); @@ -404,6 +425,7 @@ pub fn ChatInput( || cmd.starts_with("teleport ") || is_partial_mod || is_partial_register + || is_partial_set { set_command_mode.set(CommandMode::ShowingSlashHint); } else { @@ -586,6 +608,21 @@ pub fn ChatInput( ev.prevent_default(); return; } + // Autocomplete /set p to /set profile (only for non-guests) + if !is_guest.get_untracked() && cmd.starts_with("set ") { + let subcommand = cmd.strip_prefix("set ").unwrap_or(""); + if !subcommand.is_empty() + && "profile".starts_with(subcommand) + && subcommand != "profile" + { + set_message.set("/set profile".to_string()); + if let Some(input) = input_ref.get() { + input.set_value("/set profile"); + } + ev.prevent_default(); + return; + } + } } // Always prevent Tab from moving focus when in input ev.prevent_default(); @@ -599,6 +636,25 @@ pub fn ChatInput( if msg.starts_with('/') { let cmd = msg[1..].to_lowercase(); + // /set profile - open profile page (non-guests only) + // Must check before /setting since "set" is a prefix of "setting" + if !is_guest.get_untracked() && cmd.starts_with("set ") { + let subcommand = cmd.strip_prefix("set ").unwrap_or("").trim(); + if !subcommand.is_empty() && "profile".starts_with(subcommand) { + if let Some(ref callback) = on_open_profile { + callback.run(()); + } + set_message.set(String::new()); + set_command_mode.set(CommandMode::None); + if let Some(input) = input_ref.get() { + input.set_value(""); + let _ = input.blur(); + } + ev.prevent_default(); + return; + } + } + // /s, /se, /set, /sett, /setti, /settin, /setting, /settings if !cmd.is_empty() && ("setting".starts_with(&cmd) || cmd == "settings") { if let Some(ref callback) = on_open_settings { @@ -883,6 +939,12 @@ pub fn ChatInput( "/" "mod" + // Show /set hint for non-guests (details shown when typing /set) + + "|" + "/" + "set" + // Show /register hint for guests "|" @@ -912,6 +974,16 @@ pub fn ChatInput( + // Set command hint bar (shown when typing /set) + +
+ "/" + "set" + " profile" + " - edit your profile" +
+
+ // Emotion list popup bool { fn event_target_checked(_ev: &leptos::ev::Event) -> bool { false } + +/// Select input (dropdown) field with label. +#[component] +pub fn SelectInput( + name: &'static str, + label: &'static str, + options: Vec<(&'static str, &'static str)>, + #[prop(optional)] help_text: &'static str, + #[prop(default = false)] required: bool, + #[prop(optional)] class: &'static str, + #[prop(into)] value: Signal, + on_change: Callback, +) -> impl IntoView { + let input_id = name; + let help_id = format!("{}-help", name); + let has_help = !help_text.is_empty(); + + view! { +
+ + + {if has_help { + view! {

{help_text}

}.into_any() + } else { + view! {}.into_any() + }} +
+ } +} + +/// Grouped select input (dropdown with optgroups) field with label. +#[component] +pub fn GroupedSelectInput( + name: &'static str, + label: &'static str, + groups: Vec<(&'static str, Vec<(&'static str, &'static str)>)>, + #[prop(optional)] help_text: &'static str, + #[prop(default = false)] required: bool, + #[prop(optional)] class: &'static str, + #[prop(into)] value: Signal, + on_change: Callback, +) -> impl IntoView { + let input_id = name; + let help_id = format!("{}-help", name); + let has_help = !help_text.is_empty(); + + view! { +
+ + + {if has_help { + view! {

{help_text}

}.into_any() + } else { + view! {}.into_any() + }} +
+ } +} + +/// Searchable select with grouped options (combobox pattern). +/// Allows typing to filter options and selecting from grouped dropdown. +#[component] +pub fn SearchableSelect( + name: &'static str, + label: &'static str, + groups: Vec<(&'static str, Vec<(&'static str, &'static str)>)>, + #[prop(optional)] help_text: &'static str, + #[prop(default = false)] required: bool, + #[prop(optional)] class: &'static str, + #[prop(into)] value: Signal, + on_change: Callback, +) -> impl IntoView { + let input_id = name; + let listbox_id = format!("{}-listbox", name); + let help_id = format!("{}-help", name); + let has_help = !help_text.is_empty(); + + // State for the search/filter text + let (search_text, set_search_text) = signal(String::new()); + let (is_open, set_is_open) = signal(false); + let (focused_index, set_focused_index) = signal(Option::::None); + + // Clone groups for use in multiple closures + let groups_for_label = groups.clone(); + let groups_for_filter = groups.clone(); + + // Get the display label for current value + let display_label = Signal::derive(move || { + let current = value.get(); + for (_, opts) in groups_for_label.iter() { + for (val, label) in opts.iter() { + if *val == current.as_str() { + return label.to_string(); + } + } + } + current + }); + + // Filter options based on search text + let filtered_groups = Signal::derive(move || { + let search = search_text.get().to_lowercase(); + if search.is_empty() { + return groups_for_filter.clone(); + } + groups_for_filter + .iter() + .filter_map(|(group_label, options)| { + let filtered: Vec<_> = options + .iter() + .filter(|(_, label)| label.to_lowercase().contains(&search)) + .cloned() + .collect(); + if filtered.is_empty() { + None + } else { + Some((*group_label, filtered)) + } + }) + .collect() + }); + + // Count total filtered options for keyboard navigation + let filtered_count = Signal::derive(move || { + filtered_groups + .get() + .iter() + .map(|(_, opts)| opts.len()) + .sum::() + }); + + // Get the option at a given flat index from filtered results + let get_option_at_index = move |index: usize| -> Option<(&'static str, &'static str)> { + let groups = filtered_groups.get(); + let mut current = 0; + for (_, opts) in groups.iter() { + for (val, label) in opts.iter() { + if current == index { + return Some((*val, *label)); + } + current += 1; + } + } + None + }; + + let handle_select = move |val: &str| { + on_change.run(val.to_string()); + set_search_text.set(String::new()); + set_is_open.set(false); + set_focused_index.set(None); + }; + + view! { +
+ + + // Hidden select for form submission + + + // Combobox input +
+ 0 { Some(0) } else { None }); + } + on:keydown=move |ev| { + let key = ev.key(); + match key.as_str() { + "ArrowDown" => { + ev.prevent_default(); + let count = filtered_count.get(); + if count > 0 { + set_is_open.set(true); + set_focused_index.update(|idx| { + *idx = Some(idx.map(|i| (i + 1) % count).unwrap_or(0)); + }); + } + } + "ArrowUp" => { + ev.prevent_default(); + let count = filtered_count.get(); + if count > 0 { + set_focused_index.update(|idx| { + *idx = Some(idx.map(|i| if i == 0 { count - 1 } else { i - 1 }).unwrap_or(count - 1)); + }); + } + } + "Enter" => { + ev.prevent_default(); + if let Some(idx) = focused_index.get() { + if let Some((val, _)) = get_option_at_index(idx) { + handle_select(val); + } + } + } + "Escape" => { + set_is_open.set(false); + set_search_text.set(String::new()); + set_focused_index.set(None); + } + _ => {} + } + } + /> + // Dropdown arrow indicator +
+ + + +
+
+ + // Dropdown listbox + +
    + {move || { + let groups = filtered_groups.get(); + if groups.is_empty() { + view! { +
  • "No matching platforms"
  • + }.into_any() + } else { + let mut flat_index = 0usize; + groups + .into_iter() + .map(|(group_label, options)| { + let group_options = options + .into_iter() + .map(|(val, display)| { + let current_index = flat_index; + flat_index += 1; + let is_selected = Signal::derive(move || value.get() == val); + let is_focused = Signal::derive(move || focused_index.get() == Some(current_index)); + let val_string = val.to_string(); + view! { +
  • + {display} +
  • + } + }) + .collect_view(); + + view! { +
  • +
    + {group_label} +
    +
      + {group_options} +
    +
  • + } + }) + .collect_view() + .into_any() + } + }} +
+
+ + {if has_help { + view! {

{help_text}

}.into_any() + } else { + view! {}.into_any() + }} +
+ } +} + +/// Date input field with label. +#[component] +pub fn DateInput( + name: &'static str, + label: &'static str, + #[prop(optional)] help_text: &'static str, + #[prop(default = false)] required: bool, + #[prop(optional)] class: &'static str, + #[prop(into)] value: Signal, + on_change: Callback, +) -> impl IntoView { + let input_id = name; + let help_id = format!("{}-help", name); + let has_help = !help_text.is_empty(); + + view! { +
+ + + {if has_help { + view! {

{help_text}

}.into_any() + } else { + view! {}.into_any() + }} +
+ } +} diff --git a/crates/chattyness-user-ui/src/components/inventory.rs b/crates/chattyness-user-ui/src/components/inventory.rs index 1e7c8a1..51008ae 100644 --- a/crates/chattyness-user-ui/src/components/inventory.rs +++ b/crates/chattyness-user-ui/src/components/inventory.rs @@ -4,10 +4,9 @@ use leptos::prelude::*; use leptos::reactive::owner::LocalStorage; use uuid::Uuid; -use chattyness_db::models::{InventoryItem, PropAcquisitionInfo, StateScope, StateVisibility}; +use chattyness_db::models::{InventoryItem, PropAcquisitionInfo}; #[cfg(feature = "hydrate")] use chattyness_db::ws_messages::ClientMessage; -use leptos::ev::Event; use super::modals::{ConfirmModal, GuestLockedOverlay, Modal}; use super::tabs::{Tab, TabBar}; @@ -35,6 +34,9 @@ pub fn InventoryPopup( /// Whether the current user is a guest. Guests see a locked overlay. #[prop(optional, into)] is_guest: Option>, + /// Callback when user wants to view an inventory item's state (e.g., business card info) + #[prop(optional, into)] + on_view_item_state: Option>, ) -> impl IntoView { let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false)); // Tab state @@ -291,6 +293,32 @@ pub fn InventoryPopup( #[cfg(not(feature = "hydrate"))] let handle_drop = |_item_id: Uuid| {}; + // Handle copy and drop action via WebSocket (keeps original in inventory) + #[cfg(feature = "hydrate")] + let handle_copy_drop = { + move |item_id: Uuid| { + set_dropping.set(true); + + // Send copy and drop command via WebSocket + ws_sender.with_value(|sender| { + if let Some(send_fn) = sender { + send_fn(ClientMessage::CopyAndDropProp { + inventory_item_id: item_id, + }); + // Note: We don't remove from inventory since this is a copy + // The original item stays in inventory + } else { + set_error.set(Some("Not connected to server".to_string())); + } + }); + + set_dropping.set(false); + } + }; + + #[cfg(not(feature = "hydrate"))] + let handle_copy_drop = |_item_id: Uuid| {}; + // Handle delete action via WebSocket (permanent deletion) #[cfg(feature = "hydrate")] let handle_delete = { @@ -368,11 +396,13 @@ pub fn InventoryPopup( set_selected_item=set_selected_item dropping=dropping on_drop=Callback::new(handle_drop) + on_copy_drop=Callback::new(handle_copy_drop) deleting=deleting on_delete_request=Callback::new(move |(id, name)| { set_delete_confirm_item.set(Some((id, name))); }) on_delete_immediate=Callback::new(handle_delete) + on_view_item_state=on_view_item_state.clone().unwrap_or_else(|| Callback::new(|_| {})) ws_sender=ws_sender />
@@ -455,10 +485,14 @@ fn MyInventoryTab( set_selected_item: WriteSignal>, #[prop(into)] dropping: Signal, #[prop(into)] on_drop: Callback, + /// Callback for copy and drop (creates copy on scene, keeps original in inventory) + #[prop(into)] on_copy_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, + /// Callback when user wants to view item state (e.g., business card info) + #[prop(into)] on_view_item_state: Callback, /// WebSocket sender for updating item state ws_sender: StoredValue, LocalStorage>, ) -> impl IntoView { @@ -498,27 +532,42 @@ fn MyInventoryTab( 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() { + "i" | "I" => { + // View Info: only if item has server_private_state + if !item.server_private_state.is_null() { + ev.prevent_default(); + on_view_item_state.run(item_id); + } + } "d" | "D" => { - ev.prevent_default(); - on_drop.run(item_id); + // Drop: only for droppable items + if item.is_droppable { + ev.prevent_default(); + on_drop.run(item_id); + } + } + "c" | "C" => { + // Copy & Drop: only for droppable, non-unique items + if item.is_droppable && !item.is_unique { + ev.prevent_default(); + on_copy_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())); + // Delete: only for droppable items + if item.is_droppable { + 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())); + } } } _ => {} @@ -614,11 +663,16 @@ fn MyInventoryTab( let item_id = selected_item.get()?; let item = items.get().into_iter().find(|i| i.id == item_id)?; let on_drop = on_drop.clone(); + let on_copy_drop = on_copy_drop.clone(); let on_delete_request = on_delete_request.clone(); + let on_view_item_state = on_view_item_state.clone(); let is_dropping = dropping.get(); let is_deleting = deleting.get(); let is_droppable = item.is_droppable; + let is_unique = item.is_unique; + let can_copy_drop = is_droppable && !is_unique; let item_name = item.prop_name.clone(); + let has_state = !item.server_private_state.is_null(); Some(view! {
@@ -629,9 +683,33 @@ fn MyInventoryTab( {if item.is_transferable { "Transferable" } else { "Not transferable" }} {if item.is_portable { " \u{2022} Portable" } else { "" }} {if is_droppable { " \u{2022} Droppable" } else { " \u{2022} Essential" }} + {if is_unique { " \u{2022} Unique" } else { "" }}

+ // View Info button - only shown for items with server private state + + + + // Copy & Drop button - only shown for droppable, non-unique props + + + // Drop button - disabled for non-droppable (essential) props
- - // 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 - } - } }) }} @@ -706,164 +769,6 @@ 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 86b7dfd..575d59a 100644 --- a/crates/chattyness-user-ui/src/components/modals.rs +++ b/crates/chattyness-user-ui/src/components/modals.rs @@ -430,8 +430,15 @@ pub fn PropInfoModal( // Content based on action hint {match state.action_hint { Some(PropActionHint::BusinessCard) => { + let server_private_state = state.private_state + .as_ref() + .map(|ps| ps.server_private_state.clone()) + .unwrap_or_else(|| serde_json::json!({})); view! { - + }.into_any() } Some(PropActionHint::ExternalLinks) => { @@ -456,21 +463,62 @@ pub fn PropInfoModal( /// Business card view for props with profile information. /// -/// Displays profile fields and social media link buttons. +/// Displays profile fields from `server_private_state.snapshot` (captured giver profile). +/// For received cards, shows a "View Profile" button linking to the giver's profile. #[component] pub fn BusinessCardView( server_state: serde_json::Value, + server_private_state: serde_json::Value, ) -> impl IntoView { - // Extract profile from server_state - let profile = server_state.get("profile").cloned().unwrap_or(serde_json::Value::Null); + // Check if this is the owner's card (acquired from library, not received from someone) + let is_owner_card = server_state.get("is_owner_card") + .and_then(|v| v.as_bool()) + .unwrap_or(false); - 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()); + // If this is the owner's card (not received from someone), show a message + if is_owner_card { + return view! { +
+

+ "This is your business card. Drop it in a scene for others to pick up." +

+

+ "Recipients will see your profile information." +

+
+ }.into_any(); + } + + // Get snapshot from server_private_state (received cards) + let Some(snapshot) = server_private_state.get("snapshot").cloned() else { + // No snapshot means loose prop on scene - show pickup message + return view! { +
+

+ "Pick up this business card to see the owner's profile information." +

+
+ }.into_any(); + }; + + // Extract profile data from snapshot + let name = snapshot.get("display_name").and_then(|v| v.as_str()).map(|s| s.to_string()) + .or_else(|| { + // Combine first and last name if display_name not set + let first = snapshot.get("name_first").and_then(|v| v.as_str()); + let last = snapshot.get("name_last").and_then(|v| v.as_str()); + match (first, last) { + (Some(f), Some(l)) => Some(format!("{} {}", f, l)), + (Some(f), None) => Some(f.to_string()), + (None, Some(l)) => Some(l.to_string()), + (None, None) => None, + } + }); + let username = snapshot.get("username").and_then(|v| v.as_str()).map(|s| s.to_string()); + let summary = snapshot.get("summary").and_then(|v| v.as_str()).map(|s| s.to_string()); + let homepage = snapshot.get("homepage").and_then(|v| v.as_str()).map(|s| s.to_string()); + let email = snapshot.get("email").and_then(|v| v.as_str()).map(|s| s.to_string()); + let phone = snapshot.get("phone").and_then(|v| v.as_str()).map(|s| s.to_string()); view! {
@@ -479,85 +527,65 @@ pub fn BusinessCardView( {name.clone().map(|n| view! {

{n}

})} - {title.clone().map(|t| view! { -

{t}

- })} - {company.clone().map(|c| view! { -

{c}

+ {summary.clone().map(|s| view! { +

"\""{ s }"\""

})}
- // Social links -
- {linkedin.clone().map(|username| { - let url = format!("https://linkedin.com/in/{}", username); - view! { - - - "LinkedIn" - - } - })} + // Contact info + {(email.is_some() || phone.is_some() || homepage.is_some()).then(|| view! { +
+ {email.clone().map(|e| { + let mailto = format!("mailto:{}", e); + view! { +

+ "Email: " + {e} +

+ } + })} + {phone.clone().map(|p| { + let tel = format!("tel:{}", p); + view! { +

+ "Phone: " + {p} +

+ } + })} + {homepage.clone().map(|url| { + let href = url.clone(); + view! { +

+ "Website: " + {url} +

+ } + })} +
+ })} - {github.clone().map(|username| { - let url = format!("https://github.com/{}", username); - view! { + // View Profile button (if we have username from snapshot) + {username.clone().map(|u| { + let profile_url = format!("/users/{}", u); + view! { +
- - "GitHub" - - } - })} - - {twitter.clone().map(|username| { - let url = format!("https://twitter.com/{}", username); - view! { - - - "Twitter" - - } - })} - - {website.clone().map(|url| { - view! { - - "Website" + "View Full Profile" - } - })} -
+
+ } + })} - } + }.into_any() } /// External links view for props with link arrays. diff --git a/crates/chattyness-user-ui/src/components/scene_viewer.rs b/crates/chattyness-user-ui/src/components/scene_viewer.rs index 954dced..20c11c3 100644 --- a/crates/chattyness-user-ui/src/components/scene_viewer.rs +++ b/crates/chattyness-user-ui/src/components/scene_viewer.rs @@ -114,6 +114,7 @@ pub fn RealmSceneViewer( let (context_menu_open, set_context_menu_open) = signal(false); let (context_menu_position, set_context_menu_position) = signal((0.0_f64, 0.0_f64)); let (context_menu_target, set_context_menu_target) = signal(Option::::None); + let (context_menu_username, set_context_menu_username) = signal(Option::::None); // Prop context menu state let (prop_context_menu_open, set_prop_context_menu_open) = signal(false); @@ -258,13 +259,13 @@ pub fn RealmSceneViewer( if hit_test_canvas(&canvas, client_x, client_y) { if let Ok(member_id) = member_id_str.parse::() { if my_user_id != Some(member_id) { - if let Some(name) = members.get().iter() + if let Some(member) = members.get().iter() .find(|m| m.member.user_id == member_id) - .map(|m| m.member.display_name.clone()) { ev.prevent_default(); set_context_menu_position.set((client_x, client_y)); - set_context_menu_target.set(Some(name)); + set_context_menu_target.set(Some(member.member.display_name.clone())); + set_context_menu_username.set(Some(member.member.username.clone())); set_context_menu_open.set(true); return; } @@ -581,22 +582,40 @@ pub fn RealmSceneViewer( open=Signal::derive(move || context_menu_open.get()) position=Signal::derive(move || context_menu_position.get()) header=Signal::derive(move || context_menu_target.get()) - items=Signal::derive(move || vec![ContextMenuItem { label: "Whisper".to_string(), action: "whisper".to_string() }]) + items=Signal::derive(move || vec![ + ContextMenuItem { label: "View Profile".to_string(), action: "view_profile".to_string() }, + ContextMenuItem { label: "Whisper".to_string(), action: "whisper".to_string() }, + ]) on_select=Callback::new({ let on_whisper_request = on_whisper_request.clone(); move |action: String| { - if action == "whisper" { - if let Some(target) = context_menu_target.get() { - if let Some(ref callback) = on_whisper_request { - callback.run(target); + match action.as_str() { + "view_profile" => { + if let Some(username) = context_menu_username.get() { + #[cfg(feature = "hydrate")] + { + let url = format!("/users/{}", username); + let _ = web_sys::window() + .unwrap() + .open_with_url_and_target(&url, "_blank"); + } } } + "whisper" => { + if let Some(target) = context_menu_target.get() { + if let Some(ref callback) = on_whisper_request { + callback.run(target); + } + } + } + _ => {} } } }) on_close=Callback::new(move |_: ()| { set_context_menu_open.set(false); set_context_menu_target.set(None); + set_context_menu_username.set(None); }) /> { @@ -723,6 +726,7 @@ fn handle_server_message( .iter_mut() .find(|m| m.member.user_id == user_id) { + member.member.username = username.clone(); member.member.display_name = display_name.clone(); member.member.is_guest = is_guest; } @@ -730,6 +734,7 @@ fn handle_server_message( state.members.clone(), MemberIdentityInfo { user_id, + username, display_name, is_guest, }, diff --git a/crates/chattyness-user-ui/src/pages.rs b/crates/chattyness-user-ui/src/pages.rs index 7472063..a5ebf3b 100644 --- a/crates/chattyness-user-ui/src/pages.rs +++ b/crates/chattyness-user-ui/src/pages.rs @@ -5,11 +5,15 @@ pub mod home; pub mod login; pub mod password_reset; +pub mod profile; pub mod realm; pub mod signup; +pub mod user_profile; pub use home::*; pub use login::*; pub use password_reset::*; +pub use profile::*; pub use realm::*; pub use signup::*; +pub use user_profile::*; diff --git a/crates/chattyness-user-ui/src/pages/profile.rs b/crates/chattyness-user-ui/src/pages/profile.rs new file mode 100644 index 0000000..6bc9fbe --- /dev/null +++ b/crates/chattyness-user-ui/src/pages/profile.rs @@ -0,0 +1,1605 @@ +//! Profile editing page. +//! +//! Allows authenticated users to edit their profile including: +//! - Basic info (display name, name, summary, homepage, bio) +//! - Privacy settings (profile visibility, contacts visibility, avatar source) +//! - Contact links (social media, etc.) +//! - Organization affiliations + +use leptos::ev::SubmitEvent; +use leptos::prelude::*; +#[cfg(feature = "hydrate")] +use leptos::task::spawn_local; +#[cfg(feature = "hydrate")] +use leptos_router::hooks::use_navigate; +use leptos_router::hooks::use_query_map; + +use crate::components::{ + Card, Checkbox, DateInput, ErrorAlert, PageLayout, RadioGroup, SearchableSelect, SubmitButton, + SuccessAlert, Tab, TabBar, TextArea, TextInput, +}; +use chattyness_db::models::{ + AvatarSource, ContactPlatform, CreateContactRequest, CreateOrganizationRequest, + ProfileVisibility, UpdateContactRequest, UpdateOrganizationRequest, UpdateProfileRequest, + UpdateVisibilityRequest, UserContact, UserOrganization, UserProfile, +}; + +/// Profile editing page component. +#[component] +pub fn ProfilePage() -> impl IntoView { + let query = use_query_map(); + let realm_slug = Signal::derive(move || query.read().get("realm").map(|s| s.to_string())); + + let (active_tab, set_active_tab) = signal("basic"); + let (profile, set_profile) = signal(Option::::None); + let (contacts, set_contacts) = signal(Vec::::new()); + let (organizations, set_organizations) = signal(Vec::::new()); + let (loading, set_loading) = signal(true); + let (error, set_error) = signal(Option::::None); + + // Fetch profile data on mount + #[cfg(feature = "hydrate")] + { + use gloo_net::http::Request; + let navigate = use_navigate(); + + Effect::new(move |_| { + let navigate = navigate.clone(); + spawn_local(async move { + // Fetch profile + let profile_resp = Request::get("/api/profile").send().await; + match profile_resp { + Ok(resp) if resp.ok() => { + if let Ok(p) = resp.json::().await { + set_profile.set(Some(p)); + } + } + Ok(resp) if resp.status() == 401 => { + // Not authenticated, redirect to login + navigate("/", Default::default()); + return; + } + _ => { + set_error.set(Some("Failed to load profile".to_string())); + } + } + + // Fetch contacts + let contacts_resp = Request::get("/api/profile/contacts").send().await; + if let Ok(resp) = contacts_resp { + if resp.ok() { + if let Ok(c) = resp.json::>().await { + set_contacts.set(c); + } + } + } + + // Fetch organizations + let orgs_resp = Request::get("/api/profile/organizations").send().await; + if let Ok(resp) = orgs_resp { + if resp.ok() { + if let Ok(o) = resp.json::>().await { + set_organizations.set(o); + } + } + } + + set_loading.set(false); + }); + }); + } + + view! { + +
+

"Edit Profile"

+ + + + +

"Loading profile..."

+
+ } + } + > + + + +
+ + + + + + + + + + + + +
+
+ + +
+ {move || { + if let Some(slug) = realm_slug.get() { + view! { + + "Back to Realm" + + }.into_any() + } else { + view! { + + "Back to Home" + + }.into_any() + } + }} +
+ +
+ } +} + +/// Basic info section component. +#[component] +fn BasicInfoSection( + profile: Signal>, + on_update: Callback, +) -> impl IntoView { + let (display_name, set_display_name) = signal(String::new()); + let (name_first, set_name_first) = signal(String::new()); + let (name_last, set_name_last) = signal(String::new()); + let (email, set_email) = signal(String::new()); + let (phone, set_phone) = signal(String::new()); + let (summary, set_summary) = signal(String::new()); + let (homepage, set_homepage) = signal(String::new()); + let (bio, set_bio) = signal(String::new()); + let (pending, set_pending) = signal(false); + let (error, set_error) = signal(Option::::None); + let (success, set_success) = signal(Option::::None); + + // Initialize form with profile data + Effect::new(move |_| { + if let Some(p) = profile.get() { + set_display_name.set(p.display_name.clone()); + set_name_first.set(p.name_first.clone().unwrap_or_default()); + set_name_last.set(p.name_last.clone().unwrap_or_default()); + set_email.set(p.email.clone().unwrap_or_default()); + set_phone.set(p.phone.clone().unwrap_or_default()); + set_summary.set(p.summary.clone().unwrap_or_default()); + set_homepage.set(p.homepage.clone().unwrap_or_default()); + set_bio.set(p.bio.clone().unwrap_or_default()); + } + }); + + let on_submit = move |ev: SubmitEvent| { + ev.prevent_default(); + set_error.set(None); + set_success.set(None); + set_pending.set(true); + + #[cfg(feature = "hydrate")] + { + use gloo_net::http::Request; + + let request = UpdateProfileRequest { + display_name: display_name.get(), + name_first: if name_first.get().is_empty() { + None + } else { + Some(name_first.get()) + }, + name_last: if name_last.get().is_empty() { + None + } else { + Some(name_last.get()) + }, + summary: if summary.get().is_empty() { + None + } else { + Some(summary.get()) + }, + homepage: if homepage.get().is_empty() { + None + } else { + Some(homepage.get()) + }, + bio: if bio.get().is_empty() { + None + } else { + Some(bio.get()) + }, + phone: if phone.get().is_empty() { + None + } else { + Some(phone.get()) + }, + }; + + spawn_local(async move { + let response = Request::put("/api/profile") + .json(&request) + .unwrap() + .send() + .await; + + set_pending.set(false); + + match response { + Ok(resp) if resp.ok() => { + set_success.set(Some("Profile updated successfully!".to_string())); + // Update the profile in parent + if let Some(mut p) = profile.get() { + p.display_name = request.display_name; + p.name_first = request.name_first; + p.name_last = request.name_last; + p.summary = request.summary; + p.homepage = request.homepage; + p.bio = request.bio; + p.phone = request.phone; + on_update.run(p); + } + } + Ok(resp) => { + #[derive(serde::Deserialize)] + struct ErrorResp { + error: String, + } + if let Ok(err) = resp.json::().await { + set_error.set(Some(err.error)); + } else { + set_error.set(Some("Failed to update profile".to_string())); + } + } + Err(_) => { + set_error.set(Some("Network error".to_string())); + } + } + }); + } + }; + + view! { +
+ + + + + +
+ + +
+ +
+
+ +
+ {move || { + let e = email.get(); + if e.is_empty() { + "No email set".to_string() + } else { + e + } + }} +
+

"Email cannot be changed here (used for login)"

+
+ +
+ + + + + +