Compare commits
6 commits
29f29358fd
...
3da420fe59
| Author | SHA256 | Date | |
|---|---|---|---|
| 3da420fe59 | |||
| 45a7e44b3a | |||
| 864cfaec54 | |||
| 41ea9d13cb | |||
| 7852790a1e | |||
| 3e1afb82c8 |
21 changed files with 2012 additions and 177 deletions
|
|
@ -7,6 +7,7 @@ edition.workspace = true
|
||||||
chattyness-error = { workspace = true, optional = true }
|
chattyness-error = { workspace = true, optional = true }
|
||||||
chattyness-shared = { workspace = true, optional = true }
|
chattyness-shared = { workspace = true, optional = true }
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
serde_json = { workspace = true, optional = true }
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
|
|
||||||
|
|
@ -17,4 +18,4 @@ rand = { workspace = true, optional = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
ssr = ["sqlx", "argon2", "rand", "chattyness-error/ssr", "dep:chattyness-error", "dep:chattyness-shared"]
|
ssr = ["sqlx", "argon2", "rand", "chattyness-error/ssr", "dep:chattyness-error", "dep:chattyness-shared", "dep:serde_json"]
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,44 @@ impl std::str::FromStr for RealmRole {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Moderation action type.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
|
||||||
|
#[cfg_attr(
|
||||||
|
feature = "ssr",
|
||||||
|
sqlx(type_name = "action_type", rename_all = "snake_case")
|
||||||
|
)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ActionType {
|
||||||
|
Warning,
|
||||||
|
Mute,
|
||||||
|
Kick,
|
||||||
|
Ban,
|
||||||
|
Unban,
|
||||||
|
PropRemoval,
|
||||||
|
MessageDeletion,
|
||||||
|
Summon,
|
||||||
|
SummonAll,
|
||||||
|
Teleport,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ActionType {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
ActionType::Warning => write!(f, "warning"),
|
||||||
|
ActionType::Mute => write!(f, "mute"),
|
||||||
|
ActionType::Kick => write!(f, "kick"),
|
||||||
|
ActionType::Ban => write!(f, "ban"),
|
||||||
|
ActionType::Unban => write!(f, "unban"),
|
||||||
|
ActionType::PropRemoval => write!(f, "prop_removal"),
|
||||||
|
ActionType::MessageDeletion => write!(f, "message_deletion"),
|
||||||
|
ActionType::Summon => write!(f, "summon"),
|
||||||
|
ActionType::SummonAll => write!(f, "summon_all"),
|
||||||
|
ActionType::Teleport => write!(f, "teleport"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Scene dimension mode.
|
/// Scene dimension mode.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
|
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
|
||||||
|
|
@ -295,6 +333,7 @@ pub enum AvatarLayer {
|
||||||
#[default]
|
#[default]
|
||||||
Clothes,
|
Clothes,
|
||||||
Accessories,
|
Accessories,
|
||||||
|
Emote,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for AvatarLayer {
|
impl std::fmt::Display for AvatarLayer {
|
||||||
|
|
@ -303,6 +342,7 @@ impl std::fmt::Display for AvatarLayer {
|
||||||
AvatarLayer::Skin => write!(f, "skin"),
|
AvatarLayer::Skin => write!(f, "skin"),
|
||||||
AvatarLayer::Clothes => write!(f, "clothes"),
|
AvatarLayer::Clothes => write!(f, "clothes"),
|
||||||
AvatarLayer::Accessories => write!(f, "accessories"),
|
AvatarLayer::Accessories => write!(f, "accessories"),
|
||||||
|
AvatarLayer::Emote => write!(f, "emote"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -315,6 +355,7 @@ impl std::str::FromStr for AvatarLayer {
|
||||||
"skin" => Ok(AvatarLayer::Skin),
|
"skin" => Ok(AvatarLayer::Skin),
|
||||||
"clothes" => Ok(AvatarLayer::Clothes),
|
"clothes" => Ok(AvatarLayer::Clothes),
|
||||||
"accessories" => Ok(AvatarLayer::Accessories),
|
"accessories" => Ok(AvatarLayer::Accessories),
|
||||||
|
"emote" => Ok(AvatarLayer::Emote),
|
||||||
_ => Err(format!("Invalid avatar layer: {}", s)),
|
_ => Err(format!("Invalid avatar layer: {}", s)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -685,21 +726,40 @@ pub struct InventoryResponse {
|
||||||
pub items: Vec<InventoryItem>,
|
pub items: Vec<InventoryItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A public prop from server or realm library.
|
/// Extended prop info for acquisition UI (works for both server and realm props).
|
||||||
/// Used for the public inventory tabs (Server/Realm).
|
/// Includes ownership and availability status for the current user.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
||||||
pub struct PublicProp {
|
pub struct PropAcquisitionInfo {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub asset_path: String,
|
pub asset_path: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
|
pub is_unique: bool,
|
||||||
|
/// User already has this prop in their inventory.
|
||||||
|
pub user_owns: bool,
|
||||||
|
/// For unique props: someone has already claimed it.
|
||||||
|
pub is_claimed: bool,
|
||||||
|
/// Prop is within its availability window (or has no window).
|
||||||
|
pub is_available: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Response for public props list.
|
/// Request to acquire a prop (server or realm).
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct PublicPropsResponse {
|
pub struct AcquirePropRequest {
|
||||||
pub props: Vec<PublicProp>,
|
pub prop_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response after acquiring a prop.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AcquirePropResponse {
|
||||||
|
pub item: InventoryItem,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response for prop acquisition list with status.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PropAcquisitionListResponse {
|
||||||
|
pub props: Vec<PropAcquisitionInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A prop dropped in a channel, available for pickup.
|
/// A prop dropped in a channel, available for pickup.
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ pub mod guests;
|
||||||
pub mod inventory;
|
pub mod inventory;
|
||||||
pub mod loose_props;
|
pub mod loose_props;
|
||||||
pub mod memberships;
|
pub mod memberships;
|
||||||
|
pub mod moderation;
|
||||||
pub mod owner;
|
pub mod owner;
|
||||||
pub mod props;
|
pub mod props;
|
||||||
pub mod realms;
|
pub mod realms;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
use sqlx::PgExecutor;
|
use sqlx::PgExecutor;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::models::{InventoryItem, PublicProp};
|
use crate::models::{InventoryItem, PropAcquisitionInfo};
|
||||||
use chattyness_error::AppError;
|
use chattyness_error::AppError;
|
||||||
|
|
||||||
/// List all inventory items for a user.
|
/// List all inventory items for a user.
|
||||||
|
|
@ -92,66 +92,455 @@ pub async fn drop_inventory_item<'e>(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all public server props.
|
/// List public server props with optional acquisition status.
|
||||||
///
|
///
|
||||||
/// Returns props that are:
|
/// Returns props that are active and public, with flags indicating:
|
||||||
/// - Active (`is_active = true`)
|
/// - `user_owns`: Whether the user already has this prop (false if no user_id)
|
||||||
/// - Public (`is_public = true`)
|
/// - `is_claimed`: Whether a unique prop has been claimed by anyone
|
||||||
/// - Currently available (within availability window if set)
|
/// - `is_available`: Whether the prop is within its availability window
|
||||||
pub async fn list_public_server_props<'e>(
|
///
|
||||||
|
/// When `user_id` is None, returns default values for user-specific fields.
|
||||||
|
pub async fn list_server_props<'e>(
|
||||||
executor: impl PgExecutor<'e>,
|
executor: impl PgExecutor<'e>,
|
||||||
) -> Result<Vec<PublicProp>, AppError> {
|
user_id: Option<Uuid>,
|
||||||
let props = sqlx::query_as::<_, PublicProp>(
|
) -> Result<Vec<PropAcquisitionInfo>, AppError> {
|
||||||
|
let props = sqlx::query_as::<_, PropAcquisitionInfo>(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
p.id,
|
||||||
name,
|
p.name,
|
||||||
asset_path,
|
p.asset_path,
|
||||||
description
|
p.description,
|
||||||
FROM server.props
|
p.is_unique,
|
||||||
WHERE is_active = true
|
CASE
|
||||||
AND is_public = true
|
WHEN $1::uuid IS NOT NULL THEN EXISTS(
|
||||||
AND (available_from IS NULL OR available_from <= now())
|
SELECT 1 FROM auth.inventory i
|
||||||
AND (available_until IS NULL OR available_until > now())
|
WHERE i.user_id = $1 AND i.server_prop_id = p.id
|
||||||
ORDER BY name ASC
|
)
|
||||||
|
ELSE false
|
||||||
|
END AS user_owns,
|
||||||
|
CASE
|
||||||
|
WHEN p.is_unique THEN EXISTS(
|
||||||
|
SELECT 1 FROM auth.inventory i WHERE i.server_prop_id = p.id
|
||||||
|
)
|
||||||
|
ELSE false
|
||||||
|
END AS is_claimed,
|
||||||
|
(p.available_from IS NULL OR p.available_from <= now())
|
||||||
|
AND (p.available_until IS NULL OR p.available_until > now()) AS is_available
|
||||||
|
FROM server.props p
|
||||||
|
WHERE p.is_active = true
|
||||||
|
AND p.is_public = true
|
||||||
|
ORDER BY p.name ASC
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
.bind(user_id)
|
||||||
.fetch_all(executor)
|
.fetch_all(executor)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(props)
|
Ok(props)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all public realm props for a specific realm.
|
/// List public realm props with optional acquisition status.
|
||||||
///
|
///
|
||||||
/// Returns props that are:
|
/// Returns props that are active and public in the specified realm, with flags indicating:
|
||||||
/// - In the specified realm
|
/// - `user_owns`: Whether the user already has this prop (false if no user_id)
|
||||||
/// - Active (`is_active = true`)
|
/// - `is_claimed`: Whether a unique prop has been claimed by anyone
|
||||||
/// - Public (`is_public = true`)
|
/// - `is_available`: Whether the prop is within its availability window
|
||||||
/// - Currently available (within availability window if set)
|
///
|
||||||
pub async fn list_public_realm_props<'e>(
|
/// When `user_id` is None, returns default values for user-specific fields.
|
||||||
|
pub async fn list_realm_props<'e>(
|
||||||
executor: impl PgExecutor<'e>,
|
executor: impl PgExecutor<'e>,
|
||||||
realm_id: Uuid,
|
realm_id: Uuid,
|
||||||
) -> Result<Vec<PublicProp>, AppError> {
|
user_id: Option<Uuid>,
|
||||||
let props = sqlx::query_as::<_, PublicProp>(
|
) -> Result<Vec<PropAcquisitionInfo>, AppError> {
|
||||||
|
let props = sqlx::query_as::<_, PropAcquisitionInfo>(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
p.id,
|
||||||
name,
|
p.name,
|
||||||
asset_path,
|
p.asset_path,
|
||||||
description
|
p.description,
|
||||||
FROM realm.props
|
p.is_unique,
|
||||||
WHERE realm_id = $1
|
CASE
|
||||||
AND is_active = true
|
WHEN $2::uuid IS NOT NULL THEN EXISTS(
|
||||||
AND is_public = true
|
SELECT 1 FROM auth.inventory i
|
||||||
AND (available_from IS NULL OR available_from <= now())
|
WHERE i.user_id = $2 AND i.realm_prop_id = p.id
|
||||||
AND (available_until IS NULL OR available_until > now())
|
)
|
||||||
ORDER BY name ASC
|
ELSE false
|
||||||
|
END AS user_owns,
|
||||||
|
CASE
|
||||||
|
WHEN p.is_unique THEN EXISTS(
|
||||||
|
SELECT 1 FROM auth.inventory i WHERE i.realm_prop_id = p.id
|
||||||
|
)
|
||||||
|
ELSE false
|
||||||
|
END AS is_claimed,
|
||||||
|
(p.available_from IS NULL OR p.available_from <= now())
|
||||||
|
AND (p.available_until IS NULL OR p.available_until > now()) AS is_available
|
||||||
|
FROM realm.props p
|
||||||
|
WHERE p.realm_id = $1
|
||||||
|
AND p.is_active = true
|
||||||
|
AND p.is_public = true
|
||||||
|
ORDER BY p.name ASC
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(realm_id)
|
.bind(realm_id)
|
||||||
|
.bind(user_id)
|
||||||
.fetch_all(executor)
|
.fetch_all(executor)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(props)
|
Ok(props)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Acquire a server prop into user's inventory.
|
||||||
|
///
|
||||||
|
/// Atomically validates and acquires the prop:
|
||||||
|
/// - Validates prop is active, public, within availability window
|
||||||
|
/// - 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`
|
||||||
|
///
|
||||||
|
/// Returns the created inventory item or an appropriate error.
|
||||||
|
pub async fn acquire_server_prop<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
prop_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<InventoryItem, AppError> {
|
||||||
|
// Use a CTE to atomically check conditions and insert
|
||||||
|
let result: Option<InventoryItem> = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
WITH prop_check AS (
|
||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
p.name,
|
||||||
|
p.asset_path,
|
||||||
|
p.default_layer,
|
||||||
|
p.is_unique,
|
||||||
|
p.is_transferable,
|
||||||
|
p.is_portable,
|
||||||
|
p.is_droppable,
|
||||||
|
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
|
||||||
|
FROM server.props p
|
||||||
|
WHERE p.id = $1
|
||||||
|
),
|
||||||
|
ownership_check AS (
|
||||||
|
SELECT
|
||||||
|
pc.*,
|
||||||
|
EXISTS(
|
||||||
|
SELECT 1 FROM auth.inventory i
|
||||||
|
WHERE i.user_id = $2 AND i.server_prop_id = $1
|
||||||
|
) AS user_owns,
|
||||||
|
CASE
|
||||||
|
WHEN pc.is_unique THEN EXISTS(
|
||||||
|
SELECT 1 FROM auth.inventory i WHERE i.server_prop_id = $1
|
||||||
|
)
|
||||||
|
ELSE false
|
||||||
|
END AS is_claimed
|
||||||
|
FROM prop_check pc
|
||||||
|
),
|
||||||
|
inserted AS (
|
||||||
|
INSERT INTO auth.inventory (
|
||||||
|
user_id,
|
||||||
|
server_prop_id,
|
||||||
|
prop_name,
|
||||||
|
prop_asset_path,
|
||||||
|
layer,
|
||||||
|
origin,
|
||||||
|
is_transferable,
|
||||||
|
is_portable,
|
||||||
|
is_droppable
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
$2,
|
||||||
|
oc.id,
|
||||||
|
oc.name,
|
||||||
|
oc.asset_path,
|
||||||
|
oc.default_layer,
|
||||||
|
'server_library'::server.prop_origin,
|
||||||
|
oc.is_transferable,
|
||||||
|
oc.is_portable,
|
||||||
|
oc.is_droppable
|
||||||
|
FROM ownership_check oc
|
||||||
|
WHERE oc.is_active = true
|
||||||
|
AND oc.is_public = true
|
||||||
|
AND oc.available_from_ok = true
|
||||||
|
AND oc.available_until_ok = true
|
||||||
|
AND oc.user_owns = false
|
||||||
|
AND oc.is_claimed = false
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
prop_name,
|
||||||
|
prop_asset_path,
|
||||||
|
layer,
|
||||||
|
is_transferable,
|
||||||
|
is_portable,
|
||||||
|
is_droppable,
|
||||||
|
origin,
|
||||||
|
acquired_at
|
||||||
|
)
|
||||||
|
SELECT * FROM inserted
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(prop_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Some(item) => Ok(item),
|
||||||
|
None => {
|
||||||
|
// Need to determine the specific error case
|
||||||
|
// We'll do a separate query to understand why it failed
|
||||||
|
Err(AppError::Conflict(
|
||||||
|
"Unable to acquire prop - it may not exist, not be available, or already owned"
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get detailed acquisition error for a server prop.
|
||||||
|
///
|
||||||
|
/// This is called when acquire_server_prop fails to determine the specific error.
|
||||||
|
pub async fn get_server_prop_acquisition_error<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
prop_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<AppError, AppError> {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
struct PropStatus {
|
||||||
|
exists: bool,
|
||||||
|
is_active: bool,
|
||||||
|
is_public: bool,
|
||||||
|
is_available: bool,
|
||||||
|
is_unique: bool,
|
||||||
|
user_owns: bool,
|
||||||
|
is_claimed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
let status: Option<PropStatus> = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
true AS exists,
|
||||||
|
p.is_active,
|
||||||
|
p.is_public,
|
||||||
|
(p.available_from IS NULL OR p.available_from <= now())
|
||||||
|
AND (p.available_until IS NULL OR p.available_until > now()) AS is_available,
|
||||||
|
p.is_unique,
|
||||||
|
EXISTS(
|
||||||
|
SELECT 1 FROM auth.inventory i
|
||||||
|
WHERE i.user_id = $2 AND i.server_prop_id = $1
|
||||||
|
) AS user_owns,
|
||||||
|
CASE
|
||||||
|
WHEN p.is_unique THEN EXISTS(
|
||||||
|
SELECT 1 FROM auth.inventory i WHERE i.server_prop_id = $1
|
||||||
|
)
|
||||||
|
ELSE false
|
||||||
|
END AS is_claimed
|
||||||
|
FROM server.props p
|
||||||
|
WHERE p.id = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(prop_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match status {
|
||||||
|
None => Ok(AppError::NotFound("Server prop not found".to_string())),
|
||||||
|
Some(s) if !s.is_active || !s.is_public => {
|
||||||
|
Ok(AppError::Forbidden("This prop is not available".to_string()))
|
||||||
|
}
|
||||||
|
Some(s) if !s.is_available => Ok(AppError::Forbidden(
|
||||||
|
"This prop is not currently available".to_string(),
|
||||||
|
)),
|
||||||
|
Some(s) if s.user_owns => Ok(AppError::Conflict("You already own this prop".to_string())),
|
||||||
|
Some(s) if s.is_claimed => Ok(AppError::Conflict(
|
||||||
|
"This unique prop has already been claimed by another user".to_string(),
|
||||||
|
)),
|
||||||
|
Some(_) => Ok(AppError::Internal(
|
||||||
|
"Unknown error acquiring prop".to_string(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acquire a realm prop into user's inventory.
|
||||||
|
///
|
||||||
|
/// Atomically validates and acquires the prop:
|
||||||
|
/// - Validates prop belongs to realm, is active, public, within availability window
|
||||||
|
/// - 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`
|
||||||
|
///
|
||||||
|
/// Returns the created inventory item or an appropriate error.
|
||||||
|
pub async fn acquire_realm_prop<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
prop_id: Uuid,
|
||||||
|
realm_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<InventoryItem, AppError> {
|
||||||
|
// Use a CTE to atomically check conditions and insert
|
||||||
|
let result: Option<InventoryItem> = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
WITH prop_check AS (
|
||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
p.name,
|
||||||
|
p.asset_path,
|
||||||
|
p.default_layer,
|
||||||
|
p.is_unique,
|
||||||
|
p.is_transferable,
|
||||||
|
p.is_droppable,
|
||||||
|
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
|
||||||
|
FROM realm.props p
|
||||||
|
WHERE p.id = $1 AND p.realm_id = $2
|
||||||
|
),
|
||||||
|
ownership_check AS (
|
||||||
|
SELECT
|
||||||
|
pc.*,
|
||||||
|
EXISTS(
|
||||||
|
SELECT 1 FROM auth.inventory i
|
||||||
|
WHERE i.user_id = $3 AND i.realm_prop_id = $1
|
||||||
|
) AS user_owns,
|
||||||
|
CASE
|
||||||
|
WHEN pc.is_unique THEN EXISTS(
|
||||||
|
SELECT 1 FROM auth.inventory i WHERE i.realm_prop_id = $1
|
||||||
|
)
|
||||||
|
ELSE false
|
||||||
|
END AS is_claimed
|
||||||
|
FROM prop_check pc
|
||||||
|
),
|
||||||
|
inserted AS (
|
||||||
|
INSERT INTO auth.inventory (
|
||||||
|
user_id,
|
||||||
|
realm_prop_id,
|
||||||
|
prop_name,
|
||||||
|
prop_asset_path,
|
||||||
|
layer,
|
||||||
|
origin,
|
||||||
|
is_transferable,
|
||||||
|
is_portable,
|
||||||
|
is_droppable
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
$3,
|
||||||
|
oc.id,
|
||||||
|
oc.name,
|
||||||
|
oc.asset_path,
|
||||||
|
oc.default_layer,
|
||||||
|
'realm_library'::server.prop_origin,
|
||||||
|
oc.is_transferable,
|
||||||
|
true, -- realm props are portable by default
|
||||||
|
oc.is_droppable
|
||||||
|
FROM ownership_check oc
|
||||||
|
WHERE oc.is_active = true
|
||||||
|
AND oc.is_public = true
|
||||||
|
AND oc.available_from_ok = true
|
||||||
|
AND oc.available_until_ok = true
|
||||||
|
AND oc.user_owns = false
|
||||||
|
AND oc.is_claimed = false
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
prop_name,
|
||||||
|
prop_asset_path,
|
||||||
|
layer,
|
||||||
|
is_transferable,
|
||||||
|
is_portable,
|
||||||
|
is_droppable,
|
||||||
|
origin,
|
||||||
|
acquired_at
|
||||||
|
)
|
||||||
|
SELECT * FROM inserted
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(prop_id)
|
||||||
|
.bind(realm_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Some(item) => Ok(item),
|
||||||
|
None => {
|
||||||
|
// Need to determine the specific error case
|
||||||
|
Err(AppError::Conflict(
|
||||||
|
"Unable to acquire prop - it may not exist, not be available, or already owned"
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get detailed acquisition error for a realm prop.
|
||||||
|
///
|
||||||
|
/// This is called when acquire_realm_prop fails to determine the specific error.
|
||||||
|
pub async fn get_realm_prop_acquisition_error<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
prop_id: Uuid,
|
||||||
|
realm_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<AppError, AppError> {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
struct PropStatus {
|
||||||
|
exists: bool,
|
||||||
|
is_active: bool,
|
||||||
|
is_public: bool,
|
||||||
|
is_available: bool,
|
||||||
|
is_unique: bool,
|
||||||
|
user_owns: bool,
|
||||||
|
is_claimed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
let status: Option<PropStatus> = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
true AS exists,
|
||||||
|
p.is_active,
|
||||||
|
p.is_public,
|
||||||
|
(p.available_from IS NULL OR p.available_from <= now())
|
||||||
|
AND (p.available_until IS NULL OR p.available_until > now()) AS is_available,
|
||||||
|
p.is_unique,
|
||||||
|
EXISTS(
|
||||||
|
SELECT 1 FROM auth.inventory i
|
||||||
|
WHERE i.user_id = $3 AND i.realm_prop_id = $1
|
||||||
|
) AS user_owns,
|
||||||
|
CASE
|
||||||
|
WHEN p.is_unique THEN EXISTS(
|
||||||
|
SELECT 1 FROM auth.inventory i WHERE i.realm_prop_id = $1
|
||||||
|
)
|
||||||
|
ELSE false
|
||||||
|
END AS is_claimed
|
||||||
|
FROM realm.props p
|
||||||
|
WHERE p.id = $1 AND p.realm_id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(prop_id)
|
||||||
|
.bind(realm_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match status {
|
||||||
|
None => Ok(AppError::NotFound("Realm prop not found".to_string())),
|
||||||
|
Some(s) if !s.is_active || !s.is_public => {
|
||||||
|
Ok(AppError::Forbidden("This prop is not available".to_string()))
|
||||||
|
}
|
||||||
|
Some(s) if !s.is_available => Ok(AppError::Forbidden(
|
||||||
|
"This prop is not currently available".to_string(),
|
||||||
|
)),
|
||||||
|
Some(s) if s.user_owns => Ok(AppError::Conflict("You already own this prop".to_string())),
|
||||||
|
Some(s) if s.is_claimed => Ok(AppError::Conflict(
|
||||||
|
"This unique prop has already been claimed by another user".to_string(),
|
||||||
|
)),
|
||||||
|
Some(_) => Ok(AppError::Internal(
|
||||||
|
"Unknown error acquiring prop".to_string(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,30 @@ use uuid::Uuid;
|
||||||
use crate::models::{InventoryItem, LooseProp};
|
use crate::models::{InventoryItem, LooseProp};
|
||||||
use chattyness_error::AppError;
|
use chattyness_error::AppError;
|
||||||
|
|
||||||
|
/// Ensure an instance exists for a scene.
|
||||||
|
///
|
||||||
|
/// In this system, scenes are used directly as instances (channel_id = scene_id).
|
||||||
|
/// This creates an instance record if one doesn't exist, using the scene_id as the instance_id.
|
||||||
|
/// This is needed for loose_props foreign key constraint.
|
||||||
|
pub async fn ensure_scene_instance<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
scene_id: Uuid,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO scene.instances (id, scene_id, instance_type)
|
||||||
|
SELECT $1, $1, 'public'::scene.instance_type
|
||||||
|
WHERE EXISTS (SELECT 1 FROM realm.scenes WHERE id = $1)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(scene_id)
|
||||||
|
.execute(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// List all loose props in a channel (excluding expired).
|
/// List all loose props in a channel (excluding expired).
|
||||||
pub async fn list_channel_loose_props<'e>(
|
pub async fn list_channel_loose_props<'e>(
|
||||||
executor: impl PgExecutor<'e>,
|
executor: impl PgExecutor<'e>,
|
||||||
|
|
@ -106,8 +130,8 @@ pub async fn drop_prop_to_canvas<'e>(
|
||||||
instance_id as channel_id,
|
instance_id as channel_id,
|
||||||
server_prop_id,
|
server_prop_id,
|
||||||
realm_prop_id,
|
realm_prop_id,
|
||||||
ST_X(position) as position_x,
|
ST_X(position)::real as position_x,
|
||||||
ST_Y(position) as position_y,
|
ST_Y(position)::real as position_y,
|
||||||
dropped_by,
|
dropped_by,
|
||||||
expires_at,
|
expires_at,
|
||||||
created_at
|
created_at
|
||||||
|
|
|
||||||
|
|
@ -199,3 +199,33 @@ pub async fn is_member(pool: &PgPool, user_id: Uuid, realm_id: Uuid) -> Result<b
|
||||||
|
|
||||||
Ok(exists.0)
|
Ok(exists.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if a user is a moderator (server-level or realm-level).
|
||||||
|
///
|
||||||
|
/// Returns true if the user has any of:
|
||||||
|
/// - Server staff role: owner, admin, or moderator (from `server.staff`)
|
||||||
|
/// - Realm role: owner or moderator (from `realm.memberships`)
|
||||||
|
pub async fn is_moderator(pool: &PgPool, user_id: Uuid, realm_id: Uuid) -> Result<bool, AppError> {
|
||||||
|
// Check server-level staff first (owner, admin, moderator can moderate any realm)
|
||||||
|
if let Some(server_role) = get_user_staff_role(pool, user_id).await? {
|
||||||
|
match server_role {
|
||||||
|
ServerRole::Owner | ServerRole::Admin | ServerRole::Moderator => return Ok(true),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check realm-level role (owner or moderator for this specific realm)
|
||||||
|
let realm_mod: (bool,) = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1 FROM realm.memberships
|
||||||
|
WHERE user_id = $1 AND realm_id = $2 AND role IN ('owner', 'moderator')
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(realm_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(realm_mod.0)
|
||||||
|
}
|
||||||
|
|
|
||||||
77
crates/chattyness-db/src/queries/moderation.rs
Normal file
77
crates/chattyness-db/src/queries/moderation.rs
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
//! Moderation-related database queries.
|
||||||
|
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::models::ActionType;
|
||||||
|
use chattyness_error::AppError;
|
||||||
|
|
||||||
|
/// Log a moderation action to the realm audit log.
|
||||||
|
pub async fn log_moderation_action(
|
||||||
|
pool: &PgPool,
|
||||||
|
realm_id: Uuid,
|
||||||
|
moderator_id: Uuid,
|
||||||
|
action_type: ActionType,
|
||||||
|
target_user_id: Option<Uuid>,
|
||||||
|
reason: &str,
|
||||||
|
metadata: serde_json::Value,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO realm.moderation_actions (
|
||||||
|
realm_id,
|
||||||
|
action_type,
|
||||||
|
target_user_id,
|
||||||
|
moderator_id,
|
||||||
|
reason,
|
||||||
|
metadata
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(realm_id)
|
||||||
|
.bind(action_type)
|
||||||
|
.bind(target_user_id)
|
||||||
|
.bind(moderator_id)
|
||||||
|
.bind(reason)
|
||||||
|
.bind(metadata)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log a moderation action using a connection (for RLS support).
|
||||||
|
pub async fn log_moderation_action_conn(
|
||||||
|
conn: &mut sqlx::PgConnection,
|
||||||
|
realm_id: Uuid,
|
||||||
|
moderator_id: Uuid,
|
||||||
|
action_type: ActionType,
|
||||||
|
target_user_id: Option<Uuid>,
|
||||||
|
reason: &str,
|
||||||
|
metadata: serde_json::Value,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO realm.moderation_actions (
|
||||||
|
realm_id,
|
||||||
|
action_type,
|
||||||
|
target_user_id,
|
||||||
|
moderator_id,
|
||||||
|
reason,
|
||||||
|
metadata
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(realm_id)
|
||||||
|
.bind(action_type)
|
||||||
|
.bind(target_user_id)
|
||||||
|
.bind(moderator_id)
|
||||||
|
.bind(reason)
|
||||||
|
.bind(metadata)
|
||||||
|
.execute(conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
@ -90,6 +90,14 @@ pub enum ClientMessage {
|
||||||
/// Scene ID to teleport to.
|
/// Scene ID to teleport to.
|
||||||
scene_id: Uuid,
|
scene_id: Uuid,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Moderator command (only processed if sender is a moderator).
|
||||||
|
ModCommand {
|
||||||
|
/// Subcommand name ("summon", "avatar", "teleport", "ban", etc.).
|
||||||
|
subcommand: String,
|
||||||
|
/// Arguments for the subcommand.
|
||||||
|
args: Vec<String>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Server-to-client WebSocket messages.
|
/// Server-to-client WebSocket messages.
|
||||||
|
|
@ -234,4 +242,22 @@ pub enum ServerMessage {
|
||||||
/// Scene slug for URL.
|
/// Scene slug for URL.
|
||||||
scene_slug: String,
|
scene_slug: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// User has been summoned by a moderator - triggers teleport.
|
||||||
|
Summoned {
|
||||||
|
/// Scene ID to teleport to.
|
||||||
|
scene_id: Uuid,
|
||||||
|
/// Scene slug for URL.
|
||||||
|
scene_slug: String,
|
||||||
|
/// Display name of the moderator who summoned.
|
||||||
|
summoned_by: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Result of a moderator command.
|
||||||
|
ModCommandResult {
|
||||||
|
/// Whether the command succeeded.
|
||||||
|
success: bool,
|
||||||
|
/// Human-readable result message.
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,66 +8,194 @@ use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use chattyness_db::{
|
use chattyness_db::{
|
||||||
models::{InventoryResponse, PublicPropsResponse},
|
User,
|
||||||
|
models::{
|
||||||
|
AcquirePropRequest, AcquirePropResponse, InventoryResponse, PropAcquisitionListResponse,
|
||||||
|
},
|
||||||
queries::{inventory, realms},
|
queries::{inventory, realms},
|
||||||
};
|
};
|
||||||
use chattyness_error::AppError;
|
use chattyness_error::AppError;
|
||||||
|
|
||||||
use crate::auth::{AuthUser, RlsConn};
|
use crate::auth::{AuthUser, OptionalAuthUser, RlsConn};
|
||||||
|
|
||||||
/// Get user's full inventory.
|
/// Get user's full inventory.
|
||||||
///
|
///
|
||||||
/// GET /api/inventory
|
/// GET /api/user/{uuid}/inventory
|
||||||
pub async fn get_inventory(
|
///
|
||||||
|
/// Supports "me" as a special UUID value to get the current user's inventory.
|
||||||
|
pub async fn get_user_inventory(
|
||||||
rls_conn: RlsConn,
|
rls_conn: RlsConn,
|
||||||
AuthUser(user): AuthUser,
|
AuthUser(user): AuthUser,
|
||||||
|
Path(uuid_str): Path<String>,
|
||||||
) -> Result<Json<InventoryResponse>, AppError> {
|
) -> Result<Json<InventoryResponse>, AppError> {
|
||||||
let mut conn = rls_conn.acquire().await;
|
// Resolve "me" to current user ID, otherwise parse UUID
|
||||||
|
let target_user_id = if uuid_str.eq_ignore_ascii_case("me") {
|
||||||
|
user.id
|
||||||
|
} else {
|
||||||
|
uuid_str.parse::<Uuid>().map_err(|_| {
|
||||||
|
AppError::Validation(format!("Invalid user UUID: {}", uuid_str))
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
|
||||||
let items = inventory::list_user_inventory(&mut *conn, user.id).await?;
|
// For now, users can only view their own inventory
|
||||||
|
if target_user_id != user.id {
|
||||||
|
return Err(AppError::Forbidden(
|
||||||
|
"You can only view your own inventory".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut conn = rls_conn.acquire().await;
|
||||||
|
let items = inventory::list_user_inventory(&mut *conn, target_user_id).await?;
|
||||||
|
|
||||||
Ok(Json(InventoryResponse { items }))
|
Ok(Json(InventoryResponse { items }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drop an item from inventory.
|
/// Drop an item from inventory.
|
||||||
///
|
///
|
||||||
/// DELETE /api/inventory/{item_id}
|
/// DELETE /api/user/{uuid}/inventory/{item_id}
|
||||||
pub async fn drop_item(
|
pub async fn drop_item(
|
||||||
rls_conn: RlsConn,
|
rls_conn: RlsConn,
|
||||||
AuthUser(user): AuthUser,
|
AuthUser(user): AuthUser,
|
||||||
Path(item_id): Path<Uuid>,
|
Path((uuid_str, item_id)): Path<(String, Uuid)>,
|
||||||
) -> Result<Json<serde_json::Value>, AppError> {
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
let mut conn = rls_conn.acquire().await;
|
// Resolve "me" to current user ID
|
||||||
|
let target_user_id = if uuid_str.eq_ignore_ascii_case("me") {
|
||||||
|
user.id
|
||||||
|
} else {
|
||||||
|
uuid_str.parse::<Uuid>().map_err(|_| {
|
||||||
|
AppError::Validation(format!("Invalid user UUID: {}", uuid_str))
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
|
||||||
|
// Users can only drop from their own inventory
|
||||||
|
if target_user_id != user.id {
|
||||||
|
return Err(AppError::Forbidden(
|
||||||
|
"You can only drop items from your own inventory".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut conn = rls_conn.acquire().await;
|
||||||
inventory::drop_inventory_item(&mut *conn, user.id, item_id).await?;
|
inventory::drop_inventory_item(&mut *conn, user.id, item_id).await?;
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({ "success": true })))
|
Ok(Json(serde_json::json!({ "success": true })))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get public server props.
|
/// Get server prop catalog.
|
||||||
///
|
///
|
||||||
/// GET /api/inventory/server
|
/// GET /api/server/inventory
|
||||||
|
///
|
||||||
|
/// Returns all public server props. If the user is authenticated (non-guest),
|
||||||
|
/// includes acquisition status (user_owns, is_claimed, is_available).
|
||||||
|
/// For guests/unauthenticated, returns default status values.
|
||||||
pub async fn get_server_props(
|
pub async fn get_server_props(
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
) -> Result<Json<PublicPropsResponse>, AppError> {
|
OptionalAuthUser(maybe_user): OptionalAuthUser,
|
||||||
let props = inventory::list_public_server_props(&pool).await?;
|
) -> Result<Json<PropAcquisitionListResponse>, AppError> {
|
||||||
|
// Get user_id if authenticated and not a guest
|
||||||
|
let user_id = maybe_user
|
||||||
|
.as_ref()
|
||||||
|
.filter(|u: &&User| !u.is_guest())
|
||||||
|
.map(|u| u.id);
|
||||||
|
|
||||||
Ok(Json(PublicPropsResponse { props }))
|
let props = inventory::list_server_props(&pool, user_id).await?;
|
||||||
|
|
||||||
|
Ok(Json(PropAcquisitionListResponse { props }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get public realm props.
|
/// Get realm prop catalog.
|
||||||
///
|
///
|
||||||
/// GET /api/realms/{slug}/inventory
|
/// GET /api/realms/{slug}/inventory
|
||||||
|
///
|
||||||
|
/// Returns all public realm props for the specified realm. If the user is authenticated
|
||||||
|
/// (non-guest), includes acquisition status. For guests/unauthenticated, returns default status.
|
||||||
pub async fn get_realm_props(
|
pub async fn get_realm_props(
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
|
OptionalAuthUser(maybe_user): OptionalAuthUser,
|
||||||
Path(slug): Path<String>,
|
Path(slug): Path<String>,
|
||||||
) -> Result<Json<PublicPropsResponse>, AppError> {
|
) -> Result<Json<PropAcquisitionListResponse>, AppError> {
|
||||||
// Get the realm by slug to get its ID
|
// Get the realm by slug to get its ID
|
||||||
let realm = realms::get_realm_by_slug(&pool, &slug)
|
let realm = realms::get_realm_by_slug(&pool, &slug)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
|
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
|
||||||
|
|
||||||
let props = inventory::list_public_realm_props(&pool, realm.id).await?;
|
// Get user_id if authenticated and not a guest
|
||||||
|
let user_id = maybe_user
|
||||||
|
.as_ref()
|
||||||
|
.filter(|u: &&User| !u.is_guest())
|
||||||
|
.map(|u| u.id);
|
||||||
|
|
||||||
Ok(Json(PublicPropsResponse { props }))
|
let props = inventory::list_realm_props(&pool, realm.id, user_id).await?;
|
||||||
|
|
||||||
|
Ok(Json(PropAcquisitionListResponse { props }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acquire a server prop into user's inventory.
|
||||||
|
///
|
||||||
|
/// POST /api/server/inventory/request
|
||||||
|
pub async fn acquire_server_prop(
|
||||||
|
rls_conn: RlsConn,
|
||||||
|
AuthUser(user): AuthUser,
|
||||||
|
Json(req): Json<AcquirePropRequest>,
|
||||||
|
) -> Result<Json<AcquirePropResponse>, AppError> {
|
||||||
|
// Guests cannot acquire props
|
||||||
|
if user.is_guest() {
|
||||||
|
return Err(AppError::Forbidden(
|
||||||
|
"Guests cannot acquire props".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut conn = rls_conn.acquire().await;
|
||||||
|
|
||||||
|
// Try to acquire the prop
|
||||||
|
match inventory::acquire_server_prop(&mut *conn, req.prop_id, user.id).await {
|
||||||
|
Ok(item) => Ok(Json(AcquirePropResponse { item })),
|
||||||
|
Err(_) => {
|
||||||
|
// Get the specific error reason
|
||||||
|
let error =
|
||||||
|
inventory::get_server_prop_acquisition_error(&mut *conn, req.prop_id, user.id)
|
||||||
|
.await?;
|
||||||
|
Err(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acquire a realm prop into user's inventory.
|
||||||
|
///
|
||||||
|
/// POST /api/realms/{slug}/inventory/request
|
||||||
|
pub async fn acquire_realm_prop(
|
||||||
|
rls_conn: RlsConn,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
AuthUser(user): AuthUser,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
Json(req): Json<AcquirePropRequest>,
|
||||||
|
) -> Result<Json<AcquirePropResponse>, AppError> {
|
||||||
|
// Guests cannot acquire props
|
||||||
|
if user.is_guest() {
|
||||||
|
return Err(AppError::Forbidden(
|
||||||
|
"Guests cannot acquire props".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the realm by slug to get its ID
|
||||||
|
let realm = realms::get_realm_by_slug(&pool, &slug)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
|
||||||
|
|
||||||
|
let mut conn = rls_conn.acquire().await;
|
||||||
|
|
||||||
|
// Try to acquire the prop
|
||||||
|
match inventory::acquire_realm_prop(&mut *conn, req.prop_id, realm.id, user.id).await {
|
||||||
|
Ok(item) => Ok(Json(AcquirePropResponse { item })),
|
||||||
|
Err(_) => {
|
||||||
|
// Get the specific error reason
|
||||||
|
let error = inventory::get_realm_prop_acquisition_error(
|
||||||
|
&mut *conn,
|
||||||
|
req.prop_id,
|
||||||
|
realm.id,
|
||||||
|
user.id,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Err(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,13 +58,22 @@ pub fn api_router() -> Router<AppState> {
|
||||||
"/realms/{slug}/avatar/slot",
|
"/realms/{slug}/avatar/slot",
|
||||||
axum::routing::put(avatars::assign_slot).delete(avatars::clear_slot),
|
axum::routing::put(avatars::assign_slot).delete(avatars::clear_slot),
|
||||||
)
|
)
|
||||||
// Inventory routes (require authentication)
|
// User inventory routes
|
||||||
.route("/inventory", get(inventory::get_inventory))
|
.route("/user/{uuid}/inventory", get(inventory::get_user_inventory))
|
||||||
.route(
|
.route(
|
||||||
"/inventory/{item_id}",
|
"/user/{uuid}/inventory/{item_id}",
|
||||||
axum::routing::delete(inventory::drop_item),
|
axum::routing::delete(inventory::drop_item),
|
||||||
)
|
)
|
||||||
// Public inventory routes (public server/realm props)
|
// Server prop catalog (enriched if authenticated)
|
||||||
.route("/inventory/server", get(inventory::get_server_props))
|
.route("/server/inventory", get(inventory::get_server_props))
|
||||||
|
.route(
|
||||||
|
"/server/inventory/request",
|
||||||
|
axum::routing::post(inventory::acquire_server_prop),
|
||||||
|
)
|
||||||
|
// Realm prop catalog (enriched if authenticated)
|
||||||
.route("/realms/{slug}/inventory", get(inventory::get_realm_props))
|
.route("/realms/{slug}/inventory", get(inventory::get_realm_props))
|
||||||
|
.route(
|
||||||
|
"/realms/{slug}/inventory/request",
|
||||||
|
axum::routing::post(inventory::acquire_realm_prop),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,8 @@ use tokio::sync::{broadcast, mpsc};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use chattyness_db::{
|
use chattyness_db::{
|
||||||
models::{AvatarRenderData, ChannelMemberWithAvatar, EmotionState, User},
|
models::{ActionType, AvatarRenderData, ChannelMemberWithAvatar, EmotionState, User},
|
||||||
queries::{avatars, channel_members, loose_props, realms, scenes},
|
queries::{avatars, channel_members, loose_props, memberships, moderation, realms, scenes},
|
||||||
ws_messages::{close_codes, ClientMessage, DisconnectReason, ServerMessage, WsConfig},
|
ws_messages::{close_codes, ClientMessage, DisconnectReason, ServerMessage, WsConfig},
|
||||||
};
|
};
|
||||||
use chattyness_error::AppError;
|
use chattyness_error::AppError;
|
||||||
|
|
@ -607,6 +607,20 @@ async fn handle_socket(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ClientMessage::DropProp { inventory_item_id } => {
|
ClientMessage::DropProp { 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
|
// Get user's current position for random offset
|
||||||
let member_info = channel_members::get_channel_member(
|
let member_info = channel_members::get_channel_member(
|
||||||
&mut *recv_conn,
|
&mut *recv_conn,
|
||||||
|
|
@ -814,6 +828,252 @@ async fn handle_socket(
|
||||||
scene_slug: scene.slug,
|
scene_slug: scene.slug,
|
||||||
}).await;
|
}).await;
|
||||||
}
|
}
|
||||||
|
ClientMessage::ModCommand { subcommand, args } => {
|
||||||
|
// Check if user is a moderator
|
||||||
|
let is_mod = match memberships::is_moderator(&pool, user_id, realm_id).await {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("[WS] Failed to check moderator status: {:?}", e);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !is_mod {
|
||||||
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||||
|
success: false,
|
||||||
|
message: "You do not have moderator permissions".to_string(),
|
||||||
|
}).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get moderator's current scene info and display name
|
||||||
|
let mod_member = match channel_members::get_channel_member(
|
||||||
|
&mut *recv_conn,
|
||||||
|
channel_id,
|
||||||
|
user_id,
|
||||||
|
realm_id,
|
||||||
|
).await {
|
||||||
|
Ok(Some(m)) => m,
|
||||||
|
Ok(None) | Err(_) => {
|
||||||
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||||
|
success: false,
|
||||||
|
message: "Failed to get moderator info".to_string(),
|
||||||
|
}).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get moderator's current scene details
|
||||||
|
let mod_scene = match scenes::get_scene_by_id(&pool, channel_id).await {
|
||||||
|
Ok(Some(s)) => s,
|
||||||
|
Ok(None) | Err(_) => {
|
||||||
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||||
|
success: false,
|
||||||
|
message: "Failed to get scene info".to_string(),
|
||||||
|
}).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match subcommand.as_str() {
|
||||||
|
"summon" => {
|
||||||
|
if args.is_empty() {
|
||||||
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||||
|
success: false,
|
||||||
|
message: "Usage: /mod summon [nick|*]".to_string(),
|
||||||
|
}).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = &args[0];
|
||||||
|
|
||||||
|
if target == "*" {
|
||||||
|
// Summon all users in the realm
|
||||||
|
let mut summoned_count = 0;
|
||||||
|
let mut target_ids = Vec::new();
|
||||||
|
|
||||||
|
// Iterate all connected users in this realm
|
||||||
|
for entry in ws_state.users.iter() {
|
||||||
|
let (target_user_id, target_conn) = entry.pair();
|
||||||
|
if target_conn.realm_id == realm_id && *target_user_id != user_id {
|
||||||
|
// Send Summoned message to each user
|
||||||
|
let summon_msg = ServerMessage::Summoned {
|
||||||
|
scene_id: mod_scene.id,
|
||||||
|
scene_slug: mod_scene.slug.clone(),
|
||||||
|
summoned_by: mod_member.display_name.clone(),
|
||||||
|
};
|
||||||
|
if target_conn.direct_tx.send(summon_msg).await.is_ok() {
|
||||||
|
summoned_count += 1;
|
||||||
|
target_ids.push(*target_user_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the action
|
||||||
|
let metadata = serde_json::json!({
|
||||||
|
"scene_id": mod_scene.id,
|
||||||
|
"scene_slug": mod_scene.slug,
|
||||||
|
"summoned_count": summoned_count,
|
||||||
|
});
|
||||||
|
let _ = moderation::log_moderation_action(
|
||||||
|
&pool,
|
||||||
|
realm_id,
|
||||||
|
user_id,
|
||||||
|
ActionType::SummonAll,
|
||||||
|
None,
|
||||||
|
&format!("Summoned {} users to scene {}", summoned_count, mod_scene.name),
|
||||||
|
metadata,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||||
|
success: true,
|
||||||
|
message: format!("Summoned {} users to {}", summoned_count, mod_scene.name),
|
||||||
|
}).await;
|
||||||
|
} else {
|
||||||
|
// Summon specific user by display name
|
||||||
|
if let Some((target_user_id, target_conn)) = ws_state
|
||||||
|
.find_user_by_display_name(realm_id, target)
|
||||||
|
{
|
||||||
|
if target_user_id == user_id {
|
||||||
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||||
|
success: false,
|
||||||
|
message: "You cannot summon yourself".to_string(),
|
||||||
|
}).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send Summoned message to target
|
||||||
|
let summon_msg = ServerMessage::Summoned {
|
||||||
|
scene_id: mod_scene.id,
|
||||||
|
scene_slug: mod_scene.slug.clone(),
|
||||||
|
summoned_by: mod_member.display_name.clone(),
|
||||||
|
};
|
||||||
|
if target_conn.direct_tx.send(summon_msg).await.is_ok() {
|
||||||
|
// Log the action
|
||||||
|
let metadata = serde_json::json!({
|
||||||
|
"scene_id": mod_scene.id,
|
||||||
|
"scene_slug": mod_scene.slug,
|
||||||
|
"target_display_name": target,
|
||||||
|
});
|
||||||
|
let _ = moderation::log_moderation_action(
|
||||||
|
&pool,
|
||||||
|
realm_id,
|
||||||
|
user_id,
|
||||||
|
ActionType::Summon,
|
||||||
|
Some(target_user_id),
|
||||||
|
&format!("Summoned {} to scene {}", target, mod_scene.name),
|
||||||
|
metadata,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||||
|
success: true,
|
||||||
|
message: format!("Summoned {} to {}", target, mod_scene.name),
|
||||||
|
}).await;
|
||||||
|
} else {
|
||||||
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||||
|
success: false,
|
||||||
|
message: format!("Failed to send summon to {}", target),
|
||||||
|
}).await;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||||
|
success: false,
|
||||||
|
message: format!("User '{}' is not online in this realm", target),
|
||||||
|
}).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"teleport" => {
|
||||||
|
if args.len() < 2 {
|
||||||
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||||
|
success: false,
|
||||||
|
message: "Usage: /mod teleport [nick] [slug]".to_string(),
|
||||||
|
}).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let target_nick = &args[0];
|
||||||
|
let target_slug = &args[1];
|
||||||
|
|
||||||
|
// Look up the target scene by slug
|
||||||
|
let target_scene = match scenes::get_scene_by_slug(&pool, realm_id, target_slug).await {
|
||||||
|
Ok(Some(s)) => s,
|
||||||
|
Ok(None) => {
|
||||||
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||||
|
success: false,
|
||||||
|
message: format!("Scene '{}' not found", target_slug),
|
||||||
|
}).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||||
|
success: false,
|
||||||
|
message: "Failed to look up scene".to_string(),
|
||||||
|
}).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find target user by display name
|
||||||
|
if let Some((target_user_id, target_conn)) = ws_state
|
||||||
|
.find_user_by_display_name(realm_id, target_nick)
|
||||||
|
{
|
||||||
|
if target_user_id == user_id {
|
||||||
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||||
|
success: false,
|
||||||
|
message: "You cannot teleport yourself".to_string(),
|
||||||
|
}).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send Summoned message to target user with the specified scene
|
||||||
|
let teleport_msg = ServerMessage::Summoned {
|
||||||
|
scene_id: target_scene.id,
|
||||||
|
scene_slug: target_scene.slug.clone(),
|
||||||
|
summoned_by: mod_member.display_name.clone(),
|
||||||
|
};
|
||||||
|
if target_conn.direct_tx.send(teleport_msg).await.is_ok() {
|
||||||
|
// Log the action
|
||||||
|
let metadata = serde_json::json!({
|
||||||
|
"scene_id": target_scene.id,
|
||||||
|
"scene_slug": target_scene.slug,
|
||||||
|
"target_display_name": target_nick,
|
||||||
|
});
|
||||||
|
let _ = moderation::log_moderation_action(
|
||||||
|
&pool,
|
||||||
|
realm_id,
|
||||||
|
user_id,
|
||||||
|
ActionType::Teleport,
|
||||||
|
Some(target_user_id),
|
||||||
|
&format!("Teleported {} to scene {}", target_nick, target_scene.name),
|
||||||
|
metadata,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||||
|
success: true,
|
||||||
|
message: format!("Teleported {} to {}", target_nick, target_scene.name),
|
||||||
|
}).await;
|
||||||
|
} else {
|
||||||
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||||
|
success: false,
|
||||||
|
message: format!("Failed to send teleport to {}", target_nick),
|
||||||
|
}).await;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||||
|
success: false,
|
||||||
|
message: format!("User '{}' is not online in this realm", target_nick),
|
||||||
|
}).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||||
|
success: false,
|
||||||
|
message: format!("Unknown mod command: {}", subcommand),
|
||||||
|
}).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Message::Close(close_frame) => {
|
Message::Close(close_frame) => {
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,12 @@ pub mod conversation_modal;
|
||||||
pub mod editor;
|
pub mod editor;
|
||||||
pub mod emotion_picker;
|
pub mod emotion_picker;
|
||||||
pub mod forms;
|
pub mod forms;
|
||||||
|
pub mod hotkey_help;
|
||||||
pub mod inventory;
|
pub mod inventory;
|
||||||
pub mod keybindings;
|
pub mod keybindings;
|
||||||
pub mod keybindings_popup;
|
pub mod keybindings_popup;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
|
pub mod log_popup;
|
||||||
pub mod modals;
|
pub mod modals;
|
||||||
pub mod notification_history;
|
pub mod notification_history;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
|
|
@ -33,10 +35,12 @@ pub use conversation_modal::*;
|
||||||
pub use editor::*;
|
pub use editor::*;
|
||||||
pub use emotion_picker::*;
|
pub use emotion_picker::*;
|
||||||
pub use forms::*;
|
pub use forms::*;
|
||||||
|
pub use hotkey_help::*;
|
||||||
pub use inventory::*;
|
pub use inventory::*;
|
||||||
pub use keybindings::*;
|
pub use keybindings::*;
|
||||||
pub use keybindings_popup::*;
|
pub use keybindings_popup::*;
|
||||||
pub use layout::*;
|
pub use layout::*;
|
||||||
|
pub use log_popup::*;
|
||||||
pub use modals::*;
|
pub use modals::*;
|
||||||
pub use notification_history::*;
|
pub use notification_history::*;
|
||||||
pub use notifications::*;
|
pub use notifications::*;
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,10 @@ enum CommandMode {
|
||||||
None,
|
None,
|
||||||
/// Showing command hint for colon commands (`:e[mote], :l[ist]`).
|
/// Showing command hint for colon commands (`:e[mote], :l[ist]`).
|
||||||
ShowingColonHint,
|
ShowingColonHint,
|
||||||
/// Showing command hint for slash commands (`/setting`).
|
/// Showing command hint for slash commands (`/setting`, `/mod` for mods).
|
||||||
ShowingSlashHint,
|
ShowingSlashHint,
|
||||||
|
/// Showing mod command hints only (`/mod summon [nick|*]`).
|
||||||
|
ShowingModHint,
|
||||||
/// Showing emotion list popup.
|
/// Showing emotion list popup.
|
||||||
ShowingList,
|
ShowingList,
|
||||||
/// Showing scene list popup for teleport.
|
/// Showing scene list popup for teleport.
|
||||||
|
|
@ -98,6 +100,35 @@ fn parse_whisper_command(cmd: &str) -> Option<(String, String)> {
|
||||||
Some((name, message))
|
Some((name, message))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse a mod command and return (subcommand, args) if valid.
|
||||||
|
///
|
||||||
|
/// Supports `/mod summon [nick|*]` etc.
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
fn parse_mod_command(cmd: &str) -> Option<(String, Vec<String>)> {
|
||||||
|
let cmd = cmd.trim();
|
||||||
|
|
||||||
|
// Strip the leading slash if present
|
||||||
|
let cmd = cmd.strip_prefix('/').unwrap_or(cmd);
|
||||||
|
|
||||||
|
// Check for `mod <subcommand> [args...]`
|
||||||
|
let rest = cmd.strip_prefix("mod ").map(str::trim)?;
|
||||||
|
|
||||||
|
if rest.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split into parts
|
||||||
|
let parts: Vec<&str> = rest.split_whitespace().collect();
|
||||||
|
if parts.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let subcommand = parts[0].to_lowercase();
|
||||||
|
let args: Vec<String> = parts[1..].iter().map(|s| s.to_string()).collect();
|
||||||
|
|
||||||
|
Some((subcommand, args))
|
||||||
|
}
|
||||||
|
|
||||||
/// Chat input component with emote command support.
|
/// Chat input component with emote command support.
|
||||||
///
|
///
|
||||||
/// Props:
|
/// Props:
|
||||||
|
|
@ -109,6 +140,7 @@ fn parse_whisper_command(cmd: &str) -> Option<(String, String)> {
|
||||||
/// - `on_focus_change`: Callback when focus state changes
|
/// - `on_focus_change`: Callback when focus state changes
|
||||||
/// - `on_open_settings`: Callback to open settings popup
|
/// - `on_open_settings`: Callback to open settings popup
|
||||||
/// - `on_open_inventory`: Callback to open inventory popup
|
/// - `on_open_inventory`: Callback to open inventory popup
|
||||||
|
/// - `on_open_log`: Callback to open message log popup
|
||||||
/// - `whisper_target`: Signal containing the display name to whisper to (triggers pre-fill)
|
/// - `whisper_target`: Signal containing the display name to whisper to (triggers pre-fill)
|
||||||
/// - `scenes`: List of available scenes for teleport command
|
/// - `scenes`: List of available scenes for teleport command
|
||||||
/// - `allow_user_teleport`: Whether teleporting is enabled for this realm
|
/// - `allow_user_teleport`: Whether teleporting is enabled for this realm
|
||||||
|
|
@ -123,9 +155,13 @@ pub fn ChatInput(
|
||||||
on_focus_change: Callback<bool>,
|
on_focus_change: Callback<bool>,
|
||||||
#[prop(optional)] on_open_settings: Option<Callback<()>>,
|
#[prop(optional)] on_open_settings: Option<Callback<()>>,
|
||||||
#[prop(optional)] on_open_inventory: Option<Callback<()>>,
|
#[prop(optional)] on_open_inventory: Option<Callback<()>>,
|
||||||
|
/// Callback to open message log popup.
|
||||||
|
#[prop(optional)]
|
||||||
|
on_open_log: Option<Callback<()>>,
|
||||||
/// Signal containing the display name to whisper to. When set, pre-fills the input.
|
/// Signal containing the display name to whisper to. When set, pre-fills the input.
|
||||||
#[prop(optional, into)]
|
/// Uses RwSignal so the component can clear it after consuming.
|
||||||
whisper_target: Option<Signal<Option<String>>>,
|
#[prop(optional)]
|
||||||
|
whisper_target: Option<RwSignal<Option<String>>>,
|
||||||
/// List of available scenes for teleport command.
|
/// List of available scenes for teleport command.
|
||||||
#[prop(optional, into)]
|
#[prop(optional, into)]
|
||||||
scenes: Option<Signal<Vec<SceneSummary>>>,
|
scenes: Option<Signal<Vec<SceneSummary>>>,
|
||||||
|
|
@ -135,6 +171,12 @@ pub fn ChatInput(
|
||||||
/// Callback when a teleport is requested.
|
/// Callback when a teleport is requested.
|
||||||
#[prop(optional)]
|
#[prop(optional)]
|
||||||
on_teleport: Option<Callback<Uuid>>,
|
on_teleport: Option<Callback<Uuid>>,
|
||||||
|
/// Whether the current user is a moderator.
|
||||||
|
#[prop(default = Signal::derive(|| false))]
|
||||||
|
is_moderator: Signal<bool>,
|
||||||
|
/// Callback to send a mod command.
|
||||||
|
#[prop(optional)]
|
||||||
|
on_mod_command: Option<Callback<(String, Vec<String>)>>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let (message, set_message) = signal(String::new());
|
let (message, set_message) = signal(String::new());
|
||||||
let (command_mode, set_command_mode) = signal(CommandMode::None);
|
let (command_mode, set_command_mode) = signal(CommandMode::None);
|
||||||
|
|
@ -236,6 +278,9 @@ pub fn ChatInput(
|
||||||
let _ = input.focus();
|
let _ = input.focus();
|
||||||
let len = whisper_prefix.len() as u32;
|
let len = whisper_prefix.len() as u32;
|
||||||
let _ = input.set_selection_range(len, len);
|
let _ = input.set_selection_range(len, len);
|
||||||
|
|
||||||
|
// Clear the whisper target so it doesn't re-trigger on re-render
|
||||||
|
whisper_signal.set(None);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -316,21 +361,37 @@ pub fn ChatInput(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check if typing mod command (only for moderators)
|
||||||
|
// Show mod hint when typing "/mod" or "/mod ..."
|
||||||
|
let is_typing_mod = is_moderator.get_untracked()
|
||||||
|
&& (cmd == "mod" || cmd.starts_with("mod "));
|
||||||
|
// Show /mod in slash hints when just starting to type it
|
||||||
|
let is_partial_mod = is_moderator.get_untracked()
|
||||||
|
&& !cmd.is_empty()
|
||||||
|
&& "mod".starts_with(&cmd)
|
||||||
|
&& cmd != "mod";
|
||||||
|
|
||||||
if is_complete_whisper || is_complete_teleport {
|
if is_complete_whisper || is_complete_teleport {
|
||||||
// User is typing the argument part, no hint needed
|
// User is typing the argument part, no hint needed
|
||||||
set_command_mode.set(CommandMode::None);
|
set_command_mode.set(CommandMode::None);
|
||||||
|
} else if is_typing_mod {
|
||||||
|
// Show mod-specific hint bar
|
||||||
|
set_command_mode.set(CommandMode::ShowingModHint);
|
||||||
} else if cmd.is_empty()
|
} else if cmd.is_empty()
|
||||||
|| "setting".starts_with(&cmd)
|
|| "setting".starts_with(&cmd)
|
||||||
|| "inventory".starts_with(&cmd)
|
|| "inventory".starts_with(&cmd)
|
||||||
|| "whisper".starts_with(&cmd)
|
|| "whisper".starts_with(&cmd)
|
||||||
|| "teleport".starts_with(&cmd)
|
|| "teleport".starts_with(&cmd)
|
||||||
|
|| "log".starts_with(&cmd)
|
||||||
|| cmd == "setting"
|
|| cmd == "setting"
|
||||||
|| cmd == "settings"
|
|| cmd == "settings"
|
||||||
|| cmd == "inventory"
|
|| cmd == "inventory"
|
||||||
|
|| cmd == "log"
|
||||||
|| cmd.starts_with("w ")
|
|| cmd.starts_with("w ")
|
||||||
|| cmd.starts_with("whisper ")
|
|| cmd.starts_with("whisper ")
|
||||||
|| cmd.starts_with("t ")
|
|| cmd.starts_with("t ")
|
||||||
|| cmd.starts_with("teleport ")
|
|| cmd.starts_with("teleport ")
|
||||||
|
|| is_partial_mod
|
||||||
{
|
{
|
||||||
set_command_mode.set(CommandMode::ShowingSlashHint);
|
set_command_mode.set(CommandMode::ShowingSlashHint);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -348,6 +409,7 @@ pub fn ChatInput(
|
||||||
let apply_emotion = apply_emotion.clone();
|
let apply_emotion = apply_emotion.clone();
|
||||||
let on_open_settings = on_open_settings.clone();
|
let on_open_settings = on_open_settings.clone();
|
||||||
let on_open_inventory = on_open_inventory.clone();
|
let on_open_inventory = on_open_inventory.clone();
|
||||||
|
let on_open_log = on_open_log.clone();
|
||||||
move |ev: web_sys::KeyboardEvent| {
|
move |ev: web_sys::KeyboardEvent| {
|
||||||
let key = ev.key();
|
let key = ev.key();
|
||||||
let current_mode = command_mode.get_untracked();
|
let current_mode = command_mode.get_untracked();
|
||||||
|
|
@ -484,6 +546,20 @@ pub fn ChatInput(
|
||||||
ev.prevent_default();
|
ev.prevent_default();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Autocomplete to /log if /l, /lo (but not if it could be /list or /teleport)
|
||||||
|
// Only match /l if it's exactly /l (not /li which would match /list)
|
||||||
|
if !cmd.is_empty()
|
||||||
|
&& "log".starts_with(&cmd)
|
||||||
|
&& cmd != "log"
|
||||||
|
&& !cmd.starts_with("li")
|
||||||
|
{
|
||||||
|
set_message.set("/log".to_string());
|
||||||
|
if let Some(input) = input_ref.get() {
|
||||||
|
input.set_value("/log");
|
||||||
|
}
|
||||||
|
ev.prevent_default();
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Always prevent Tab from moving focus when in input
|
// Always prevent Tab from moving focus when in input
|
||||||
ev.prevent_default();
|
ev.prevent_default();
|
||||||
|
|
@ -527,6 +603,21 @@ pub fn ChatInput(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /l, /lo, /log - open message log
|
||||||
|
if !cmd.is_empty() && "log".starts_with(&cmd) {
|
||||||
|
if let Some(ref callback) = on_open_log {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// /w NAME message or /whisper NAME message
|
// /w NAME message or /whisper NAME message
|
||||||
if let Some((target_name, whisper_content)) = parse_whisper_command(&msg) {
|
if let Some((target_name, whisper_content)) = parse_whisper_command(&msg) {
|
||||||
if !whisper_content.trim().is_empty() {
|
if !whisper_content.trim().is_empty() {
|
||||||
|
|
@ -585,6 +676,22 @@ pub fn ChatInput(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /mod <subcommand> [args...] - execute mod command
|
||||||
|
if is_moderator.get_untracked() {
|
||||||
|
if let Some((subcommand, args)) = parse_mod_command(&msg) {
|
||||||
|
if let Some(ref callback) = on_mod_command {
|
||||||
|
callback.run((subcommand, args));
|
||||||
|
}
|
||||||
|
set_message.set(String::new());
|
||||||
|
set_command_mode.set(CommandMode::None);
|
||||||
|
if let Some(input) = input_ref.get() {
|
||||||
|
input.set_value("");
|
||||||
|
}
|
||||||
|
ev.prevent_default();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Invalid slash command - just ignore, don't send
|
// Invalid slash command - just ignore, don't send
|
||||||
ev.prevent_default();
|
ev.prevent_default();
|
||||||
return;
|
return;
|
||||||
|
|
@ -702,7 +809,7 @@ pub fn ChatInput(
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
// Slash command hint bar (/s[etting], /i[nventory], /w[hisper], /t[eleport])
|
// Slash command hint bar (/s[etting], /i[nventory], /w[hisper], /l[og], /t[eleport])
|
||||||
<Show when=move || command_mode.get() == CommandMode::ShowingSlashHint>
|
<Show when=move || command_mode.get() == CommandMode::ShowingSlashHint>
|
||||||
<div class="absolute bottom-full left-0 mb-1 px-3 py-1 bg-gray-700/90 backdrop-blur-sm rounded text-sm">
|
<div class="absolute bottom-full left-0 mb-1 px-3 py-1 bg-gray-700/90 backdrop-blur-sm rounded text-sm">
|
||||||
<span class="text-gray-400">"/"</span>
|
<span class="text-gray-400">"/"</span>
|
||||||
|
|
@ -716,12 +823,35 @@ pub fn ChatInput(
|
||||||
<span class="text-gray-400">"/"</span>
|
<span class="text-gray-400">"/"</span>
|
||||||
<span class="text-blue-400">"w"</span>
|
<span class="text-blue-400">"w"</span>
|
||||||
<span class="text-gray-500">"[hisper] name"</span>
|
<span class="text-gray-500">"[hisper] name"</span>
|
||||||
|
<span class="text-gray-600 mx-2">"|"</span>
|
||||||
|
<span class="text-gray-400">"/"</span>
|
||||||
|
<span class="text-blue-400">"l"</span>
|
||||||
|
<span class="text-gray-500">"[og]"</span>
|
||||||
<Show when=move || allow_user_teleport.get()>
|
<Show when=move || allow_user_teleport.get()>
|
||||||
<span class="text-gray-600 mx-2">"|"</span>
|
<span class="text-gray-600 mx-2">"|"</span>
|
||||||
<span class="text-gray-400">"/"</span>
|
<span class="text-gray-400">"/"</span>
|
||||||
<span class="text-blue-400">"t"</span>
|
<span class="text-blue-400">"t"</span>
|
||||||
<span class="text-gray-500">"[eleport]"</span>
|
<span class="text-gray-500">"[eleport]"</span>
|
||||||
</Show>
|
</Show>
|
||||||
|
// Show /mod hint for moderators (details shown when typing /mod)
|
||||||
|
<Show when=move || is_moderator.get()>
|
||||||
|
<span class="text-gray-600 mx-2">"|"</span>
|
||||||
|
<span class="text-purple-400">"/"</span>
|
||||||
|
<span class="text-purple-400">"mod"</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
// Mod command hint bar (shown when typing /mod)
|
||||||
|
<Show when=move || command_mode.get() == CommandMode::ShowingModHint>
|
||||||
|
<div class="absolute bottom-full left-0 mb-1 px-3 py-1 bg-purple-900/90 backdrop-blur-sm rounded text-sm">
|
||||||
|
<span class="text-purple-400">"/"</span>
|
||||||
|
<span class="text-purple-400">"mod"</span>
|
||||||
|
<span class="text-purple-300">" summon"</span>
|
||||||
|
<span class="text-purple-500">" [nick|*]"</span>
|
||||||
|
<span class="text-purple-600">" | "</span>
|
||||||
|
<span class="text-purple-300">"teleport"</span>
|
||||||
|
<span class="text-purple-500">" [nick] [slug]"</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
|
|
||||||
106
crates/chattyness-user-ui/src/components/hotkey_help.rs
Normal file
106
crates/chattyness-user-ui/src/components/hotkey_help.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
//! Hotkey help overlay component.
|
||||||
|
//!
|
||||||
|
//! Displays available keyboard shortcuts while held.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
/// Hotkey help overlay that shows available keyboard shortcuts.
|
||||||
|
///
|
||||||
|
/// This component displays while the user holds down the `?` key.
|
||||||
|
#[component]
|
||||||
|
pub fn HotkeyHelp(
|
||||||
|
/// Whether the help overlay is visible.
|
||||||
|
#[prop(into)]
|
||||||
|
visible: Signal<bool>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let outer_class = move || {
|
||||||
|
if visible.get() {
|
||||||
|
"fixed inset-0 z-50 flex items-center justify-center pointer-events-none"
|
||||||
|
} else {
|
||||||
|
"hidden"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class=outer_class>
|
||||||
|
// Semi-transparent backdrop
|
||||||
|
<div class="absolute inset-0 bg-black/60" aria-hidden="true" />
|
||||||
|
|
||||||
|
// Help content
|
||||||
|
<div class="relative bg-gray-800/95 backdrop-blur-sm rounded-lg shadow-2xl p-6 border border-gray-600 max-w-md">
|
||||||
|
<h2 class="text-lg font-bold text-white mb-4 text-center">
|
||||||
|
"Keyboard Shortcuts"
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||||
|
// Navigation & Chat
|
||||||
|
<div class="col-span-2 text-gray-400 font-medium mt-2 first:mt-0">
|
||||||
|
"Navigation & Chat"
|
||||||
|
</div>
|
||||||
|
<HotkeyRow key="Space" description="Focus chat input" />
|
||||||
|
<HotkeyRow key=":" description="Open emote commands" />
|
||||||
|
<HotkeyRow key="/" description="Open slash commands" />
|
||||||
|
<HotkeyRow key="Esc" description="Close/unfocus" />
|
||||||
|
|
||||||
|
// Popups
|
||||||
|
<div class="col-span-2 text-gray-400 font-medium mt-3">
|
||||||
|
"Popups"
|
||||||
|
</div>
|
||||||
|
<HotkeyRow key="s" description="Settings" />
|
||||||
|
<HotkeyRow key="i" description="Inventory" />
|
||||||
|
<HotkeyRow key="k" description="Keybindings" />
|
||||||
|
<HotkeyRow key="a" description="Avatar editor" />
|
||||||
|
<HotkeyRow key="l" description="Message log" />
|
||||||
|
|
||||||
|
// Emotions
|
||||||
|
<div class="col-span-2 text-gray-400 font-medium mt-3">
|
||||||
|
"Emotions"
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2 text-gray-300">
|
||||||
|
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-xs">"e"</kbd>
|
||||||
|
" + "
|
||||||
|
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-xs">"0-9"</kbd>
|
||||||
|
" / "
|
||||||
|
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-xs">"q"</kbd>
|
||||||
|
" / "
|
||||||
|
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-xs">"w"</kbd>
|
||||||
|
<span class="text-gray-500 ml-2">"Apply emotion"</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// View controls
|
||||||
|
<div class="col-span-2 text-gray-400 font-medium mt-3">
|
||||||
|
"View (when panning enabled)"
|
||||||
|
</div>
|
||||||
|
<HotkeyRow key="Arrows" description="Pan view" />
|
||||||
|
<HotkeyRow key="+/-" description="Zoom in/out" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 pt-3 border-t border-gray-600 text-xs text-gray-500 text-center">
|
||||||
|
"Release " <kbd class="px-1.5 py-0.5 bg-gray-700 rounded">"?"</kbd> " to close"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single hotkey row with key and description.
|
||||||
|
#[component]
|
||||||
|
fn HotkeyRow(
|
||||||
|
/// The key or key combination.
|
||||||
|
key: &'static str,
|
||||||
|
/// Description of what the key does.
|
||||||
|
description: &'static str,
|
||||||
|
) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<div class="contents">
|
||||||
|
<div class="text-right">
|
||||||
|
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-xs text-gray-200">
|
||||||
|
{key}
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-300">
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@ use leptos::prelude::*;
|
||||||
use leptos::reactive::owner::LocalStorage;
|
use leptos::reactive::owner::LocalStorage;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use chattyness_db::models::{InventoryItem, PublicProp};
|
use chattyness_db::models::{InventoryItem, PropAcquisitionInfo};
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
use chattyness_db::ws_messages::ClientMessage;
|
use chattyness_db::ws_messages::ClientMessage;
|
||||||
|
|
||||||
|
|
@ -46,13 +46,13 @@ pub fn InventoryPopup(
|
||||||
let (selected_item, set_selected_item) = signal(Option::<Uuid>::None);
|
let (selected_item, set_selected_item) = signal(Option::<Uuid>::None);
|
||||||
let (dropping, set_dropping) = signal(false);
|
let (dropping, set_dropping) = signal(false);
|
||||||
|
|
||||||
// Server props state
|
// Server props state (with acquisition info for authenticated users)
|
||||||
let (server_props, set_server_props) = signal(Vec::<PublicProp>::new());
|
let (server_props, set_server_props) = signal(Vec::<PropAcquisitionInfo>::new());
|
||||||
let (server_loading, set_server_loading) = signal(false);
|
let (server_loading, set_server_loading) = signal(false);
|
||||||
let (server_error, set_server_error) = signal(Option::<String>::None);
|
let (server_error, set_server_error) = signal(Option::<String>::None);
|
||||||
|
|
||||||
// Realm props state
|
// Realm props state (with acquisition info for authenticated users)
|
||||||
let (realm_props, set_realm_props) = signal(Vec::<PublicProp>::new());
|
let (realm_props, set_realm_props) = signal(Vec::<PropAcquisitionInfo>::new());
|
||||||
let (realm_loading, set_realm_loading) = signal(false);
|
let (realm_loading, set_realm_loading) = signal(false);
|
||||||
let (realm_error, set_realm_error) = signal(Option::<String>::None);
|
let (realm_error, set_realm_error) = signal(Option::<String>::None);
|
||||||
|
|
||||||
|
|
@ -61,6 +61,9 @@ pub fn InventoryPopup(
|
||||||
let (server_loaded, set_server_loaded) = signal(false);
|
let (server_loaded, set_server_loaded) = signal(false);
|
||||||
let (realm_loaded, set_realm_loaded) = signal(false);
|
let (realm_loaded, set_realm_loaded) = signal(false);
|
||||||
|
|
||||||
|
// Trigger to refresh my inventory after acquisition
|
||||||
|
let (inventory_refresh_trigger, set_inventory_refresh_trigger) = signal(0u32);
|
||||||
|
|
||||||
// Fetch my inventory when popup opens or tab is selected
|
// Fetch my inventory when popup opens or tab is selected
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
{
|
{
|
||||||
|
|
@ -68,6 +71,9 @@ pub fn InventoryPopup(
|
||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
|
|
||||||
Effect::new(move |_| {
|
Effect::new(move |_| {
|
||||||
|
// Track refresh trigger to refetch after acquisition
|
||||||
|
let _refresh = inventory_refresh_trigger.get();
|
||||||
|
|
||||||
if !open.get() {
|
if !open.get() {
|
||||||
// Reset state when closing
|
// Reset state when closing
|
||||||
set_selected_item.set(None);
|
set_selected_item.set(None);
|
||||||
|
|
@ -86,7 +92,7 @@ pub fn InventoryPopup(
|
||||||
set_error.set(None);
|
set_error.set(None);
|
||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
let response = Request::get("/api/inventory").send().await;
|
let response = Request::get("/api/user/me/inventory").send().await;
|
||||||
match response {
|
match response {
|
||||||
Ok(resp) if resp.ok() => {
|
Ok(resp) if resp.ok() => {
|
||||||
if let Ok(data) = resp
|
if let Ok(data) = resp
|
||||||
|
|
@ -112,6 +118,7 @@ pub fn InventoryPopup(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch server props when server tab is selected
|
// Fetch server props when server tab is selected
|
||||||
|
// Uses status endpoint if authenticated (non-guest), otherwise basic endpoint
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
{
|
{
|
||||||
use gloo_net::http::Request;
|
use gloo_net::http::Request;
|
||||||
|
|
@ -126,17 +133,19 @@ pub fn InventoryPopup(
|
||||||
set_server_error.set(None);
|
set_server_error.set(None);
|
||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
let response = Request::get("/api/inventory/server").send().await;
|
// Single endpoint returns enriched data if authenticated
|
||||||
|
let response = Request::get("/api/server/inventory").send().await;
|
||||||
match response {
|
match response {
|
||||||
Ok(resp) if resp.ok() => {
|
Ok(resp) if resp.ok() => {
|
||||||
if let Ok(data) = resp
|
if let Ok(data) = resp
|
||||||
.json::<chattyness_db::models::PublicPropsResponse>()
|
.json::<chattyness_db::models::PropAcquisitionListResponse>()
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
set_server_props.set(data.props);
|
set_server_props.set(data.props);
|
||||||
set_server_loaded.set(true);
|
set_server_loaded.set(true);
|
||||||
} else {
|
} else {
|
||||||
set_server_error.set(Some("Failed to parse server props".to_string()));
|
set_server_error
|
||||||
|
.set(Some("Failed to parse server props".to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
|
|
@ -175,19 +184,20 @@ pub fn InventoryPopup(
|
||||||
set_realm_error.set(None);
|
set_realm_error.set(None);
|
||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
let response = Request::get(&format!("/api/realms/{}/inventory", slug))
|
// Single endpoint returns enriched data if authenticated
|
||||||
.send()
|
let endpoint = format!("/api/realms/{}/inventory", slug);
|
||||||
.await;
|
let response = Request::get(&endpoint).send().await;
|
||||||
match response {
|
match response {
|
||||||
Ok(resp) if resp.ok() => {
|
Ok(resp) if resp.ok() => {
|
||||||
if let Ok(data) = resp
|
if let Ok(data) = resp
|
||||||
.json::<chattyness_db::models::PublicPropsResponse>()
|
.json::<chattyness_db::models::PropAcquisitionListResponse>()
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
set_realm_props.set(data.props);
|
set_realm_props.set(data.props);
|
||||||
set_realm_loaded.set(true);
|
set_realm_loaded.set(true);
|
||||||
} else {
|
} else {
|
||||||
set_realm_error.set(Some("Failed to parse realm props".to_string()));
|
set_realm_error
|
||||||
|
.set(Some("Failed to parse realm props".to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
|
|
@ -272,23 +282,40 @@ pub fn InventoryPopup(
|
||||||
|
|
||||||
// Server tab
|
// Server tab
|
||||||
<Show when=move || active_tab.get() == "server">
|
<Show when=move || active_tab.get() == "server">
|
||||||
<PublicPropsTab
|
<AcquisitionPropsTab
|
||||||
props=server_props
|
props=server_props
|
||||||
|
set_props=set_server_props
|
||||||
loading=server_loading
|
loading=server_loading
|
||||||
error=server_error
|
error=server_error
|
||||||
tab_name="Server"
|
tab_name="Server"
|
||||||
empty_message="No public server props available"
|
empty_message="No public server props available"
|
||||||
|
is_guest=is_guest
|
||||||
|
acquire_endpoint="/api/server/inventory/request"
|
||||||
|
on_acquired=Callback::new(move |_| {
|
||||||
|
// Trigger inventory refresh and reset loaded state
|
||||||
|
set_my_inventory_loaded.set(false);
|
||||||
|
set_inventory_refresh_trigger.update(|n| *n += 1);
|
||||||
|
})
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
// Realm tab
|
// Realm tab
|
||||||
<Show when=move || active_tab.get() == "realm">
|
<Show when=move || active_tab.get() == "realm">
|
||||||
<PublicPropsTab
|
<AcquisitionPropsTab
|
||||||
props=realm_props
|
props=realm_props
|
||||||
|
set_props=set_realm_props
|
||||||
loading=realm_loading
|
loading=realm_loading
|
||||||
error=realm_error
|
error=realm_error
|
||||||
tab_name="Realm"
|
tab_name="Realm"
|
||||||
empty_message="No public realm props available"
|
empty_message="No public realm props available"
|
||||||
|
is_guest=is_guest
|
||||||
|
acquire_endpoint_is_realm=true
|
||||||
|
realm_slug=realm_slug
|
||||||
|
on_acquired=Callback::new(move |_| {
|
||||||
|
// Trigger inventory refresh and reset loaded state
|
||||||
|
set_my_inventory_loaded.set(false);
|
||||||
|
set_inventory_refresh_trigger.update(|n| *n += 1);
|
||||||
|
})
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -450,17 +477,103 @@ fn MyInventoryTab(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Public props tab content (read-only display).
|
/// Acquisition props tab content with acquire functionality.
|
||||||
#[component]
|
#[component]
|
||||||
fn PublicPropsTab(
|
fn AcquisitionPropsTab(
|
||||||
#[prop(into)] props: Signal<Vec<PublicProp>>,
|
#[prop(into)] props: Signal<Vec<PropAcquisitionInfo>>,
|
||||||
|
set_props: WriteSignal<Vec<PropAcquisitionInfo>>,
|
||||||
#[prop(into)] loading: Signal<bool>,
|
#[prop(into)] loading: Signal<bool>,
|
||||||
#[prop(into)] error: Signal<Option<String>>,
|
#[prop(into)] error: Signal<Option<String>>,
|
||||||
tab_name: &'static str,
|
tab_name: &'static str,
|
||||||
empty_message: &'static str,
|
empty_message: &'static str,
|
||||||
|
#[prop(into)] is_guest: Signal<bool>,
|
||||||
|
/// Static endpoint for server props (e.g., "/api/server/inventory/request")
|
||||||
|
#[prop(optional)]
|
||||||
|
acquire_endpoint: Option<&'static str>,
|
||||||
|
/// Whether this is a realm props tab (uses dynamic endpoint with slug)
|
||||||
|
#[prop(optional, default = false)]
|
||||||
|
acquire_endpoint_is_realm: bool,
|
||||||
|
/// Realm slug for realm prop acquisition (required if acquire_endpoint_is_realm is true)
|
||||||
|
#[prop(optional, into)]
|
||||||
|
realm_slug: Signal<String>,
|
||||||
|
#[prop(into)] on_acquired: Callback<()>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
// Selected prop for showing details
|
// Selected prop for showing details
|
||||||
let (selected_prop, set_selected_prop) = signal(Option::<Uuid>::None);
|
let (selected_prop, set_selected_prop) = signal(Option::<Uuid>::None);
|
||||||
|
let (acquiring, set_acquiring) = signal(false);
|
||||||
|
let (acquire_error, set_acquire_error) = signal(Option::<String>::None);
|
||||||
|
|
||||||
|
// Handle acquire action
|
||||||
|
let acquire_endpoint_opt = acquire_endpoint.map(|s| s.to_string());
|
||||||
|
let do_acquire = Callback::new(move |prop_id: Uuid| {
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
set_acquiring.set(true);
|
||||||
|
set_acquire_error.set(None);
|
||||||
|
|
||||||
|
let endpoint = if acquire_endpoint_is_realm {
|
||||||
|
let slug = realm_slug.get();
|
||||||
|
if slug.is_empty() {
|
||||||
|
set_acquire_error.set(Some("No realm selected".to_string()));
|
||||||
|
set_acquiring.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
format!("/api/realms/{}/inventory/request", slug)
|
||||||
|
} else {
|
||||||
|
acquire_endpoint_opt.clone().unwrap_or_default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_acquired = on_acquired.clone();
|
||||||
|
|
||||||
|
leptos::task::spawn_local(async move {
|
||||||
|
use gloo_net::http::Request;
|
||||||
|
|
||||||
|
// Simple body with just prop_id - realm_id comes from URL for realm props
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"prop_id": prop_id
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = Request::post(&endpoint)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(body.to_string())
|
||||||
|
.unwrap()
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match response {
|
||||||
|
Ok(resp) if resp.ok() => {
|
||||||
|
// Update local state to mark prop as owned
|
||||||
|
set_props.update(|props| {
|
||||||
|
if let Some(prop) = props.iter_mut().find(|p| p.id == prop_id) {
|
||||||
|
prop.user_owns = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Notify parent to refresh inventory
|
||||||
|
on_acquired.run(());
|
||||||
|
}
|
||||||
|
Ok(resp) => {
|
||||||
|
// Try to parse error message from response
|
||||||
|
if let Ok(error_json) = resp.json::<serde_json::Value>().await {
|
||||||
|
let error_msg = error_json
|
||||||
|
.get("error")
|
||||||
|
.and_then(|e| e.as_str())
|
||||||
|
.unwrap_or("Unknown error");
|
||||||
|
set_acquire_error.set(Some(error_msg.to_string()));
|
||||||
|
} else {
|
||||||
|
set_acquire_error.set(Some(format!(
|
||||||
|
"Failed to acquire prop: {}",
|
||||||
|
resp.status()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
set_acquire_error.set(Some(format!("Network error: {}", e)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set_acquiring.set(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
// Loading state
|
// Loading state
|
||||||
|
|
@ -477,6 +590,13 @@ fn PublicPropsTab(
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
// Acquire error state
|
||||||
|
<Show when=move || acquire_error.get().is_some()>
|
||||||
|
<div class="bg-red-900/20 border border-red-600 rounded p-4 mb-4">
|
||||||
|
<p class="text-red-400">{move || acquire_error.get().unwrap_or_default()}</p>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
// Empty state
|
// Empty state
|
||||||
<Show when=move || !loading.get() && error.get().is_none() && props.get().is_empty()>
|
<Show when=move || !loading.get() && error.get().is_none() && props.get().is_empty()>
|
||||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
|
@ -501,7 +621,7 @@ fn PublicPropsTab(
|
||||||
<For
|
<For
|
||||||
each=move || props.get()
|
each=move || props.get()
|
||||||
key=|prop| prop.id
|
key=|prop| prop.id
|
||||||
children=move |prop: PublicProp| {
|
children=move |prop: PropAcquisitionInfo| {
|
||||||
let prop_id = prop.id;
|
let prop_id = prop.id;
|
||||||
let prop_name = prop.name.clone();
|
let prop_name = prop.name.clone();
|
||||||
let is_selected = move || selected_prop.get() == Some(prop_id);
|
let is_selected = move || selected_prop.get() == Some(prop_id);
|
||||||
|
|
@ -511,13 +631,21 @@ fn PublicPropsTab(
|
||||||
format!("/static/{}", prop.asset_path)
|
format!("/static/{}", prop.asset_path)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Visual indicator for ownership status
|
||||||
|
let user_owns = prop.user_owns;
|
||||||
|
let is_claimed = prop.is_claimed;
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class=move || format!(
|
class=move || format!(
|
||||||
"aspect-square rounded-lg border-2 transition-all p-1 focus:outline-none focus:ring-2 focus:ring-blue-500 {}",
|
"aspect-square rounded-lg border-2 transition-all p-1 focus:outline-none focus:ring-2 focus:ring-blue-500 relative {}",
|
||||||
if is_selected() {
|
if is_selected() {
|
||||||
"border-blue-500 bg-blue-900/30"
|
"border-blue-500 bg-blue-900/30"
|
||||||
|
} else if user_owns {
|
||||||
|
"border-green-500/50 bg-green-900/20"
|
||||||
|
} else if is_claimed {
|
||||||
|
"border-red-500/50 bg-red-900/20 opacity-50"
|
||||||
} else {
|
} else {
|
||||||
"border-gray-600 hover:border-gray-500 bg-gray-700/50"
|
"border-gray-600 hover:border-gray-500 bg-gray-700/50"
|
||||||
}
|
}
|
||||||
|
|
@ -534,27 +662,63 @@ fn PublicPropsTab(
|
||||||
alt=""
|
alt=""
|
||||||
class="w-full h-full object-contain"
|
class="w-full h-full object-contain"
|
||||||
/>
|
/>
|
||||||
|
// Ownership badge
|
||||||
|
<Show when=move || user_owns>
|
||||||
|
<span class="absolute top-0 right-0 w-3 h-3 bg-green-500 rounded-full transform translate-x-1 -translate-y-1" title="Owned" />
|
||||||
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// Selected prop details (read-only)
|
// Selected prop details with acquire button
|
||||||
{move || {
|
{move || {
|
||||||
let prop_id = selected_prop.get()?;
|
let prop_id = selected_prop.get()?;
|
||||||
let prop = props.get().into_iter().find(|p| p.id == prop_id)?;
|
let prop = props.get().into_iter().find(|p| p.id == prop_id)?;
|
||||||
|
let guest = is_guest.get();
|
||||||
|
let is_acquiring = acquiring.get();
|
||||||
|
|
||||||
|
// Determine button state
|
||||||
|
let (button_text, button_class, button_disabled, button_title) = if guest {
|
||||||
|
("Sign in to Acquire", "bg-gray-600 text-gray-400 cursor-not-allowed", true, "Guests cannot acquire props")
|
||||||
|
} else if prop.user_owns {
|
||||||
|
("Already Owned", "bg-green-700 text-white cursor-default", true, "You already own this prop")
|
||||||
|
} else if prop.is_claimed && prop.is_unique {
|
||||||
|
("Claimed", "bg-red-700 text-white cursor-not-allowed", true, "This unique prop has been claimed by another user")
|
||||||
|
} else if !prop.is_available {
|
||||||
|
("Unavailable", "bg-gray-600 text-gray-400 cursor-not-allowed", true, "This prop is not currently available")
|
||||||
|
} else if is_acquiring {
|
||||||
|
("Acquiring...", "bg-blue-600 text-white opacity-50", true, "")
|
||||||
|
} else {
|
||||||
|
("Acquire", "bg-blue-600 hover:bg-blue-700 text-white", false, "Add this prop to your inventory")
|
||||||
|
};
|
||||||
|
|
||||||
|
let prop_name = prop.name.clone();
|
||||||
|
let prop_description = prop.description.clone();
|
||||||
|
|
||||||
Some(view! {
|
Some(view! {
|
||||||
<div class="mt-4 pt-4 border-t border-gray-700">
|
<div class="mt-4 pt-4 border-t border-gray-700">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-white font-medium">{prop.name.clone()}</h3>
|
<h3 class="text-white font-medium">{prop_name}</h3>
|
||||||
{prop.description.map(|desc| view! {
|
{prop_description.map(|desc| view! {
|
||||||
<p class="text-gray-400 text-sm">{desc}</p>
|
<p class="text-gray-400 text-sm">{desc}</p>
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-gray-500 text-sm italic">"View only"</p>
|
<button
|
||||||
|
type="button"
|
||||||
|
class=format!("px-4 py-2 rounded-lg transition-colors {}", button_class)
|
||||||
|
on:click=move |_| {
|
||||||
|
if !button_disabled {
|
||||||
|
do_acquire.run(prop_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
disabled=button_disabled
|
||||||
|
title=button_title
|
||||||
|
>
|
||||||
|
{button_text}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
})
|
})
|
||||||
|
|
@ -563,3 +727,4 @@ fn PublicPropsTab(
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
206
crates/chattyness-user-ui/src/components/log_popup.rs
Normal file
206
crates/chattyness-user-ui/src/components/log_popup.rs
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
//! Message log popup component.
|
||||||
|
//!
|
||||||
|
//! Displays a filterable chronological log of received messages.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos::reactive::owner::LocalStorage;
|
||||||
|
|
||||||
|
use super::chat_types::{ChatMessage, MessageLog};
|
||||||
|
use super::modals::Modal;
|
||||||
|
|
||||||
|
/// Filter mode for message log display.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum LogFilter {
|
||||||
|
/// Show all messages.
|
||||||
|
#[default]
|
||||||
|
All,
|
||||||
|
/// Show only broadcast chat messages.
|
||||||
|
Chat,
|
||||||
|
/// Show only whispers.
|
||||||
|
Whispers,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Message log popup component.
|
||||||
|
///
|
||||||
|
/// Displays a filterable list of messages from the session.
|
||||||
|
#[component]
|
||||||
|
pub fn LogPopup(
|
||||||
|
#[prop(into)] open: Signal<bool>,
|
||||||
|
message_log: StoredValue<MessageLog, LocalStorage>,
|
||||||
|
on_close: Callback<()>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let (filter, set_filter) = signal(LogFilter::All);
|
||||||
|
|
||||||
|
// Get filtered messages based on current filter
|
||||||
|
// Note: We read `open` to force re-evaluation when the modal opens,
|
||||||
|
// since StoredValue is not reactive.
|
||||||
|
let filtered_messages = move || {
|
||||||
|
// Reading open ensures we re-fetch messages when modal opens
|
||||||
|
let _ = open.get();
|
||||||
|
let current_filter = filter.get();
|
||||||
|
message_log.with_value(|log| {
|
||||||
|
log.all_messages()
|
||||||
|
.iter()
|
||||||
|
.filter(|msg| match current_filter {
|
||||||
|
LogFilter::All => true,
|
||||||
|
LogFilter::Chat => !msg.is_whisper,
|
||||||
|
LogFilter::Whispers => msg.is_whisper,
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-scroll to bottom when modal opens
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
Effect::new(move |_| {
|
||||||
|
if open.get() {
|
||||||
|
// Use a small delay to ensure the DOM is rendered
|
||||||
|
use gloo_timers::callback::Timeout;
|
||||||
|
Timeout::new(50, || {
|
||||||
|
if let Some(document) = web_sys::window().and_then(|w| w.document()) {
|
||||||
|
if let Some(container) = document
|
||||||
|
.query_selector(".log-popup-messages")
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
|
let scroll_height = container.scroll_height();
|
||||||
|
container.set_scroll_top(scroll_height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.forget();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let tab_class = |is_active: bool| {
|
||||||
|
if is_active {
|
||||||
|
"px-3 py-1.5 rounded text-sm font-medium bg-blue-600 text-white"
|
||||||
|
} else {
|
||||||
|
"px-3 py-1.5 rounded text-sm font-medium bg-gray-700 text-gray-300 hover:bg-gray-600"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Modal
|
||||||
|
open=open
|
||||||
|
on_close=on_close.clone()
|
||||||
|
title="Message Log"
|
||||||
|
title_id="message-log-title"
|
||||||
|
max_width="max-w-2xl"
|
||||||
|
>
|
||||||
|
// Filter tabs
|
||||||
|
<div class="flex gap-2 mb-4">
|
||||||
|
<button
|
||||||
|
class=move || tab_class(filter.get() == LogFilter::All)
|
||||||
|
on:click=move |_| set_filter.set(LogFilter::All)
|
||||||
|
>
|
||||||
|
"All"
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class=move || tab_class(filter.get() == LogFilter::Chat)
|
||||||
|
on:click=move |_| set_filter.set(LogFilter::Chat)
|
||||||
|
>
|
||||||
|
"Chat"
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class=move || tab_class(filter.get() == LogFilter::Whispers)
|
||||||
|
on:click=move |_| set_filter.set(LogFilter::Whispers)
|
||||||
|
>
|
||||||
|
"Whispers"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Message list
|
||||||
|
<div class="log-popup-messages max-h-[70vh] overflow-y-auto bg-gray-900/50 rounded-lg p-2">
|
||||||
|
<Show
|
||||||
|
when=move || !filtered_messages().is_empty()
|
||||||
|
fallback=|| view! {
|
||||||
|
<p class="text-gray-400 text-center py-8">
|
||||||
|
"No messages yet"
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ul class="space-y-1 font-mono text-sm">
|
||||||
|
<For
|
||||||
|
each=move || filtered_messages()
|
||||||
|
key=|msg| msg.message_id
|
||||||
|
children=move |msg: ChatMessage| {
|
||||||
|
let is_whisper = msg.is_whisper;
|
||||||
|
let display_name = msg.display_name.clone();
|
||||||
|
let content = msg.content.clone();
|
||||||
|
let timestamp = msg.timestamp;
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<li class=move || {
|
||||||
|
if is_whisper {
|
||||||
|
"py-1 px-2 rounded bg-purple-900/30 border-l-2 border-purple-500"
|
||||||
|
} else {
|
||||||
|
"py-1 px-2"
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
<span class="text-gray-500">
|
||||||
|
"["
|
||||||
|
{format_timestamp(timestamp)}
|
||||||
|
"] "
|
||||||
|
</span>
|
||||||
|
<span class=move || {
|
||||||
|
if is_whisper {
|
||||||
|
"text-purple-300 font-medium"
|
||||||
|
} else {
|
||||||
|
"text-blue-300 font-medium"
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
{display_name}
|
||||||
|
</span>
|
||||||
|
<Show when=move || is_whisper>
|
||||||
|
<span class="text-purple-400 text-xs ml-1">
|
||||||
|
"(whisper)"
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<span class="text-gray-400">
|
||||||
|
": "
|
||||||
|
</span>
|
||||||
|
<span class=move || {
|
||||||
|
if is_whisper {
|
||||||
|
"text-gray-300 italic"
|
||||||
|
} else {
|
||||||
|
"text-gray-200"
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
{content}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Footer hint
|
||||||
|
<div class="mt-4 pt-4 border-t border-gray-600 text-xs text-gray-500 text-center">
|
||||||
|
"Press " <kbd class="px-1.5 py-0.5 bg-gray-700 rounded">"Esc"</kbd> " to close"
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a timestamp for display (HH:MM:SS).
|
||||||
|
fn format_timestamp(timestamp: i64) -> String {
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
let date = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(timestamp as f64));
|
||||||
|
let hours = date.get_hours();
|
||||||
|
let minutes = date.get_minutes();
|
||||||
|
let seconds = date.get_seconds();
|
||||||
|
format!("{:02}:{:02}:{:02}", hours, minutes, seconds)
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "hydrate"))]
|
||||||
|
{
|
||||||
|
let _ = timestamp;
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -88,6 +88,26 @@ pub struct TeleportInfo {
|
||||||
pub scene_slug: String,
|
pub scene_slug: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Summon information received from server (moderator summoned this user).
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct SummonInfo {
|
||||||
|
/// Scene ID to teleport to.
|
||||||
|
pub scene_id: uuid::Uuid,
|
||||||
|
/// Scene slug for URL.
|
||||||
|
pub scene_slug: String,
|
||||||
|
/// Display name of the moderator who summoned.
|
||||||
|
pub summoned_by: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of a moderator command.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ModCommandResultInfo {
|
||||||
|
/// Whether the command succeeded.
|
||||||
|
pub success: bool,
|
||||||
|
/// Human-readable result message.
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Hook to manage WebSocket connection for a channel.
|
/// Hook to manage WebSocket connection for a channel.
|
||||||
///
|
///
|
||||||
/// Returns a tuple of:
|
/// Returns a tuple of:
|
||||||
|
|
@ -107,6 +127,8 @@ pub fn use_channel_websocket(
|
||||||
on_welcome: Option<Callback<ChannelMemberInfo>>,
|
on_welcome: Option<Callback<ChannelMemberInfo>>,
|
||||||
on_error: Option<Callback<WsError>>,
|
on_error: Option<Callback<WsError>>,
|
||||||
on_teleport_approved: Option<Callback<TeleportInfo>>,
|
on_teleport_approved: Option<Callback<TeleportInfo>>,
|
||||||
|
on_summoned: Option<Callback<SummonInfo>>,
|
||||||
|
on_mod_command_result: Option<Callback<ModCommandResultInfo>>,
|
||||||
) -> (Signal<WsState>, WsSenderStorage) {
|
) -> (Signal<WsState>, WsSenderStorage) {
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
@ -220,6 +242,8 @@ pub fn use_channel_websocket(
|
||||||
let on_welcome_clone = on_welcome.clone();
|
let on_welcome_clone = on_welcome.clone();
|
||||||
let on_error_clone = on_error.clone();
|
let on_error_clone = on_error.clone();
|
||||||
let on_teleport_approved_clone = on_teleport_approved.clone();
|
let on_teleport_approved_clone = on_teleport_approved.clone();
|
||||||
|
let on_summoned_clone = on_summoned.clone();
|
||||||
|
let on_mod_command_result_clone = on_mod_command_result.clone();
|
||||||
// For starting heartbeat on Welcome
|
// For starting heartbeat on Welcome
|
||||||
let ws_ref_for_heartbeat = ws_ref.clone();
|
let ws_ref_for_heartbeat = ws_ref.clone();
|
||||||
let heartbeat_started: Rc<RefCell<bool>> = Rc::new(RefCell::new(false));
|
let heartbeat_started: Rc<RefCell<bool>> = Rc::new(RefCell::new(false));
|
||||||
|
|
@ -293,6 +317,8 @@ pub fn use_channel_websocket(
|
||||||
&on_member_fading_clone,
|
&on_member_fading_clone,
|
||||||
&on_error_clone,
|
&on_error_clone,
|
||||||
&on_teleport_approved_clone,
|
&on_teleport_approved_clone,
|
||||||
|
&on_summoned_clone,
|
||||||
|
&on_mod_command_result_clone,
|
||||||
¤t_user_id_for_msg,
|
¤t_user_id_for_msg,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -402,6 +428,8 @@ fn handle_server_message(
|
||||||
on_member_fading: &Callback<FadingMember>,
|
on_member_fading: &Callback<FadingMember>,
|
||||||
on_error: &Option<Callback<WsError>>,
|
on_error: &Option<Callback<WsError>>,
|
||||||
on_teleport_approved: &Option<Callback<TeleportInfo>>,
|
on_teleport_approved: &Option<Callback<TeleportInfo>>,
|
||||||
|
on_summoned: &Option<Callback<SummonInfo>>,
|
||||||
|
on_mod_command_result: &Option<Callback<ModCommandResultInfo>>,
|
||||||
current_user_id: &std::rc::Rc<std::cell::RefCell<Option<uuid::Uuid>>>,
|
current_user_id: &std::rc::Rc<std::cell::RefCell<Option<uuid::Uuid>>>,
|
||||||
) {
|
) {
|
||||||
let mut members_vec = members.borrow_mut();
|
let mut members_vec = members.borrow_mut();
|
||||||
|
|
@ -578,6 +606,24 @@ fn handle_server_message(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ServerMessage::Summoned {
|
||||||
|
scene_id,
|
||||||
|
scene_slug,
|
||||||
|
summoned_by,
|
||||||
|
} => {
|
||||||
|
if let Some(callback) = on_summoned {
|
||||||
|
callback.run(SummonInfo {
|
||||||
|
scene_id,
|
||||||
|
scene_slug,
|
||||||
|
summoned_by,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ServerMessage::ModCommandResult { success, message } => {
|
||||||
|
if let Some(callback) = on_mod_command_result {
|
||||||
|
callback.run(ModCommandResultInfo { success, message });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -596,6 +642,8 @@ pub fn use_channel_websocket(
|
||||||
_on_welcome: Option<Callback<ChannelMemberInfo>>,
|
_on_welcome: Option<Callback<ChannelMemberInfo>>,
|
||||||
_on_error: Option<Callback<WsError>>,
|
_on_error: Option<Callback<WsError>>,
|
||||||
_on_teleport_approved: Option<Callback<TeleportInfo>>,
|
_on_teleport_approved: Option<Callback<TeleportInfo>>,
|
||||||
|
_on_summoned: Option<Callback<SummonInfo>>,
|
||||||
|
_on_mod_command_result: Option<Callback<ModCommandResultInfo>>,
|
||||||
) -> (Signal<WsState>, WsSenderStorage) {
|
) -> (Signal<WsState>, WsSenderStorage) {
|
||||||
let (ws_state, _) = signal(WsState::Disconnected);
|
let (ws_state, _) = signal(WsState::Disconnected);
|
||||||
let sender: WsSenderStorage = StoredValue::new_local(None);
|
let sender: WsSenderStorage = StoredValue::new_local(None);
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,14 @@ use uuid::Uuid;
|
||||||
|
|
||||||
use crate::components::{
|
use crate::components::{
|
||||||
ActiveBubble, AvatarEditorPopup, Card, ChatInput, ConversationModal, EmotionKeybindings,
|
ActiveBubble, AvatarEditorPopup, Card, ChatInput, ConversationModal, EmotionKeybindings,
|
||||||
FadingMember, InventoryPopup, KeybindingsPopup, MessageLog, NotificationHistoryModal,
|
FadingMember, HotkeyHelp, InventoryPopup, KeybindingsPopup, LogPopup, MessageLog,
|
||||||
NotificationMessage, NotificationToast, RealmHeader, RealmSceneViewer, ReconnectionOverlay,
|
NotificationHistoryModal, NotificationMessage, NotificationToast, RealmHeader,
|
||||||
SettingsPopup, ViewerSettings,
|
RealmSceneViewer, ReconnectionOverlay, SettingsPopup, ViewerSettings,
|
||||||
};
|
};
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
use crate::components::{
|
use crate::components::{
|
||||||
ChannelMemberInfo, ChatMessage, DEFAULT_BUBBLE_TIMEOUT_MS, FADE_DURATION_MS, HistoryEntry,
|
ChannelMemberInfo, ChatMessage, DEFAULT_BUBBLE_TIMEOUT_MS, FADE_DURATION_MS, HistoryEntry,
|
||||||
TeleportInfo, WsError, add_to_history, use_channel_websocket,
|
ModCommandResultInfo, SummonInfo, TeleportInfo, WsError, add_to_history, use_channel_websocket,
|
||||||
};
|
};
|
||||||
use crate::utils::LocalStoragePersist;
|
use crate::utils::LocalStoragePersist;
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
|
|
@ -74,6 +74,12 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
let (settings_open, set_settings_open) = signal(false);
|
let (settings_open, set_settings_open) = signal(false);
|
||||||
let viewer_settings = RwSignal::new(ViewerSettings::load());
|
let viewer_settings = RwSignal::new(ViewerSettings::load());
|
||||||
|
|
||||||
|
// Log popup state
|
||||||
|
let (log_open, set_log_open) = signal(false);
|
||||||
|
|
||||||
|
// Hotkey help overlay state (shown while ? is held)
|
||||||
|
let (hotkey_help_visible, set_hotkey_help_visible) = signal(false);
|
||||||
|
|
||||||
// Keybindings popup state
|
// Keybindings popup state
|
||||||
let keybindings = RwSignal::new(EmotionKeybindings::load());
|
let keybindings = RwSignal::new(EmotionKeybindings::load());
|
||||||
let (keybindings_open, set_keybindings_open) = signal(false);
|
let (keybindings_open, set_keybindings_open) = signal(false);
|
||||||
|
|
@ -105,7 +111,7 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
let (is_guest, set_is_guest) = signal(false);
|
let (is_guest, set_is_guest) = signal(false);
|
||||||
|
|
||||||
// Whisper target - when set, triggers pre-fill in ChatInput
|
// Whisper target - when set, triggers pre-fill in ChatInput
|
||||||
let (whisper_target, set_whisper_target) = signal(Option::<String>::None);
|
let whisper_target = RwSignal::new(Option::<String>::None);
|
||||||
|
|
||||||
// Notification state for cross-scene whispers
|
// Notification state for cross-scene whispers
|
||||||
let (current_notification, set_current_notification) =
|
let (current_notification, set_current_notification) =
|
||||||
|
|
@ -135,6 +141,12 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
// Whether teleportation is allowed in this realm
|
// Whether teleportation is allowed in this realm
|
||||||
let (allow_user_teleport, set_allow_user_teleport) = signal(false);
|
let (allow_user_teleport, set_allow_user_teleport) = signal(false);
|
||||||
|
|
||||||
|
// Whether the current user is a moderator (set from Welcome message or membership)
|
||||||
|
let (is_moderator, set_is_moderator) = signal(false);
|
||||||
|
|
||||||
|
// Mod notification state (for summon notifications, command results)
|
||||||
|
let (mod_notification, set_mod_notification) = signal(Option::<(bool, String)>::None);
|
||||||
|
|
||||||
let realm_data = LocalResource::new(move || {
|
let realm_data = LocalResource::new(move || {
|
||||||
let slug = slug.get();
|
let slug = slug.get();
|
||||||
async move {
|
async move {
|
||||||
|
|
@ -412,6 +424,90 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Callback for being summoned by a moderator - show notification and teleport
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let on_summoned = Callback::new(move |info: SummonInfo| {
|
||||||
|
// Show notification
|
||||||
|
set_mod_notification.set(Some((true, format!("Summoned by {}", info.summoned_by))));
|
||||||
|
|
||||||
|
// Auto-dismiss notification after 3 seconds
|
||||||
|
let timeout = gloo_timers::callback::Timeout::new(3000, move || {
|
||||||
|
set_mod_notification.set(None);
|
||||||
|
});
|
||||||
|
timeout.forget();
|
||||||
|
|
||||||
|
let scene_id = info.scene_id;
|
||||||
|
let scene_slug = info.scene_slug.clone();
|
||||||
|
let realm_slug = slug.get_untracked();
|
||||||
|
|
||||||
|
// Fetch the new scene data (same as teleport approval)
|
||||||
|
let scene_slug_for_url = scene_slug.clone();
|
||||||
|
let realm_slug_for_url = realm_slug.clone();
|
||||||
|
spawn_local(async move {
|
||||||
|
use gloo_net::http::Request;
|
||||||
|
let response = Request::get(&format!(
|
||||||
|
"/api/realms/{}/scenes/{}",
|
||||||
|
realm_slug, scene_slug
|
||||||
|
))
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Ok(resp) = response {
|
||||||
|
if resp.ok() {
|
||||||
|
if let Ok(scene) = resp.json::<Scene>().await {
|
||||||
|
// Update scene dimensions from the new scene
|
||||||
|
if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) {
|
||||||
|
set_scene_dimensions.set((w as f64, h as f64));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update URL to reflect new scene
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let Ok(history) = window.history() {
|
||||||
|
let new_url = if scene.is_entry_point {
|
||||||
|
format!("/realms/{}", realm_slug_for_url)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"/realms/{}/scenes/{}",
|
||||||
|
realm_slug_for_url, scene_slug_for_url
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let _ = history.replace_state_with_url(
|
||||||
|
&wasm_bindgen::JsValue::NULL,
|
||||||
|
"",
|
||||||
|
Some(&new_url),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the current scene for the viewer
|
||||||
|
set_current_scene.set(Some(scene));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update channel_id to trigger WebSocket reconnection
|
||||||
|
set_channel_id.set(Some(scene_id));
|
||||||
|
|
||||||
|
// Clear members since we're switching scenes
|
||||||
|
set_members.set(Vec::new());
|
||||||
|
|
||||||
|
// Trigger a reconnect to ensure fresh connection
|
||||||
|
reconnect_trigger.update(|t| *t += 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Callback for mod command result - show notification
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let on_mod_command_result = Callback::new(move |info: ModCommandResultInfo| {
|
||||||
|
set_mod_notification.set(Some((info.success, info.message)));
|
||||||
|
|
||||||
|
// Auto-dismiss notification after 3 seconds
|
||||||
|
let timeout = gloo_timers::callback::Timeout::new(3000, move || {
|
||||||
|
set_mod_notification.set(None);
|
||||||
|
});
|
||||||
|
timeout.forget();
|
||||||
|
});
|
||||||
|
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
let (ws_state, ws_sender) = use_channel_websocket(
|
let (ws_state, ws_sender) = use_channel_websocket(
|
||||||
slug,
|
slug,
|
||||||
|
|
@ -426,6 +522,8 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
Some(on_welcome),
|
Some(on_welcome),
|
||||||
Some(on_ws_error),
|
Some(on_ws_error),
|
||||||
Some(on_teleport_approved),
|
Some(on_teleport_approved),
|
||||||
|
Some(on_summoned),
|
||||||
|
Some(on_mod_command_result),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set channel ID, current scene, and scene dimensions when entry scene loads
|
// Set channel ID, current scene, and scene dimensions when entry scene loads
|
||||||
|
|
@ -794,6 +892,20 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle 'l' to toggle message log
|
||||||
|
if key == "l" || key == "L" {
|
||||||
|
set_log_open.update(|v| *v = !*v);
|
||||||
|
ev.prevent_default();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle '?' to show hotkey help (while held)
|
||||||
|
if key == "?" {
|
||||||
|
set_hotkey_help_visible.set(true);
|
||||||
|
ev.prevent_default();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if 'e' key was pressed
|
// Check if 'e' key was pressed
|
||||||
if key == "e" || key == "E" {
|
if key == "e" || key == "E" {
|
||||||
*e_pressed_clone.borrow_mut() = true;
|
*e_pressed_clone.borrow_mut() = true;
|
||||||
|
|
@ -830,6 +942,32 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
|
|
||||||
// Store the closure for cleanup
|
// Store the closure for cleanup
|
||||||
*closure_holder_clone.borrow_mut() = Some(closure);
|
*closure_holder_clone.borrow_mut() = Some(closure);
|
||||||
|
|
||||||
|
// Add keyup handler for releasing '?' (hotkey help)
|
||||||
|
// We hide on any keyup when visible, since ? = Shift+/ and releasing
|
||||||
|
// either key means the user is no longer holding '?'
|
||||||
|
let keyup_closure = Closure::<dyn Fn(web_sys::KeyboardEvent)>::new(
|
||||||
|
move |ev: web_sys::KeyboardEvent| {
|
||||||
|
let key = ev.key();
|
||||||
|
// Hide if releasing ?, /, or Shift while help is visible
|
||||||
|
if hotkey_help_visible.get_untracked()
|
||||||
|
&& (key == "?" || key == "/" || key == "Shift")
|
||||||
|
{
|
||||||
|
set_hotkey_help_visible.set(false);
|
||||||
|
ev.prevent_default();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
let _ = window.add_event_listener_with_callback(
|
||||||
|
"keyup",
|
||||||
|
keyup_closure.as_ref().unchecked_ref(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forget the keyup closure (it lives for the duration of the page)
|
||||||
|
keyup_closure.forget();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save position on page unload (beforeunload event)
|
// Save position on page unload (beforeunload event)
|
||||||
|
|
@ -909,6 +1047,9 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
Some(RealmRole::Owner) | Some(RealmRole::Moderator)
|
Some(RealmRole::Owner) | Some(RealmRole::Moderator)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Update is_moderator signal for mod commands
|
||||||
|
set_is_moderator.set(can_admin);
|
||||||
|
|
||||||
// Get scene name and description for header
|
// Get scene name and description for header
|
||||||
let scene_info = entry_scene
|
let scene_info = entry_scene
|
||||||
.get()
|
.get()
|
||||||
|
|
@ -982,9 +1123,11 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
let on_open_inventory_cb = Callback::new(move |_: ()| {
|
let on_open_inventory_cb = Callback::new(move |_: ()| {
|
||||||
set_inventory_open.set(true);
|
set_inventory_open.set(true);
|
||||||
});
|
});
|
||||||
let whisper_target_signal = Signal::derive(move || whisper_target.get());
|
let on_open_log_cb = Callback::new(move |_: ()| {
|
||||||
|
set_log_open.set(true);
|
||||||
|
});
|
||||||
let on_whisper_request_cb = Callback::new(move |target: String| {
|
let on_whisper_request_cb = Callback::new(move |target: String| {
|
||||||
set_whisper_target.set(Some(target));
|
whisper_target.set(Some(target));
|
||||||
});
|
});
|
||||||
let scenes_signal = Signal::derive(move || available_scenes.get());
|
let scenes_signal = Signal::derive(move || available_scenes.get());
|
||||||
let teleport_enabled_signal = Signal::derive(move || allow_user_teleport.get());
|
let teleport_enabled_signal = Signal::derive(move || allow_user_teleport.get());
|
||||||
|
|
@ -998,6 +1141,17 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let ws_for_mod = ws_sender_clone.clone();
|
||||||
|
let on_mod_command_cb = Callback::new(move |(subcommand, args): (String, Vec<String>)| {
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
ws_for_mod.with_value(|sender| {
|
||||||
|
if let Some(send_fn) = sender {
|
||||||
|
send_fn(ClientMessage::ModCommand { subcommand, args });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
let is_moderator_signal = Signal::derive(move || is_moderator.get());
|
||||||
view! {
|
view! {
|
||||||
<div class="relative w-full">
|
<div class="relative w-full">
|
||||||
<RealmSceneViewer
|
<RealmSceneViewer
|
||||||
|
|
@ -1031,10 +1185,13 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
on_focus_change=on_chat_focus_change.clone()
|
on_focus_change=on_chat_focus_change.clone()
|
||||||
on_open_settings=on_open_settings_cb
|
on_open_settings=on_open_settings_cb
|
||||||
on_open_inventory=on_open_inventory_cb
|
on_open_inventory=on_open_inventory_cb
|
||||||
whisper_target=whisper_target_signal
|
on_open_log=on_open_log_cb
|
||||||
|
whisper_target=whisper_target
|
||||||
scenes=scenes_signal
|
scenes=scenes_signal
|
||||||
allow_user_teleport=teleport_enabled_signal
|
allow_user_teleport=teleport_enabled_signal
|
||||||
on_teleport=on_teleport_cb
|
on_teleport=on_teleport_cb
|
||||||
|
is_moderator=is_moderator_signal
|
||||||
|
on_mod_command=on_mod_command_cb
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1088,6 +1245,15 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
scene_dimensions=scene_dimensions.get()
|
scene_dimensions=scene_dimensions.get()
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
// Log popup
|
||||||
|
<LogPopup
|
||||||
|
open=Signal::derive(move || log_open.get())
|
||||||
|
message_log=message_log
|
||||||
|
on_close=Callback::new(move |_: ()| {
|
||||||
|
set_log_open.set(false);
|
||||||
|
})
|
||||||
|
/>
|
||||||
|
|
||||||
// Keybindings popup
|
// Keybindings popup
|
||||||
<KeybindingsPopup
|
<KeybindingsPopup
|
||||||
open=Signal::derive(move || keybindings_open.get())
|
open=Signal::derive(move || keybindings_open.get())
|
||||||
|
|
@ -1127,7 +1293,7 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
<NotificationToast
|
<NotificationToast
|
||||||
notification=Signal::derive(move || current_notification.get())
|
notification=Signal::derive(move || current_notification.get())
|
||||||
on_reply=Callback::new(move |name: String| {
|
on_reply=Callback::new(move |name: String| {
|
||||||
set_whisper_target.set(Some(name));
|
whisper_target.set(Some(name));
|
||||||
})
|
})
|
||||||
on_context=Callback::new(move |name: String| {
|
on_context=Callback::new(move |name: String| {
|
||||||
set_conversation_partner.set(name);
|
set_conversation_partner.set(name);
|
||||||
|
|
@ -1165,12 +1331,42 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
// Mod command notification toast (summon, command results)
|
||||||
|
<Show when=move || mod_notification.get().is_some()>
|
||||||
|
{move || {
|
||||||
|
if let Some((success, msg)) = mod_notification.get() {
|
||||||
|
let (bg_class, border_class, icon_class, icon) = if success {
|
||||||
|
("bg-purple-900/90", "border-purple-500/50", "text-purple-300", "✓")
|
||||||
|
} else {
|
||||||
|
("bg-yellow-900/90", "border-yellow-500/50", "text-yellow-300", "⚠")
|
||||||
|
};
|
||||||
|
view! {
|
||||||
|
<div class="fixed top-4 left-1/2 -translate-x-1/2 z-50 animate-slide-in-down">
|
||||||
|
<div class=format!("{} border {} rounded-lg shadow-lg px-6 py-3 flex items-center gap-3", bg_class, border_class)>
|
||||||
|
<span class=format!("{} text-lg font-bold", icon_class)>"[MOD]"</span>
|
||||||
|
<span class=format!("{} text-lg", icon_class)>{icon}</span>
|
||||||
|
<span class="text-gray-200">{msg}</span>
|
||||||
|
<button
|
||||||
|
class="text-gray-400 hover:text-white ml-2"
|
||||||
|
on:click=move |_| set_mod_notification.set(None)
|
||||||
|
>
|
||||||
|
"×"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
().into_any()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
|
||||||
// Notification history modal
|
// Notification history modal
|
||||||
<NotificationHistoryModal
|
<NotificationHistoryModal
|
||||||
open=Signal::derive(move || history_modal_open.get())
|
open=Signal::derive(move || history_modal_open.get())
|
||||||
on_close=Callback::new(move |_: ()| set_history_modal_open.set(false))
|
on_close=Callback::new(move |_: ()| set_history_modal_open.set(false))
|
||||||
on_reply=Callback::new(move |name: String| {
|
on_reply=Callback::new(move |name: String| {
|
||||||
set_whisper_target.set(Some(name));
|
whisper_target.set(Some(name));
|
||||||
})
|
})
|
||||||
on_context=Callback::new(move |name: String| {
|
on_context=Callback::new(move |name: String| {
|
||||||
set_conversation_partner.set(name);
|
set_conversation_partner.set(name);
|
||||||
|
|
@ -1226,6 +1422,9 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hotkey help overlay (shown while ? is held)
|
||||||
|
<HotkeyHelp visible=Signal::derive(move || hotkey_help_visible.get()) />
|
||||||
}
|
}
|
||||||
.into_any()
|
.into_any()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,9 +71,10 @@ COMMENT ON TYPE server.portability IS 'Whether prop can be used outside origin r
|
||||||
CREATE TYPE server.avatar_layer AS ENUM (
|
CREATE TYPE server.avatar_layer AS ENUM (
|
||||||
'skin', -- Background layer (behind user, body/face)
|
'skin', -- Background layer (behind user, body/face)
|
||||||
'clothes', -- Middle layer (with user, worn items)
|
'clothes', -- Middle layer (with user, worn items)
|
||||||
'accessories' -- Foreground layer (in front of user, held/attached items)
|
'accessories', -- Foreground layer (in front of user, held/attached items)
|
||||||
|
'emote' -- Facial expression layer (overlays face)
|
||||||
);
|
);
|
||||||
COMMENT ON TYPE server.avatar_layer IS 'Z-layer for avatar prop positioning: skin (behind), clothes (with), accessories (front)';
|
COMMENT ON TYPE server.avatar_layer IS 'Z-layer for avatar prop positioning: skin (behind), clothes (with), accessories (front), emote (expressions)';
|
||||||
|
|
||||||
-- Emotion state for avatar overlays (moved from props schema)
|
-- Emotion state for avatar overlays (moved from props schema)
|
||||||
CREATE TYPE server.emotion_state AS ENUM (
|
CREATE TYPE server.emotion_state AS ENUM (
|
||||||
|
|
@ -100,7 +101,9 @@ CREATE TYPE server.action_type AS ENUM (
|
||||||
'ban',
|
'ban',
|
||||||
'unban',
|
'unban',
|
||||||
'prop_removal',
|
'prop_removal',
|
||||||
'message_deletion'
|
'message_deletion',
|
||||||
|
'summon',
|
||||||
|
'summon_all'
|
||||||
);
|
);
|
||||||
COMMENT ON TYPE server.action_type IS 'Type of moderation action taken';
|
COMMENT ON TYPE server.action_type IS 'Type of moderation action taken';
|
||||||
|
|
||||||
|
|
|
||||||
31
run-dev.sh
31
run-dev.sh
|
|
@ -35,6 +35,7 @@ Options:
|
||||||
-k, --kill Kill existing instance and exit
|
-k, --kill Kill existing instance and exit
|
||||||
-s, --status Check if an instance is running
|
-s, --status Check if an instance is running
|
||||||
-r, --release Build and run in release mode
|
-r, --release Build and run in release mode
|
||||||
|
--public Bind to 0.0.0.0 instead of 127.0.0.1
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
$0 # Build and run both
|
$0 # Build and run both
|
||||||
|
|
@ -70,17 +71,19 @@ print_server_info() {
|
||||||
local mode="$1"
|
local mode="$1"
|
||||||
local build_type="Debug"
|
local build_type="Debug"
|
||||||
[ "$RELEASE" = "true" ] && build_type="Release"
|
[ "$RELEASE" = "true" ] && build_type="Release"
|
||||||
|
local bind_addr="127.0.0.1"
|
||||||
|
[ "$PUBLIC" = "true" ] && bind_addr="0.0.0.0"
|
||||||
echo ""
|
echo ""
|
||||||
echo "========================================"
|
echo "========================================"
|
||||||
echo " Chattyness Development ($mode - $build_type)"
|
echo " Chattyness Development ($mode - $build_type)"
|
||||||
echo "========================================"
|
echo "========================================"
|
||||||
echo ""
|
echo ""
|
||||||
if run_owner; then
|
if run_owner; then
|
||||||
echo " Owner Admin: http://127.0.0.1:$OWNER_PORT"
|
echo " Owner Admin: http://$bind_addr:$OWNER_PORT"
|
||||||
fi
|
fi
|
||||||
if run_app; then
|
if run_app; then
|
||||||
echo " User App: http://127.0.0.1:$APP_PORT"
|
echo " User App: http://$bind_addr:$APP_PORT"
|
||||||
echo " Realm Admin: http://127.0.0.1:$APP_PORT/admin"
|
echo " Realm Admin: http://$bind_addr:$APP_PORT/admin"
|
||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
if [ "$TARGET" = "both" ]; then
|
if [ "$TARGET" = "both" ]; then
|
||||||
|
|
@ -225,6 +228,9 @@ do_watch() {
|
||||||
local release_flag=""
|
local release_flag=""
|
||||||
[ "$RELEASE" = "true" ] && release_flag="--release"
|
[ "$RELEASE" = "true" ] && release_flag="--release"
|
||||||
|
|
||||||
|
local bind_addr="127.0.0.1"
|
||||||
|
[ "$PUBLIC" = "true" ] && bind_addr="0.0.0.0"
|
||||||
|
|
||||||
# Build owner first to create CSS (needed if user app needs admin CSS)
|
# Build owner first to create CSS (needed if user app needs admin CSS)
|
||||||
if run_owner; then
|
if run_owner; then
|
||||||
echo "Building owner app first (for admin CSS)..."
|
echo "Building owner app first (for admin CSS)..."
|
||||||
|
|
@ -234,12 +240,12 @@ do_watch() {
|
||||||
|
|
||||||
# Start watch processes
|
# Start watch processes
|
||||||
if run_owner; then
|
if run_owner; then
|
||||||
cargo leptos watch -p chattyness-owner $release_flag &
|
HOST="$bind_addr" cargo leptos watch -p chattyness-owner $release_flag &
|
||||||
OWNER_PID=$!
|
OWNER_PID=$!
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if run_app; then
|
if run_app; then
|
||||||
cargo leptos watch -p chattyness-app --split $release_flag &
|
HOST="$bind_addr" cargo leptos watch -p chattyness-app --split $release_flag &
|
||||||
APP_PID=$!
|
APP_PID=$!
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -279,15 +285,18 @@ do_build() {
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Start servers
|
# Start servers
|
||||||
|
local bind_addr="127.0.0.1"
|
||||||
|
[ "$PUBLIC" = "true" ] && bind_addr="0.0.0.0"
|
||||||
|
|
||||||
if run_owner; then
|
if run_owner; then
|
||||||
echo "Starting Owner Server on :$OWNER_PORT..."
|
echo "Starting Owner Server on $bind_addr:$OWNER_PORT..."
|
||||||
./target/$target_dir/chattyness-owner &
|
HOST="$bind_addr" ./target/$target_dir/chattyness-owner &
|
||||||
OWNER_PID=$!
|
OWNER_PID=$!
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if run_app; then
|
if run_app; then
|
||||||
echo "Starting App Server on :$APP_PORT..."
|
echo "Starting App Server on $bind_addr:$APP_PORT..."
|
||||||
./target/$target_dir/chattyness-app &
|
HOST="$bind_addr" ./target/$target_dir/chattyness-app &
|
||||||
APP_PID=$!
|
APP_PID=$!
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -306,6 +315,7 @@ FORCE="false"
|
||||||
KILL_EXISTING="false"
|
KILL_EXISTING="false"
|
||||||
CHECK_STATUS="false"
|
CHECK_STATUS="false"
|
||||||
RELEASE="false"
|
RELEASE="false"
|
||||||
|
PUBLIC="false"
|
||||||
|
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case $arg in
|
case $arg in
|
||||||
|
|
@ -333,6 +343,9 @@ for arg in "$@"; do
|
||||||
-r | --release)
|
-r | --release)
|
||||||
RELEASE="true"
|
RELEASE="true"
|
||||||
;;
|
;;
|
||||||
|
--public)
|
||||||
|
PUBLIC="true"
|
||||||
|
;;
|
||||||
--help | -h)
|
--help | -h)
|
||||||
usage
|
usage
|
||||||
;;
|
;;
|
||||||
|
|
|
||||||
|
|
@ -58,24 +58,16 @@ capitalize() {
|
||||||
}
|
}
|
||||||
|
|
||||||
# Function to determine tags based on filename
|
# Function to determine tags based on filename
|
||||||
# Tags complement default_layer/default_emotion - avoid redundant info
|
# Tags complement default_layer - avoid redundant info
|
||||||
get_tags() {
|
get_tags() {
|
||||||
local filename="$1"
|
local filename="$1"
|
||||||
case "$filename" in
|
case "$filename" in
|
||||||
face.svg)
|
face.svg)
|
||||||
# Content layer prop - "skin" is already in default_layer
|
# Base face prop - "skin" is already in default_layer
|
||||||
echo '["base", "face"]'
|
echo '["base", "face"]'
|
||||||
;;
|
;;
|
||||||
smile.svg | happy.svg | neutral.svg | sad.svg | angry.svg | surprised.svg | thinking.svg | laughing.svg | crying.svg | love.svg | confused.svg)
|
neutral.svg | smile.svg | sad.svg | angry.svg | surprised.svg | thinking.svg | laughing.svg | crying.svg | love.svg | confused.svg | sleeping.svg | wink.svg)
|
||||||
# Emotion props - emotion is already in default_emotion
|
# Facial expression props - "emote" is already in default_layer
|
||||||
echo '["face"]'
|
|
||||||
;;
|
|
||||||
sleeping.svg)
|
|
||||||
# Emotion prop for sleeping
|
|
||||||
echo '["face"]'
|
|
||||||
;;
|
|
||||||
wink.svg)
|
|
||||||
# Emotion prop for wink
|
|
||||||
echo '["face"]'
|
echo '["face"]'
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
|
|
@ -85,7 +77,7 @@ get_tags() {
|
||||||
}
|
}
|
||||||
|
|
||||||
# Function to get positioning fields based on filename
|
# Function to get positioning fields based on filename
|
||||||
# Returns: "layer:<value>" for content layer props, "emotion:<value>" for emotion props, "none" for generic props
|
# Returns: "layer:<value>" for content layer props, "none" for generic props
|
||||||
get_positioning() {
|
get_positioning() {
|
||||||
local filename="$1"
|
local filename="$1"
|
||||||
case "$filename" in
|
case "$filename" in
|
||||||
|
|
@ -93,41 +85,9 @@ get_positioning() {
|
||||||
# Base face is a content layer prop (skin layer)
|
# Base face is a content layer prop (skin layer)
|
||||||
echo "layer:skin"
|
echo "layer:skin"
|
||||||
;;
|
;;
|
||||||
neutral.svg)
|
neutral.svg | smile.svg | sad.svg | angry.svg | surprised.svg | thinking.svg | laughing.svg | crying.svg | love.svg | confused.svg | sleeping.svg | wink.svg)
|
||||||
echo "emotion:neutral"
|
# Facial expression props use the emote layer
|
||||||
;;
|
echo "layer:emote"
|
||||||
smile.svg)
|
|
||||||
echo "emotion:happy"
|
|
||||||
;;
|
|
||||||
sad.svg)
|
|
||||||
echo "emotion:sad"
|
|
||||||
;;
|
|
||||||
angry.svg)
|
|
||||||
echo "emotion:angry"
|
|
||||||
;;
|
|
||||||
surprised.svg)
|
|
||||||
echo "emotion:surprised"
|
|
||||||
;;
|
|
||||||
thinking.svg)
|
|
||||||
echo "emotion:thinking"
|
|
||||||
;;
|
|
||||||
laughing.svg)
|
|
||||||
echo "emotion:laughing"
|
|
||||||
;;
|
|
||||||
crying.svg)
|
|
||||||
echo "emotion:crying"
|
|
||||||
;;
|
|
||||||
love.svg)
|
|
||||||
echo "emotion:love"
|
|
||||||
;;
|
|
||||||
confused.svg)
|
|
||||||
echo "emotion:confused"
|
|
||||||
;;
|
|
||||||
sleeping.svg)
|
|
||||||
echo "emotion:sleeping"
|
|
||||||
;;
|
|
||||||
wink.svg)
|
|
||||||
echo "emotion:wink"
|
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "none"
|
echo "none"
|
||||||
|
|
@ -159,13 +119,9 @@ for file in "$STOCKAVATAR_DIR"/*.svg; do
|
||||||
|
|
||||||
case "$positioning_type" in
|
case "$positioning_type" in
|
||||||
layer)
|
layer)
|
||||||
# Content layer prop
|
# Content layer prop (skin, clothes, accessories, emote)
|
||||||
positioning_json="\"default_layer\": \"$positioning_value\", \"default_position\": 4"
|
positioning_json="\"default_layer\": \"$positioning_value\", \"default_position\": 4"
|
||||||
;;
|
;;
|
||||||
emotion)
|
|
||||||
# Emotion layer prop
|
|
||||||
positioning_json="\"default_emotion\": \"$positioning_value\", \"default_position\": 4"
|
|
||||||
;;
|
|
||||||
*)
|
*)
|
||||||
# Generic prop (no default positioning)
|
# Generic prop (no default positioning)
|
||||||
positioning_json=""
|
positioning_json=""
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue