Rework avatars.

Now we have a concept of an avatar at the server, realm, and scene level
and we have the groundwork for a realm store. New uesrs no longer props,
they get a default avatar. New system supports gender
{male,female,neutral} and {child,adult}.
This commit is contained in:
Evan Carroll 2026-01-22 21:04:27 -06:00
parent e4abdb183f
commit 6fb90e42c3
55 changed files with 7392 additions and 512 deletions

View file

@ -6,14 +6,16 @@ use tower_sessions::Session;
use chattyness_db::{
models::{
AccountStatus, AuthenticatedUser, CurrentUserResponse, GuestLoginRequest,
GuestLoginResponse, JoinRealmRequest, JoinRealmResponse, LoginRequest, LoginResponse,
LoginType, PasswordResetRequest, PasswordResetResponse, RealmRole, RealmSummary,
RegisterGuestRequest, RegisterGuestResponse, SignupRequest, SignupResponse, UserSummary,
AccountStatus, AgeCategory, AuthenticatedUser, CurrentUserResponse, GenderPreference,
GuestLoginRequest, GuestLoginResponse, JoinRealmRequest, JoinRealmResponse, LoginRequest,
LoginResponse, LoginType, PasswordResetRequest, PasswordResetResponse, RealmRole,
RealmSummary, RegisterGuestRequest, RegisterGuestResponse, SignupRequest, SignupResponse,
UserSummary,
},
queries::{guests, memberships, realms, users},
};
use chattyness_error::AppError;
use chattyness_shared::{AgeConfig, GenderConfig, SignupConfig};
use crate::auth::{
AuthUser, OptionalAuthUser,
@ -249,6 +251,7 @@ pub async fn logout(session: Session) -> Result<Json<LogoutResponse>, AppError>
pub async fn signup(
rls_conn: crate::auth::RlsConn,
State(pool): State<PgPool>,
State(signup_config): State<SignupConfig>,
session: Session,
Json(req): Json<SignupRequest>,
) -> Result<Json<SignupResponse>, AppError> {
@ -273,6 +276,29 @@ pub async fn signup(
.await?
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", req.realm_slug)))?;
// Determine gender preference based on config
let gender_preference = match signup_config.gender {
GenderConfig::Ask => req.gender_preference,
GenderConfig::DefaultNeutral => Some(GenderPreference::GenderNeutral),
GenderConfig::DefaultMale => Some(GenderPreference::GenderMale),
GenderConfig::DefaultFemale => Some(GenderPreference::GenderFemale),
};
// Determine age category based on config
let age_category = match signup_config.age {
AgeConfig::Ask => req.age_category,
AgeConfig::Infer => {
// Infer age from birthday if provided
if let Some(birthday) = req.birthday {
Some(infer_age_category_from_birthday(birthday))
} else {
Some(AgeCategory::Adult) // Default to adult if no birthday
}
}
AgeConfig::DefaultAdult => Some(AgeCategory::Adult),
AgeConfig::DefaultChild => Some(AgeCategory::Child),
};
// Create the user using RLS connection
let email_opt = req.email.as_ref().and_then(|e| {
let trimmed = e.trim();
@ -284,12 +310,15 @@ pub async fn signup(
});
let mut conn = rls_conn.acquire().await;
let user_id = users::create_user_conn(
let user_id = users::create_user_with_preferences_conn(
&mut *conn,
&req.username,
email_opt,
req.display_name.trim(),
&req.password,
req.birthday,
gender_preference,
age_category,
)
.await?;
drop(conn);
@ -530,3 +559,65 @@ pub async fn register_guest(
username: req.username,
}))
}
/// Request to update user preferences.
#[derive(Debug, serde::Deserialize)]
pub struct UpdatePreferencesRequest {
#[serde(default)]
pub birthday: Option<chrono::NaiveDate>,
#[serde(default)]
pub gender_preference: Option<chattyness_db::models::GenderPreference>,
#[serde(default)]
pub age_category: Option<chattyness_db::models::AgeCategory>,
}
/// Response after updating preferences.
#[derive(Debug, serde::Serialize)]
pub struct UpdatePreferencesResponse {
pub success: bool,
}
/// Update user preferences handler.
///
/// Updates the user's birthday, gender preference, and/or age category.
/// These preferences are used for default avatar selection.
pub async fn update_preferences(
rls_conn: crate::auth::RlsConn,
AuthUser(user): AuthUser,
Json(req): Json<UpdatePreferencesRequest>,
) -> Result<Json<UpdatePreferencesResponse>, AppError> {
let mut conn = rls_conn.acquire().await;
// Update user preferences (requires RLS for auth.users UPDATE policy)
users::update_user_preferences_conn(
&mut *conn,
user.id,
req.birthday,
req.gender_preference,
req.age_category,
)
.await?;
Ok(Json(UpdatePreferencesResponse { success: true }))
}
/// Infer age category from birthday.
///
/// Returns `Adult` if 18 years or older, `Child` otherwise.
fn infer_age_category_from_birthday(birthday: chrono::NaiveDate) -> AgeCategory {
use chrono::Datelike;
let today = chrono::Utc::now().date_naive();
let age = today.year() - birthday.year();
let had_birthday_this_year =
(today.month(), today.day()) >= (birthday.month(), birthday.day());
let actual_age = if had_birthday_this_year {
age
} else {
age - 1
};
if actual_age >= 18 {
AgeCategory::Adult
} else {
AgeCategory::Child
}
}

View file

@ -7,10 +7,13 @@ use axum::Json;
use axum::extract::Path;
use chattyness_db::{
models::{AssignSlotRequest, AvatarWithPaths, ClearSlotRequest},
queries::{avatars, realms},
models::{AssignSlotRequest, AvatarWithPaths, ClearSlotRequest, RealmAvatar, ServerAvatar},
queries::{avatars, realm_avatars, realms, server_avatars},
};
use chattyness_error::AppError;
use sqlx::PgPool;
use axum::extract::State;
use uuid::Uuid;
use crate::auth::{AuthUser, RlsConn};
@ -127,3 +130,136 @@ pub async fn clear_slot(
Ok(Json(avatar))
}
// =============================================================================
// Avatar Store Endpoints
// =============================================================================
/// List public server avatars.
///
/// GET /api/server/avatars
///
/// Returns all public, active server avatars that users can select from.
pub async fn list_server_avatars(
State(pool): State<PgPool>,
) -> Result<Json<Vec<ServerAvatar>>, AppError> {
let avatars = server_avatars::list_public_server_avatars(&pool).await?;
Ok(Json(avatars))
}
/// List public realm avatars.
///
/// GET /api/realms/{slug}/avatars
///
/// Returns all public, active realm avatars for the specified realm.
pub async fn list_realm_avatars(
State(pool): State<PgPool>,
Path(slug): Path<String>,
) -> Result<Json<Vec<RealmAvatar>>, AppError> {
// Get realm
let realm = realms::get_realm_by_slug(&pool, &slug)
.await?
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
let avatars = realm_avatars::list_public_realm_avatars(&pool, realm.id).await?;
Ok(Json(avatars))
}
/// Request to select an avatar from the store.
#[derive(Debug, serde::Deserialize)]
pub struct SelectAvatarRequest {
/// The avatar ID to select
pub avatar_id: Uuid,
/// Source: "server" or "realm"
pub source: AvatarSelectionSource,
}
/// Avatar selection source.
#[derive(Debug, serde::Deserialize, Clone, Copy)]
#[serde(rename_all = "snake_case")]
pub enum AvatarSelectionSource {
Server,
Realm,
}
/// Response after selecting an avatar.
#[derive(Debug, serde::Serialize)]
pub struct SelectAvatarResponse {
pub success: bool,
}
/// Select an avatar from the store.
///
/// POST /api/realms/{slug}/avatar/select
///
/// Selects a server or realm avatar to use. This has lower priority than
/// a custom avatar but higher priority than default avatars.
pub async fn select_avatar(
rls_conn: RlsConn,
AuthUser(user): AuthUser,
Path(slug): Path<String>,
Json(req): Json<SelectAvatarRequest>,
) -> Result<Json<SelectAvatarResponse>, AppError> {
let mut conn = rls_conn.acquire().await;
// Get realm
let realm = realms::get_realm_by_slug(&mut *conn, &slug)
.await?
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
// Verify the avatar exists and is public
match req.source {
AvatarSelectionSource::Server => {
let avatar = server_avatars::get_server_avatar_by_id(&mut *conn, req.avatar_id)
.await?
.ok_or_else(|| AppError::NotFound("Server avatar not found".to_string()))?;
if !avatar.is_public || !avatar.is_active {
return Err(AppError::NotFound("Server avatar not available".to_string()));
}
avatars::select_server_avatar(&mut *conn, user.id, realm.id, req.avatar_id).await?;
}
AvatarSelectionSource::Realm => {
let avatar = realm_avatars::get_realm_avatar_by_id(&mut *conn, req.avatar_id)
.await?
.ok_or_else(|| AppError::NotFound("Realm avatar not found".to_string()))?;
if avatar.realm_id != realm.id {
return Err(AppError::NotFound("Realm avatar not found in this realm".to_string()));
}
if !avatar.is_public || !avatar.is_active {
return Err(AppError::NotFound("Realm avatar not available".to_string()));
}
avatars::select_realm_avatar(&mut *conn, user.id, realm.id, req.avatar_id).await?;
}
}
Ok(Json(SelectAvatarResponse { success: true }))
}
/// Clear avatar selection response.
#[derive(Debug, serde::Serialize)]
pub struct ClearAvatarSelectionResponse {
pub success: bool,
}
/// Clear avatar selection.
///
/// DELETE /api/realms/{slug}/avatar/selection
///
/// Clears both server and realm avatar selections, reverting to default
/// avatar behavior (custom avatar if available, otherwise defaults).
pub async fn clear_avatar_selection(
rls_conn: RlsConn,
AuthUser(user): AuthUser,
Path(slug): Path<String>,
) -> Result<Json<ClearAvatarSelectionResponse>, AppError> {
let mut conn = rls_conn.acquire().await;
// Get realm
let realm = realms::get_realm_by_slug(&mut *conn, &slug)
.await?
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
avatars::clear_avatar_selection(&mut *conn, user.id, realm.id).await?;
Ok(Json(ClearAvatarSelectionResponse { success: true }))
}

View file

@ -31,6 +31,10 @@ pub fn api_router() -> Router<AppState> {
"/auth/register-guest",
axum::routing::post(auth::register_guest),
)
.route(
"/auth/preferences",
axum::routing::put(auth::update_preferences),
)
// Realm routes (READ-ONLY)
.route("/realms", get(realms::list_realms))
.route("/realms/{slug}", get(realms::get_realm))
@ -62,6 +66,17 @@ pub fn api_router() -> Router<AppState> {
"/realms/{slug}/avatar/slot",
axum::routing::put(avatars::assign_slot).delete(avatars::clear_slot),
)
// Avatar store routes
.route("/server/avatars", get(avatars::list_server_avatars))
.route("/realms/{slug}/avatars", get(avatars::list_realm_avatars))
.route(
"/realms/{slug}/avatar/select",
axum::routing::post(avatars::select_avatar),
)
.route(
"/realms/{slug}/avatar/selection",
axum::routing::delete(avatars::clear_avatar_selection),
)
// User inventory routes
.route("/user/{uuid}/inventory", get(inventory::get_user_inventory))
.route(

View file

@ -18,14 +18,33 @@ use tokio::sync::{broadcast, mpsc};
use uuid::Uuid;
use chattyness_db::{
models::{ActionType, AvatarRenderData, ChannelMemberWithAvatar, EmotionState, User},
queries::{avatars, channel_members, loose_props, memberships, moderation, realms, scenes, users},
models::{ActionType, AvatarRenderData, ChannelMemberWithAvatar, EmotionState, ForcedAvatarReason, User},
queries::{avatars, channel_members, loose_props, memberships, moderation, realm_avatars, realms, scenes, server_avatars, users},
ws_messages::{close_codes, ClientMessage, DisconnectReason, ServerMessage, WsConfig},
};
use chattyness_error::AppError;
use chattyness_shared::WebSocketConfig;
use crate::auth::AuthUser;
use chrono::Duration as ChronoDuration;
/// Parse a duration string like "30m", "2h", "7d" into a chrono::Duration.
fn parse_duration(s: &str) -> Option<ChronoDuration> {
let s = s.trim().to_lowercase();
if s.is_empty() {
return None;
}
let (num_str, unit) = s.split_at(s.len() - 1);
let num: i64 = num_str.parse().ok()?;
match unit {
"m" => Some(ChronoDuration::minutes(num)),
"h" => Some(ChronoDuration::hours(num)),
"d" => Some(ChronoDuration::days(num)),
_ => None,
}
}
/// Channel state for broadcasting updates.
pub struct ChannelState {
@ -271,8 +290,22 @@ async fn handle_socket(
}
tracing::info!("[WS] Channel joined");
// Check if scene has forced avatar
if let Ok(Some(scene_forced)) = realm_avatars::get_scene_forced_avatar(&pool, channel_id).await {
tracing::info!("[WS] Scene has forced avatar: {:?}", scene_forced.forced_avatar_id);
// Apply scene-forced avatar to user
if let Err(e) = realm_avatars::apply_scene_forced_avatar(
&pool,
user.id,
realm_id,
scene_forced.forced_avatar_id,
).await {
tracing::warn!("[WS] Failed to apply scene forced avatar: {:?}", e);
}
}
// Get initial state
let members = match get_members_with_avatars(&mut conn, channel_id, realm_id).await {
let members = match get_members_with_avatars(&mut conn, &pool, channel_id, realm_id).await {
Ok(m) => m,
Err(e) => {
tracing::error!("[WS] Failed to get members: {:?}", e);
@ -338,11 +371,13 @@ async fn handle_socket(
let member_display_name = member.display_name.clone();
// Broadcast join to others
let avatar = avatars::get_avatar_with_paths_conn(&mut *conn, user.id, realm_id)
// Use effective avatar resolution to handle priority chain:
// forced > custom > selected realm > selected server > realm default > server default
let avatar = avatars::get_effective_avatar_render_data(&pool, user.id, realm_id)
.await
.ok()
.flatten()
.map(|a| a.to_render_data())
.map(|(render_data, _source)| render_data)
.unwrap_or_default();
let join_msg = ServerMessage::MemberJoined {
member: ChannelMemberWithAvatar { member, avatar },
@ -458,6 +493,7 @@ async fn handle_socket(
};
let emotion_layer = match avatars::set_emotion(
&mut *recv_conn,
&pool,
user_id,
realm_id,
emotion_state,
@ -711,16 +747,16 @@ async fn handle_socket(
}
}
ClientMessage::SyncAvatar => {
// Fetch current avatar from database and broadcast to channel
match avatars::get_avatar_with_paths_conn(
&mut *recv_conn,
// Fetch current effective avatar from database and broadcast to channel
// Uses the priority chain: forced > custom > selected realm > selected server > realm default > server default
match avatars::get_effective_avatar_render_data(
&pool,
user_id,
realm_id,
)
.await
{
Ok(Some(avatar_with_paths)) => {
let render_data = avatar_with_paths.to_render_data();
Ok(Some((render_data, _source))) => {
#[cfg(debug_assertions)]
tracing::debug!(
"[WS] User {} syncing avatar to channel",
@ -1066,6 +1102,280 @@ async fn handle_socket(
}).await;
}
}
"dress" => {
// /mod dress [nick] [avatar-slug] [duration?]
// Duration format: 30m, 2h, 7d (minutes/hours/days)
if args.len() < 2 {
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: false,
message: "Usage: /mod dress [nick] [avatar-slug] [duration?]".to_string(),
}).await;
continue;
}
let target_nick = &args[0];
let avatar_slug = &args[1];
let duration_str = args.get(2).map(|s| s.as_str());
// Parse duration if provided
let duration = if let Some(dur_str) = duration_str {
match parse_duration(dur_str) {
Some(d) => Some(d),
None => {
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: false,
message: "Invalid duration format. Use: 30m, 2h, 7d".to_string(),
}).await;
continue;
}
}
} else {
None // Permanent until undressed
};
// Find target user
if let Some((target_user_id, target_conn)) = ws_state
.find_user_by_display_name(realm_id, target_nick)
{
// Try realm avatars first, then server avatars
let avatar_result = realm_avatars::get_realm_avatar_by_slug(&pool, realm_id, avatar_slug).await;
let (avatar_render_data, avatar_id, source) = match avatar_result {
Ok(Some(realm_avatar)) => {
// Resolve realm avatar to render data
match realm_avatars::resolve_realm_avatar_to_render_data(&pool, &realm_avatar, EmotionState::default()).await {
Ok(render_data) => (render_data, realm_avatar.id, "realm"),
Err(e) => {
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: false,
message: format!("Failed to resolve avatar: {:?}", e),
}).await;
continue;
}
}
}
Ok(None) => {
// Try server avatars
match server_avatars::get_server_avatar_by_slug(&pool, avatar_slug).await {
Ok(Some(server_avatar)) => {
match server_avatars::resolve_server_avatar_to_render_data(&pool, &server_avatar, EmotionState::default()).await {
Ok(render_data) => (render_data, server_avatar.id, "server"),
Err(e) => {
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: false,
message: format!("Failed to resolve avatar: {:?}", e),
}).await;
continue;
}
}
}
Ok(None) => {
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: false,
message: format!("Avatar '{}' not found", avatar_slug),
}).await;
continue;
}
Err(e) => {
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: false,
message: format!("Failed to lookup avatar: {:?}", e),
}).await;
continue;
}
}
}
Err(e) => {
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: false,
message: format!("Failed to lookup avatar: {:?}", e),
}).await;
continue;
}
};
// Acquire connection and set RLS context for the update
let mut rls_conn = match pool.acquire().await {
Ok(c) => c,
Err(e) => {
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: false,
message: format!("Database connection error: {:?}", e),
}).await;
continue;
}
};
if let Err(e) = set_rls_user_id(&mut rls_conn, user_id).await {
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: false,
message: format!("Failed to set RLS context: {:?}", e),
}).await;
continue;
}
// Apply forced avatar using connection with RLS context
let apply_result = if source == "server" {
server_avatars::apply_forced_server_avatar(
&mut *rls_conn,
target_user_id,
realm_id,
avatar_id,
Some(user_id),
duration,
).await
} else {
realm_avatars::apply_forced_realm_avatar(
&mut *rls_conn,
target_user_id,
realm_id,
avatar_id,
Some(user_id),
duration,
).await
};
if let Err(e) = apply_result {
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: false,
message: format!("Failed to apply forced avatar: {:?}", e),
}).await;
continue;
}
// Log moderation action
let metadata = serde_json::json!({
"avatar_slug": avatar_slug,
"avatar_id": avatar_id.to_string(),
"source": source,
"duration": duration_str,
});
let _ = moderation::log_moderation_action(
&pool,
realm_id,
user_id,
ActionType::DressUser,
Some(target_user_id),
&format!("Forced {} to wear avatar '{}'", target_nick, avatar_slug),
metadata,
).await;
// Send AvatarForced to target user
let _ = target_conn.direct_tx.send(ServerMessage::AvatarForced {
user_id: target_user_id,
avatar: avatar_render_data.clone(),
reason: ForcedAvatarReason::ModCommand,
forced_by: Some(mod_member.display_name.clone()),
}).await;
// Broadcast avatar update to channel
let _ = tx.send(ServerMessage::AvatarUpdated {
user_id: Some(target_user_id),
guest_session_id: None,
avatar: avatar_render_data,
});
let duration_msg = match duration_str {
Some(d) => format!(" for {}", d),
None => " permanently".to_string(),
};
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: true,
message: format!("Forced {} to wear '{}'{}", target_nick, avatar_slug, duration_msg),
}).await;
} else {
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: false,
message: format!("User '{}' is not online in this realm", target_nick),
}).await;
}
}
"undress" => {
// /mod undress [nick]
if args.is_empty() {
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: false,
message: "Usage: /mod undress [nick]".to_string(),
}).await;
continue;
}
let target_nick = &args[0];
// Find target user
if let Some((target_user_id, target_conn)) = ws_state
.find_user_by_display_name(realm_id, target_nick)
{
// Acquire connection and set RLS context for the update
let mut rls_conn = match pool.acquire().await {
Ok(c) => c,
Err(e) => {
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: false,
message: format!("Database connection error: {:?}", e),
}).await;
continue;
}
};
if let Err(e) = set_rls_user_id(&mut rls_conn, user_id).await {
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: false,
message: format!("Failed to set RLS context: {:?}", e),
}).await;
continue;
}
// Clear forced avatar using connection with RLS context
if let Err(e) = server_avatars::clear_forced_avatar(&mut *rls_conn, target_user_id, realm_id).await {
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: false,
message: format!("Failed to clear forced avatar: {:?}", e),
}).await;
continue;
}
// Get the user's original avatar
let original_avatar = match avatars::get_avatar_with_paths(&pool, target_user_id, realm_id).await {
Ok(Some(avatar)) => avatar.to_render_data(),
Ok(None) => AvatarRenderData::default(),
Err(_) => AvatarRenderData::default(),
};
// Log moderation action
let metadata = serde_json::json!({});
let _ = moderation::log_moderation_action(
&pool,
realm_id,
user_id,
ActionType::UndressUser,
Some(target_user_id),
&format!("Cleared forced avatar for {}", target_nick),
metadata,
).await;
// Send AvatarCleared to target user
let _ = target_conn.direct_tx.send(ServerMessage::AvatarCleared {
user_id: target_user_id,
avatar: original_avatar.clone(),
cleared_by: Some(mod_member.display_name.clone()),
}).await;
// Broadcast avatar update to channel
let _ = tx.send(ServerMessage::AvatarUpdated {
user_id: Some(target_user_id),
guest_session_id: None,
avatar: original_avatar,
});
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: true,
message: format!("Cleared forced avatar for {}", 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,
@ -1233,23 +1543,24 @@ async fn handle_socket(
/// Helper: Get all channel members with their avatar render data.
async fn get_members_with_avatars(
conn: &mut sqlx::pool::PoolConnection<sqlx::Postgres>,
pool: &PgPool,
channel_id: Uuid,
realm_id: Uuid,
) -> Result<Vec<ChannelMemberWithAvatar>, AppError> {
// Get members first
let members = channel_members::get_channel_members(&mut **conn, channel_id, realm_id).await?;
// Fetch avatar data for each member using full avatar with paths
// This avoids the CASE statement approach and handles all emotions correctly
// Fetch avatar data for each member using the effective avatar resolution
// This handles the priority chain: forced > custom > selected realm > selected server > realm default > server default
let mut result = Vec::with_capacity(members.len());
for member in members {
let avatar = if let Some(user_id) = member.user_id {
// Get full avatar and convert to render data for current emotion
avatars::get_avatar_with_paths_conn(&mut **conn, user_id, realm_id)
// Use the new effective avatar resolution which handles all priority levels
avatars::get_effective_avatar_render_data(pool, user_id, realm_id)
.await
.ok()
.flatten()
.map(|a| a.to_render_data())
.map(|(render_data, _source)| render_data)
.unwrap_or_default()
} else {
// Guest users don't have avatars

View file

@ -13,7 +13,7 @@ use std::sync::Arc;
use crate::api::WebSocketState;
#[cfg(feature = "ssr")]
use chattyness_shared::WebSocketConfig;
use chattyness_shared::{SignupConfig, WebSocketConfig};
/// Application state for the public app.
#[cfg(feature = "ssr")]
@ -23,6 +23,7 @@ pub struct AppState {
pub leptos_options: LeptosOptions,
pub ws_state: Arc<WebSocketState>,
pub ws_config: WebSocketConfig,
pub signup_config: SignupConfig,
}
#[cfg(feature = "ssr")]
@ -53,6 +54,13 @@ impl axum::extract::FromRef<AppState> for WebSocketConfig {
}
}
#[cfg(feature = "ssr")]
impl axum::extract::FromRef<AppState> for SignupConfig {
fn from_ref(state: &AppState) -> Self {
state.signup_config.clone()
}
}
/// Shell component for SSR.
pub fn shell(options: LeptosOptions) -> impl IntoView {
view! {

View file

@ -2,6 +2,7 @@
pub mod avatar_canvas;
pub mod avatar_editor;
pub mod avatar_store;
pub mod chat;
pub mod chat_types;
pub mod context_menu;
@ -28,6 +29,7 @@ pub mod ws_client;
pub use avatar_canvas::*;
pub use avatar_editor::*;
pub use avatar_store::*;
pub use chat::*;
pub use chat_types::*;
pub use context_menu::*;

View file

@ -7,7 +7,7 @@
use leptos::prelude::*;
use uuid::Uuid;
use chattyness_db::models::ChannelMemberWithAvatar;
use chattyness_db::models::{ChannelMemberWithAvatar, EmotionState};
use super::chat_types::{ActiveBubble, emotion_bubble_colors};
@ -736,7 +736,7 @@ pub fn AvatarCanvas(
// Draw emotion badge if non-neutral
let current_emotion = m.member.current_emotion;
if current_emotion > 0 {
if current_emotion != EmotionState::Neutral {
let badge_size = 16.0 * layout.text_scale;
let badge_x = layout.avatar_cx + layout.avatar_size / 2.0 - badge_size / 2.0;
let badge_y = layout.avatar_cy - layout.avatar_size / 2.0 - badge_size / 2.0;

View file

@ -0,0 +1,513 @@
//! Avatar store popup component.
//!
//! Allows users to browse and select pre-configured server and realm avatars.
use leptos::prelude::*;
use uuid::Uuid;
use chattyness_db::models::{RealmAvatar, ServerAvatar};
use super::modals::{GuestLockedOverlay, Modal};
use super::tabs::{Tab, TabBar};
/// Avatar store popup component.
///
/// Shows a tabbed interface with:
/// - Server Avatars: Public server-wide pre-configured avatars
/// - Realm Avatars: Public realm-specific pre-configured avatars
///
/// Props:
/// - `open`: Signal controlling visibility
/// - `on_close`: Callback when popup should close
/// - `realm_slug`: Current realm slug for API calls
/// - `is_guest`: Whether the current user is a guest (shows locked overlay)
/// - `on_avatar_selected`: Callback when an avatar is successfully selected
#[component]
pub fn AvatarStorePopup(
#[prop(into)] open: Signal<bool>,
on_close: Callback<()>,
#[prop(into)] realm_slug: Signal<String>,
/// Whether the current user is a guest. Guests see a locked overlay.
#[prop(optional, into)]
is_guest: Option<Signal<bool>>,
/// Callback fired when an avatar is successfully selected (for refreshing avatar state).
#[prop(optional, into)]
on_avatar_selected: Option<Callback<()>>,
) -> impl IntoView {
let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false));
// Tab state
let (active_tab, set_active_tab) = signal("server");
// Server avatars state
let (server_avatars, set_server_avatars) = signal(Vec::<ServerAvatar>::new());
let (server_loading, set_server_loading) = signal(false);
let (server_error, set_server_error) = signal(Option::<String>::None);
let (selected_server_avatar, set_selected_server_avatar) = signal(Option::<Uuid>::None);
// Realm avatars state
let (realm_avatars, set_realm_avatars) = signal(Vec::<RealmAvatar>::new());
let (realm_loading, set_realm_loading) = signal(false);
let (realm_error, set_realm_error) = signal(Option::<String>::None);
let (selected_realm_avatar, set_selected_realm_avatar) = signal(Option::<Uuid>::None);
// Track if tabs have been loaded
let (server_loaded, set_server_loaded) = signal(false);
let (realm_loaded, set_realm_loaded) = signal(false);
// Selection state
let (selecting, set_selecting) = signal(false);
let (selection_error, set_selection_error) = signal(Option::<String>::None);
// Fetch server avatars when popup opens or tab is selected
#[cfg(feature = "hydrate")]
{
use gloo_net::http::Request;
use leptos::task::spawn_local;
Effect::new(move |_| {
if !open.get() {
// Reset state when closing
set_selected_server_avatar.set(None);
set_selected_realm_avatar.set(None);
set_server_loaded.set(false);
set_realm_loaded.set(false);
set_selection_error.set(None);
return;
}
// Only fetch if on server tab and not already loaded
if active_tab.get() != "server" || server_loaded.get() {
return;
}
set_server_loading.set(true);
set_server_error.set(None);
spawn_local(async move {
let response = Request::get("/api/server/avatars").send().await;
match response {
Ok(resp) if resp.ok() => {
if let Ok(data) = resp.json::<Vec<ServerAvatar>>().await {
set_server_avatars.set(data);
set_server_loaded.set(true);
} else {
set_server_error.set(Some("Failed to parse server avatars".to_string()));
}
}
Ok(resp) => {
set_server_error.set(Some(format!(
"Failed to load server avatars: {}",
resp.status()
)));
}
Err(e) => {
set_server_error.set(Some(format!("Network error: {}", e)));
}
}
set_server_loading.set(false);
});
});
}
// Fetch realm avatars when realm tab is selected
#[cfg(feature = "hydrate")]
{
use gloo_net::http::Request;
use leptos::task::spawn_local;
Effect::new(move |_| {
if !open.get() || active_tab.get() != "realm" || realm_loaded.get() {
return;
}
let slug = realm_slug.get();
if slug.is_empty() {
set_realm_error.set(Some("No realm selected".to_string()));
return;
}
set_realm_loading.set(true);
set_realm_error.set(None);
spawn_local(async move {
let response = Request::get(&format!("/api/realms/{}/avatars", slug))
.send()
.await;
match response {
Ok(resp) if resp.ok() => {
if let Ok(data) = resp.json::<Vec<RealmAvatar>>().await {
set_realm_avatars.set(data);
set_realm_loaded.set(true);
} else {
set_realm_error.set(Some("Failed to parse realm avatars".to_string()));
}
}
Ok(resp) => {
set_realm_error.set(Some(format!(
"Failed to load realm avatars: {}",
resp.status()
)));
}
Err(e) => {
set_realm_error.set(Some(format!("Network error: {}", e)));
}
}
set_realm_loading.set(false);
});
});
}
// Handle avatar selection
#[cfg(feature = "hydrate")]
let select_avatar = {
let on_avatar_selected = on_avatar_selected.clone();
Callback::new(move |(avatar_id, source): (Uuid, &'static str)| {
set_selecting.set(true);
set_selection_error.set(None);
let slug = realm_slug.get();
let on_avatar_selected = on_avatar_selected.clone();
leptos::task::spawn_local(async move {
use gloo_net::http::Request;
let body = serde_json::json!({
"avatar_id": avatar_id,
"source": source
});
let response = Request::post(&format!("/api/realms/{}/avatar/select", slug))
.header("Content-Type", "application/json")
.body(body.to_string())
.unwrap()
.send()
.await;
match response {
Ok(resp) if resp.ok() => {
// Notify parent of successful selection
if let Some(callback) = on_avatar_selected {
callback.run(());
}
set_selection_error.set(None);
}
Ok(resp) => {
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_selection_error.set(Some(error_msg.to_string()));
} else {
set_selection_error.set(Some(format!(
"Failed to select avatar: {}",
resp.status()
)));
}
}
Err(e) => {
set_selection_error.set(Some(format!("Network error: {}", e)));
}
}
set_selecting.set(false);
});
})
};
#[cfg(not(feature = "hydrate"))]
let select_avatar = Callback::new(|_: (Uuid, &'static str)| {});
// Handle clear selection
#[cfg(feature = "hydrate")]
let clear_selection = {
let on_avatar_selected = on_avatar_selected.clone();
Callback::new(move |_: ()| {
set_selecting.set(true);
set_selection_error.set(None);
let slug = realm_slug.get();
let on_avatar_selected = on_avatar_selected.clone();
leptos::task::spawn_local(async move {
use gloo_net::http::Request;
let response = Request::delete(&format!("/api/realms/{}/avatar/selection", slug))
.send()
.await;
match response {
Ok(resp) if resp.ok() => {
// Notify parent of successful clear
if let Some(callback) = on_avatar_selected {
callback.run(());
}
set_selection_error.set(None);
}
Ok(resp) => {
set_selection_error.set(Some(format!(
"Failed to clear selection: {}",
resp.status()
)));
}
Err(e) => {
set_selection_error.set(Some(format!("Network error: {}", e)));
}
}
set_selecting.set(false);
});
})
};
#[cfg(not(feature = "hydrate"))]
let clear_selection = Callback::new(|_: ()| {});
view! {
<Modal
open=open
on_close=on_close
title="Avatar Store"
title_id="avatar-store-modal-title"
max_width="max-w-2xl"
class="max-h-[80vh] flex flex-col"
>
<div class="relative flex-1 flex flex-col">
// Tab bar
<TabBar
tabs=vec![
Tab::new("server", "Server Avatars"),
Tab::new("realm", "Realm Avatars"),
]
active=Signal::derive(move || active_tab.get())
on_select=Callback::new(move |id| set_active_tab.set(id))
/>
// Selection error message
<Show when=move || selection_error.get().is_some()>
<div class="bg-red-900/20 border border-red-600 rounded p-3 mx-4 mb-2">
<p class="text-red-400 text-sm">{move || selection_error.get().unwrap_or_default()}</p>
</div>
</Show>
// Tab content
<div class="flex-1 overflow-y-auto min-h-[300px]">
// Server Avatars tab
<Show when=move || active_tab.get() == "server">
<AvatarsTab
avatars=Signal::derive(move || server_avatars.get().into_iter().map(|a| AvatarInfo {
id: a.id,
name: a.name,
description: a.description,
thumbnail_path: a.thumbnail_path,
}).collect())
loading=server_loading
error=server_error
selected_id=selected_server_avatar
set_selected_id=set_selected_server_avatar
source="server"
tab_name="Server"
empty_message="No public server avatars available"
is_guest=is_guest
selecting=selecting
on_select=select_avatar.clone()
on_clear=clear_selection.clone()
/>
</Show>
// Realm Avatars tab
<Show when=move || active_tab.get() == "realm">
<AvatarsTab
avatars=Signal::derive(move || realm_avatars.get().into_iter().map(|a| AvatarInfo {
id: a.id,
name: a.name,
description: a.description,
thumbnail_path: a.thumbnail_path,
}).collect())
loading=realm_loading
error=realm_error
selected_id=selected_realm_avatar
set_selected_id=set_selected_realm_avatar
source="realm"
tab_name="Realm"
empty_message="No public realm avatars available"
is_guest=is_guest
selecting=selecting
on_select=select_avatar.clone()
on_clear=clear_selection.clone()
/>
</Show>
</div>
// Guest locked overlay
<Show when=move || is_guest.get()>
<GuestLockedOverlay />
</Show>
</div>
</Modal>
}
}
/// Simplified avatar info for the grid.
#[derive(Clone)]
struct AvatarInfo {
id: Uuid,
name: String,
description: Option<String>,
thumbnail_path: Option<String>,
}
/// Avatars tab content with selection functionality.
#[component]
fn AvatarsTab(
#[prop(into)] avatars: Signal<Vec<AvatarInfo>>,
#[prop(into)] loading: Signal<bool>,
#[prop(into)] error: Signal<Option<String>>,
#[prop(into)] selected_id: Signal<Option<Uuid>>,
set_selected_id: WriteSignal<Option<Uuid>>,
source: &'static str,
tab_name: &'static str,
empty_message: &'static str,
#[prop(into)] is_guest: Signal<bool>,
#[prop(into)] selecting: Signal<bool>,
on_select: Callback<(Uuid, &'static str)>,
on_clear: Callback<()>,
) -> impl IntoView {
view! {
// Loading state
<Show when=move || loading.get()>
<div class="flex items-center justify-center py-12">
<p class="text-gray-400">{format!("Loading {} avatars...", tab_name.to_lowercase())}</p>
</div>
</Show>
// Error state
<Show when=move || error.get().is_some()>
<div class="bg-red-900/20 border border-red-600 rounded p-4 mb-4 mx-4">
<p class="text-red-400">{move || error.get().unwrap_or_default()}</p>
</div>
</Show>
// Empty state
<Show when=move || !loading.get() && error.get().is_none() && avatars.get().is_empty()>
<div class="flex flex-col items-center justify-center py-12 text-center">
<div class="w-16 h-16 rounded-full bg-gray-700/50 flex items-center justify-center mb-4">
<svg class="w-8 h-8 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
</div>
<p class="text-gray-400">{empty_message}</p>
<p class="text-gray-500 text-sm mt-1">"Pre-configured avatars will appear here when available"</p>
</div>
</Show>
// Grid of avatars
<Show when=move || !loading.get() && !avatars.get().is_empty()>
<div class="p-4">
<div
class="grid grid-cols-4 sm:grid-cols-6 gap-3"
role="listbox"
aria-label=format!("{} avatars", tab_name)
>
<For
each=move || avatars.get()
key=|avatar| avatar.id
children=move |avatar: AvatarInfo| {
let avatar_id = avatar.id;
let avatar_name = avatar.name.clone();
let is_selected = move || selected_id.get() == Some(avatar_id);
let thumbnail_url = avatar.thumbnail_path.clone()
.map(|p| format!("/assets/{}", p))
.unwrap_or_else(|| "/static/placeholder-avatar.svg".to_string());
view! {
<button
type="button"
class=move || format!(
"aspect-square rounded-lg border-2 transition-all p-2 focus:outline-none focus:ring-2 focus:ring-blue-500 {}",
if is_selected() {
"border-blue-500 bg-blue-900/30"
} else {
"border-gray-600 hover:border-gray-500 bg-gray-700/50"
}
)
on:click=move |_| {
set_selected_id.set(Some(avatar_id));
}
role="option"
aria-selected=is_selected
aria-label=avatar_name
>
<img
src=thumbnail_url
alt=""
class="w-full h-full object-contain rounded"
/>
</button>
}
}
/>
</div>
// Selected avatar details and actions
{move || {
let avatar_id = selected_id.get()?;
let avatar = avatars.get().into_iter().find(|a| a.id == avatar_id)?;
let guest = is_guest.get();
let is_selecting = selecting.get();
let (button_text, button_class, button_disabled, button_title) = if guest {
("Sign in to Select", "bg-gray-600 text-gray-400 cursor-not-allowed", true, "Guests cannot select avatars")
} else if is_selecting {
("Selecting...", "bg-blue-600 text-white opacity-50", true, "")
} else {
("Select This Avatar", "bg-blue-600 hover:bg-blue-700 text-white", false, "Use this pre-configured avatar")
};
let avatar_name = avatar.name.clone();
let avatar_description = avatar.description.clone();
let on_select = on_select.clone();
let on_clear = on_clear.clone();
Some(view! {
<div class="mt-4 pt-4 border-t border-gray-700">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<h3 class="text-white font-medium truncate">{avatar_name}</h3>
{avatar_description.map(|desc| view! {
<p class="text-gray-400 text-sm mt-1 line-clamp-2">{desc}</p>
})}
</div>
<div class="flex gap-2 flex-shrink-0">
<button
type="button"
class="px-3 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-colors text-sm disabled:opacity-50"
on:click=move |_| {
on_clear.run(());
}
disabled=is_selecting || guest
title="Clear selection and use custom/default avatar"
>
"Clear"
</button>
<button
type="button"
class=format!("px-4 py-2 rounded-lg transition-colors {}", button_class)
on:click=move |_| {
if !button_disabled {
on_select.run((avatar_id, source));
}
}
disabled=button_disabled
title=button_title
>
{button_text}
</button>
</div>
</div>
</div>
})
}}
</div>
</Show>
}
}

View file

@ -903,6 +903,12 @@ pub fn ChatInput(
<span class="text-purple-600">" | "</span>
<span class="text-purple-300">"teleport"</span>
<span class="text-purple-500">" [nick] [slug]"</span>
<span class="text-purple-600">" | "</span>
<span class="text-purple-300">"dress"</span>
<span class="text-purple-500">" [nick] [avatar] [dur?]"</span>
<span class="text-purple-600">" | "</span>
<span class="text-purple-300">"undress"</span>
<span class="text-purple-500">" [nick]"</span>
</div>
</Show>

View file

@ -50,6 +50,7 @@ pub fn HotkeyHelp(
<HotkeyRow key="i" description="Inventory" />
<HotkeyRow key="k" description="Keybindings" />
<HotkeyRow key="a" description="Avatar editor" />
<HotkeyRow key="t" description="Avatar store" />
<HotkeyRow key="l" description="Message log" />
// Emotions

View file

@ -533,11 +533,10 @@ fn handle_server_message(
if let Some(m) = members_vec.iter_mut().find(|m| {
m.member.user_id == user_id && m.member.guest_session_id == guest_session_id
}) {
// Convert emotion name to index for internal state
// Parse emotion name to EmotionState
m.member.current_emotion = emotion
.parse::<EmotionState>()
.map(|e| e.to_index() as i16)
.unwrap_or(0);
.unwrap_or_default();
m.avatar.emotion_layer = emotion_layer;
}
on_update.run(members_vec.clone());
@ -663,6 +662,35 @@ fn handle_server_message(
});
}
}
ServerMessage::AvatarForced {
user_id,
avatar,
reason: _,
forced_by: _,
} => {
// Update the forced user's avatar
if let Some(m) = members_vec.iter_mut().find(|m| m.member.user_id == Some(user_id)) {
m.avatar.skin_layer = avatar.skin_layer.clone();
m.avatar.clothes_layer = avatar.clothes_layer.clone();
m.avatar.accessories_layer = avatar.accessories_layer.clone();
m.avatar.emotion_layer = avatar.emotion_layer.clone();
}
on_update.run(members_vec.clone());
}
ServerMessage::AvatarCleared {
user_id,
avatar,
cleared_by: _,
} => {
// Restore the user's original avatar
if let Some(m) = members_vec.iter_mut().find(|m| m.member.user_id == Some(user_id)) {
m.avatar.skin_layer = avatar.skin_layer.clone();
m.avatar.clothes_layer = avatar.clothes_layer.clone();
m.avatar.accessories_layer = avatar.accessories_layer.clone();
m.avatar.emotion_layer = avatar.emotion_layer.clone();
}
on_update.run(members_vec.clone());
}
}
}

View file

@ -12,10 +12,10 @@ use leptos_router::hooks::use_params_map;
use uuid::Uuid;
use crate::components::{
ActiveBubble, AvatarEditorPopup, Card, ChatInput, ConversationModal, EmotionKeybindings,
FadingMember, HotkeyHelp, InventoryPopup, KeybindingsPopup, LogPopup, MessageLog,
NotificationMessage, NotificationToast, RealmHeader, RealmSceneViewer, ReconnectionOverlay,
RegisterModal, SettingsPopup, ViewerSettings,
ActiveBubble, AvatarEditorPopup, AvatarStorePopup, Card, ChatInput, ConversationModal,
EmotionKeybindings, FadingMember, HotkeyHelp, InventoryPopup, KeybindingsPopup, LogPopup,
MessageLog, NotificationMessage, NotificationToast, RealmHeader, RealmSceneViewer,
ReconnectionOverlay, RegisterModal, SettingsPopup, ViewerSettings,
};
#[cfg(feature = "hydrate")]
use crate::components::{
@ -90,6 +90,9 @@ pub fn RealmPage() -> impl IntoView {
// Store full avatar data for the editor
let (full_avatar, set_full_avatar) = signal(Option::<AvatarWithPaths>::None);
// Avatar store popup state
let (avatar_store_open, set_avatar_store_open) = signal(false);
// Register modal state (for guest-to-user conversion)
let (register_modal_open, set_register_modal_open) = signal(false);
@ -870,6 +873,7 @@ pub fn RealmPage() -> impl IntoView {
|| log_open.get_untracked()
|| keybindings_open.get_untracked()
|| avatar_editor_open.get_untracked()
|| avatar_store_open.get_untracked()
|| register_modal_open.get_untracked()
|| conversation_modal_open.get_untracked()
{
@ -986,6 +990,13 @@ pub fn RealmPage() -> impl IntoView {
return;
}
// Handle 't' to toggle avatar store (template avatars)
if key == "t" || key == "T" {
set_avatar_store_open.update(|v| *v = !*v);
ev.prevent_default();
return;
}
// Handle 'l' to toggle message log
if key == "l" || key == "L" {
set_log_open.update(|v| *v = !*v);
@ -1395,6 +1406,37 @@ pub fn RealmPage() -> impl IntoView {
}
}
// Avatar store popup
<AvatarStorePopup
open=Signal::derive(move || avatar_store_open.get())
on_close=Callback::new(move |_: ()| set_avatar_store_open.set(false))
realm_slug=Signal::derive(move || slug.get())
is_guest=Signal::derive(move || is_guest.get())
on_avatar_selected=Callback::new(move |_: ()| {
// Refresh avatar data after selection
#[cfg(feature = "hydrate")]
{
use gloo_net::http::Request;
let current_slug = slug.get();
leptos::task::spawn_local(async move {
let response = Request::get(&format!("/api/realms/{}/avatar", current_slug))
.send()
.await;
if let Ok(resp) = response {
if resp.ok() {
if let Ok(avatar) = resp.json::<AvatarWithPaths>().await {
let avail = avatar.compute_emotion_availability();
set_emotion_availability.set(Some(avail));
set_skin_preview_path.set(avatar.skin_layer[4].clone());
set_full_avatar.set(Some(avatar));
}
}
}
});
}
})
/>
// Registration modal for guest-to-user conversion
{
#[cfg(feature = "hydrate")]

View file

@ -129,6 +129,9 @@ pub fn SignupPage() -> impl IntoView {
password: pwd,
confirm_password: confirm_pwd,
realm_slug: slug.unwrap(),
birthday: None,
gender_preference: None,
age_category: None,
};
let response = Request::post("/api/auth/signup")