feat: add /mod summon
This commit is contained in:
parent
864cfaec54
commit
45a7e44b3a
11 changed files with 598 additions and 5 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,42 @@ 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 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))]
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -828,6 +828,169 @@ 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||||
|
success: false,
|
||||||
|
message: format!("Unknown mod command: {}", subcommand),
|
||||||
|
}).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Message::Close(close_frame) => {
|
Message::Close(close_frame) => {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ enum CommandMode {
|
||||||
ShowingColonHint,
|
ShowingColonHint,
|
||||||
/// Showing command hint for slash commands (`/setting`).
|
/// Showing command hint for slash commands (`/setting`).
|
||||||
ShowingSlashHint,
|
ShowingSlashHint,
|
||||||
|
/// Showing mod command hint (`/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:
|
||||||
|
|
@ -140,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);
|
||||||
|
|
@ -324,9 +361,16 @@ pub fn ChatInput(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check if mod command (only for moderators)
|
||||||
|
let is_mod_command = is_moderator.get_untracked()
|
||||||
|
&& (cmd.starts_with("mod") || "mod".starts_with(&cmd));
|
||||||
|
|
||||||
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_mod_command {
|
||||||
|
// Show mod command hint
|
||||||
|
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)
|
||||||
|
|
@ -625,6 +669,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;
|
||||||
|
|
@ -769,6 +829,17 @@ pub fn ChatInput(
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
// Mod command hint bar (/mod summon [nick|*])
|
||||||
|
<Show when=move || command_mode.get() == CommandMode::ShowingModHint>
|
||||||
|
<div class="absolute bottom-full left-0 mb-1 px-3 py-1 bg-purple-800/90 backdrop-blur-sm rounded text-sm">
|
||||||
|
<span class="text-purple-300 font-medium">"[MOD] "</span>
|
||||||
|
<span class="text-gray-400">"/"</span>
|
||||||
|
<span class="text-purple-400">"mod"</span>
|
||||||
|
<span class="text-gray-300">" summon"</span>
|
||||||
|
<span class="text-gray-500">" [nick|*]"</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
// Emotion list popup
|
// Emotion list popup
|
||||||
<Show when=move || command_mode.get() == CommandMode::ShowingList>
|
<Show when=move || command_mode.get() == CommandMode::ShowingList>
|
||||||
<EmoteListPopup
|
<EmoteListPopup
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ use crate::components::{
|
||||||
#[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")]
|
||||||
|
|
@ -141,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 {
|
||||||
|
|
@ -418,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,
|
||||||
|
|
@ -432,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
|
||||||
|
|
@ -955,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()
|
||||||
|
|
@ -1046,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
|
||||||
|
|
@ -1084,6 +1190,8 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
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>
|
||||||
|
|
@ -1223,6 +1331,36 @@ 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())
|
||||||
|
|
|
||||||
|
|
@ -101,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';
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue