Initial copy of business card prop with profile

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

View file

@ -1703,6 +1703,126 @@ async fn handle_socket(
}
}
}
ClientMessage::UpdateItemState {
inventory_item_id,
scope,
visibility,
state,
merge,
} => {
// Update state on an inventory item
// Need to acquire connection with RLS context set
let result = async {
let mut conn = pool.acquire().await?;
set_rls_user_id(&mut conn, user_id).await?;
inventory::update_inventory_item_state(
&mut *conn,
user_id,
inventory_item_id,
scope,
visibility,
state,
merge,
).await
}.await;
match result {
Ok(new_state) => {
tracing::debug!(
"[WS] User {} updated {}:{} state on item {}",
user_id, scope, visibility, inventory_item_id
);
let _ = direct_tx.send(ServerMessage::ItemStateUpdated {
inventory_item_id,
scope,
visibility,
new_state,
}).await;
}
Err(e) => {
tracing::error!("[WS] Update item state failed: {:?}", e);
let _ = direct_tx.send(ServerMessage::Error {
code: "UPDATE_STATE_FAILED".to_string(),
message: format!("{:?}", e),
}).await;
}
}
}
ClientMessage::ViewPropState { prop_id, is_loose_prop } => {
// View state of a prop (loose prop or inventory item)
use chattyness_db::models::{PropStateView, PrivateStateBundle};
let result = if is_loose_prop {
// Get loose prop state
match loose_props::get_loose_prop_by_id(&pool, prop_id).await {
Ok(Some(prop)) => {
let action_hint = PropStateView::detect_action_hint(&prop.server_state);
// Get owner display name if prop was dropped by someone
let owner_name = if let Some(dropped_by) = prop.dropped_by {
users::get_user_by_id(&pool, dropped_by).await
.ok()
.flatten()
.map(|u| u.display_name)
} else {
None
};
Ok(PropStateView {
prop_id,
prop_name: prop.prop_name,
owner_display_name: owner_name,
server_state: prop.server_state,
// Realm state visible if user is in same realm
realm_state: Some(prop.realm_state),
user_state: prop.user_state,
// No private state for loose props
private_state: None,
action_hint,
})
}
Ok(None) => Err(AppError::NotFound("Loose prop not found".to_string())),
Err(e) => Err(e),
}
} else {
// Get inventory item state
match inventory::get_inventory_item(&pool, prop_id, user_id).await {
Ok(Some(item)) => {
let action_hint = PropStateView::detect_action_hint(&item.server_state);
Ok(PropStateView {
prop_id,
prop_name: item.prop_name,
owner_display_name: None, // It's the user's own item
server_state: item.server_state,
realm_state: Some(item.realm_state),
user_state: item.user_state,
// Include private state since viewer is owner
private_state: Some(PrivateStateBundle {
server_private_state: item.server_private_state,
realm_private_state: item.realm_private_state,
user_private_state: item.user_private_state,
}),
action_hint,
})
}
Ok(None) => Err(AppError::NotFound("Inventory item not found".to_string())),
Err(e) => Err(e),
}
};
match result {
Ok(view) => {
let _ = direct_tx.send(ServerMessage::PropStateViewResponse { view }).await;
}
Err(e) => {
tracing::error!("[WS] View prop state failed: {:?}", e);
let _ = direct_tx.send(ServerMessage::Error {
code: "VIEW_STATE_FAILED".to_string(),
message: format!("{:?}", e),
}).await;
}
}
}
}
}
Message::Close(close_frame) => {

View file

@ -4,9 +4,10 @@ use leptos::prelude::*;
use leptos::reactive::owner::LocalStorage;
use uuid::Uuid;
use chattyness_db::models::{InventoryItem, PropAcquisitionInfo};
use chattyness_db::models::{InventoryItem, PropAcquisitionInfo, StateScope, StateVisibility};
#[cfg(feature = "hydrate")]
use chattyness_db::ws_messages::ClientMessage;
use leptos::ev::Event;
use super::modals::{ConfirmModal, GuestLockedOverlay, Modal};
use super::tabs::{Tab, TabBar};
@ -372,6 +373,7 @@ pub fn InventoryPopup(
set_delete_confirm_item.set(Some((id, name)));
})
on_delete_immediate=Callback::new(handle_delete)
ws_sender=ws_sender
/>
</Show>
@ -457,6 +459,8 @@ fn MyInventoryTab(
#[prop(into)] on_delete_request: Callback<(Uuid, String)>,
/// Callback for immediate delete (Shift+Delete, no confirmation)
#[prop(into)] on_delete_immediate: Callback<Uuid>,
/// WebSocket sender for updating item state
ws_sender: StoredValue<Option<WsSender>, LocalStorage>,
) -> impl IntoView {
// NodeRef to maintain focus on container after item removal
let container_ref = NodeRef::<leptos::html::Div>::new();
@ -678,6 +682,21 @@ fn MyInventoryTab(
</Show>
</div>
</div>
// Show BusinessCardEditor for business card props
{
let is_business_card = item.prop_name.to_lowercase().contains("businesscard");
if is_business_card {
Some(view! {
<BusinessCardEditor
item=item.clone()
ws_sender=ws_sender
/>
})
} else {
None
}
}
</div>
})
}}
@ -687,6 +706,164 @@ fn MyInventoryTab(
}
}
/// Helper function to get string value from Event target.
fn event_target_value(ev: &Event) -> String {
use leptos::wasm_bindgen::JsCast;
ev.target()
.and_then(|t| t.dyn_into::<leptos::web_sys::HtmlInputElement>().ok())
.map(|input| input.value())
.unwrap_or_default()
}
/// Business card editor component for customizing business card props.
///
/// Allows editing profile information (name, title, social links) that will
/// be displayed when other users view the prop.
#[component]
fn BusinessCardEditor(
item: InventoryItem,
ws_sender: StoredValue<Option<WsSender>, LocalStorage>,
) -> impl IntoView {
// Extract existing profile data from server_state
let profile = item
.server_state
.get("profile")
.cloned()
.unwrap_or_else(|| serde_json::json!({}));
let (name, set_name) = signal(
profile
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
);
let (title, set_title) = signal(
profile
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
);
let (linkedin, set_linkedin) = signal(
profile
.get("linkedin")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
);
let (github, set_github) = signal(
profile
.get("github")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
);
let (website, set_website) = signal(
profile
.get("website")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
);
let (saving, set_saving) = signal(false);
let (save_message, set_save_message) = signal(Option::<(bool, String)>::None);
let item_id = item.id;
let handle_save = move |_| {
set_saving.set(true);
set_save_message.set(None);
let state = serde_json::json!({
"profile": {
"name": name.get(),
"title": title.get(),
"linkedin": linkedin.get(),
"github": github.get(),
"website": website.get()
}
});
#[cfg(feature = "hydrate")]
ws_sender.with_value(|sender| {
if let Some(send_fn) = sender {
send_fn(ClientMessage::UpdateItemState {
inventory_item_id: item_id,
scope: StateScope::Server,
visibility: StateVisibility::Public,
state,
merge: false, // Replace entire server_state
});
set_save_message.set(Some((true, "Saved!".to_string())));
} else {
set_save_message.set(Some((false, "Not connected".to_string())));
}
});
set_saving.set(false);
};
view! {
<div class="mt-4 pt-4 border-t border-gray-700">
<h4 class="text-white font-medium mb-3">"Business Card Details"</h4>
<div class="space-y-3">
<input
type="text"
placeholder="Your Name"
prop:value=move || name.get()
on:input=move |e| set_name.set(event_target_value(&e))
class="w-full px-3 py-2 bg-gray-700 rounded text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<input
type="text"
placeholder="Title / Position"
prop:value=move || title.get()
on:input=move |e| set_title.set(event_target_value(&e))
class="w-full px-3 py-2 bg-gray-700 rounded text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<input
type="text"
placeholder="LinkedIn username"
prop:value=move || linkedin.get()
on:input=move |e| set_linkedin.set(event_target_value(&e))
class="w-full px-3 py-2 bg-gray-700 rounded text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<input
type="text"
placeholder="GitHub username"
prop:value=move || github.get()
on:input=move |e| set_github.set(event_target_value(&e))
class="w-full px-3 py-2 bg-gray-700 rounded text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<input
type="text"
placeholder="Website URL"
prop:value=move || website.get()
on:input=move |e| set_website.set(event_target_value(&e))
class="w-full px-3 py-2 bg-gray-700 rounded text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="button"
class="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors disabled:opacity-50"
on:click=handle_save
disabled=move || saving.get()
>
{move || if saving.get() { "Saving..." } else { "Save Business Card" }}
</button>
{move || save_message.get().map(|(success, msg)| {
let class = if success {
"text-green-400 text-sm mt-2"
} else {
"text-red-400 text-sm mt-2"
};
view! { <p class=class>{msg}</p> }
})}
</div>
</div>
}
}
/// Acquisition props tab content with acquire functionality.
#[component]
fn AcquisitionPropsTab(

View file

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

View file

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

View file

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

View file

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