fix some emotion bugs
This commit is contained in:
parent
bd28e201a2
commit
989e20757b
11 changed files with 1203 additions and 190 deletions
|
|
@ -820,6 +820,28 @@ pub struct Avatar {
|
||||||
pub e_confused_7: Option<Uuid>,
|
pub e_confused_7: Option<Uuid>,
|
||||||
pub e_confused_8: Option<Uuid>,
|
pub e_confused_8: Option<Uuid>,
|
||||||
|
|
||||||
|
// Emotion: Sleeping (e10)
|
||||||
|
pub e_sleeping_0: Option<Uuid>,
|
||||||
|
pub e_sleeping_1: Option<Uuid>,
|
||||||
|
pub e_sleeping_2: Option<Uuid>,
|
||||||
|
pub e_sleeping_3: Option<Uuid>,
|
||||||
|
pub e_sleeping_4: Option<Uuid>,
|
||||||
|
pub e_sleeping_5: Option<Uuid>,
|
||||||
|
pub e_sleeping_6: Option<Uuid>,
|
||||||
|
pub e_sleeping_7: Option<Uuid>,
|
||||||
|
pub e_sleeping_8: Option<Uuid>,
|
||||||
|
|
||||||
|
// Emotion: Wink (e11)
|
||||||
|
pub e_wink_0: Option<Uuid>,
|
||||||
|
pub e_wink_1: Option<Uuid>,
|
||||||
|
pub e_wink_2: Option<Uuid>,
|
||||||
|
pub e_wink_3: Option<Uuid>,
|
||||||
|
pub e_wink_4: Option<Uuid>,
|
||||||
|
pub e_wink_5: Option<Uuid>,
|
||||||
|
pub e_wink_6: Option<Uuid>,
|
||||||
|
pub e_wink_7: Option<Uuid>,
|
||||||
|
pub e_wink_8: Option<Uuid>,
|
||||||
|
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
@ -1722,3 +1744,84 @@ impl Default for EmotionAvailability {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Full Avatar with Paths
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Full avatar data with all inventory UUIDs resolved to asset paths.
|
||||||
|
///
|
||||||
|
/// This struct contains all 135 slots (27 content layer + 108 emotion layer)
|
||||||
|
/// with paths pre-resolved, enabling client-side emotion availability computation
|
||||||
|
/// and rendering without additional server queries.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AvatarWithPaths {
|
||||||
|
pub avatar_id: Uuid,
|
||||||
|
pub current_emotion: i16,
|
||||||
|
/// Asset paths for skin layer positions 0-8
|
||||||
|
pub skin_layer: [Option<String>; 9],
|
||||||
|
/// Asset paths for clothes layer positions 0-8
|
||||||
|
pub clothes_layer: [Option<String>; 9],
|
||||||
|
/// Asset paths for accessories layer positions 0-8
|
||||||
|
pub accessories_layer: [Option<String>; 9],
|
||||||
|
/// Asset paths for all 12 emotions, each with 9 positions.
|
||||||
|
/// Index: emotions[emotion_index][position] where emotion_index is 0-11
|
||||||
|
/// (neutral, happy, sad, angry, surprised, thinking, laughing, crying, love, confused, sleeping, wink)
|
||||||
|
pub emotions: [[Option<String>; 9]; 12],
|
||||||
|
/// Whether each emotion has at least one slot populated (UUID exists, even if path lookup failed).
|
||||||
|
/// This matches the old get_emotion_availability behavior.
|
||||||
|
pub emotions_available: [bool; 12],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AvatarWithPaths {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
avatar_id: Uuid::nil(),
|
||||||
|
current_emotion: 0,
|
||||||
|
skin_layer: Default::default(),
|
||||||
|
clothes_layer: Default::default(),
|
||||||
|
accessories_layer: Default::default(),
|
||||||
|
emotions: Default::default(),
|
||||||
|
emotions_available: [false; 12],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AvatarWithPaths {
|
||||||
|
/// Compute emotion availability from the avatar data.
|
||||||
|
/// Uses pre-computed emotions_available which checks if UUIDs exist (not just resolved paths).
|
||||||
|
pub fn compute_emotion_availability(&self) -> EmotionAvailability {
|
||||||
|
let mut preview_paths: [Option<String>; 12] = Default::default();
|
||||||
|
|
||||||
|
for (i, emotion_layer) in self.emotions.iter().enumerate() {
|
||||||
|
// Preview is position 4 (center)
|
||||||
|
preview_paths[i] = emotion_layer[4].clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
EmotionAvailability {
|
||||||
|
available: self.emotions_available,
|
||||||
|
preview_paths,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the 9-path emotion layer for a specific emotion index (0-11).
|
||||||
|
pub fn get_emotion_layer(&self, emotion: usize) -> [Option<String>; 9] {
|
||||||
|
if emotion < 12 {
|
||||||
|
self.emotions[emotion].clone()
|
||||||
|
} else {
|
||||||
|
Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert to AvatarRenderData for the current emotion.
|
||||||
|
pub fn to_render_data(&self) -> AvatarRenderData {
|
||||||
|
AvatarRenderData {
|
||||||
|
avatar_id: self.avatar_id,
|
||||||
|
current_emotion: self.current_emotion,
|
||||||
|
skin_layer: self.skin_layer.clone(),
|
||||||
|
clothes_layer: self.clothes_layer.clone(),
|
||||||
|
accessories_layer: self.accessories_layer.clone(),
|
||||||
|
emotion_layer: self.get_emotion_layer(self.current_emotion as usize),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
//! Avatar-related database queries.
|
//! Avatar-related database queries.
|
||||||
|
|
||||||
use sqlx::PgExecutor;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use sqlx::{postgres::PgConnection, PgExecutor, PgPool};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::models::{ActiveAvatar, AvatarRenderData, EmotionAvailability};
|
use crate::models::{ActiveAvatar, AvatarWithPaths, EmotionAvailability};
|
||||||
use chattyness_error::AppError;
|
use chattyness_error::AppError;
|
||||||
|
|
||||||
/// Get the active avatar for a user in a realm.
|
/// Get the active avatar for a user in a realm.
|
||||||
|
|
@ -112,96 +114,6 @@ struct EmotionLayerRow {
|
||||||
p8: Option<String>,
|
p8: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get render data for a user's avatar in a realm.
|
|
||||||
///
|
|
||||||
/// Returns the asset paths for all equipped props in the avatar's current state.
|
|
||||||
/// This is a simplified version that only returns the center position (position 4)
|
|
||||||
/// props for skin, clothes, accessories, and current emotion layers.
|
|
||||||
pub async fn get_avatar_render_data<'e>(
|
|
||||||
executor: impl PgExecutor<'e>,
|
|
||||||
user_id: Uuid,
|
|
||||||
realm_id: Uuid,
|
|
||||||
) -> Result<AvatarRenderData, AppError> {
|
|
||||||
// Simplified query: just get position 4 (center) props for each layer
|
|
||||||
// This covers the common case of simple face avatars
|
|
||||||
let render_data = sqlx::query_as::<_, SimplifiedAvatarRow>(
|
|
||||||
r#"
|
|
||||||
SELECT
|
|
||||||
a.id as avatar_id,
|
|
||||||
aa.current_emotion,
|
|
||||||
-- Skin layer center
|
|
||||||
skin.prop_asset_path as skin_center,
|
|
||||||
-- Clothes layer center
|
|
||||||
clothes.prop_asset_path as clothes_center,
|
|
||||||
-- Accessories layer center
|
|
||||||
acc.prop_asset_path as accessories_center,
|
|
||||||
-- Current emotion layer center (based on current_emotion)
|
|
||||||
CASE aa.current_emotion
|
|
||||||
WHEN 0 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_neutral_4)
|
|
||||||
WHEN 1 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_happy_4)
|
|
||||||
WHEN 2 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_sad_4)
|
|
||||||
WHEN 3 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_angry_4)
|
|
||||||
WHEN 4 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_surprised_4)
|
|
||||||
WHEN 5 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_thinking_4)
|
|
||||||
WHEN 6 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_laughing_4)
|
|
||||||
WHEN 7 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_crying_4)
|
|
||||||
WHEN 8 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_love_4)
|
|
||||||
WHEN 9 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_confused_4)
|
|
||||||
END as emotion_center
|
|
||||||
FROM props.active_avatars aa
|
|
||||||
JOIN props.avatars a ON aa.avatar_id = a.id
|
|
||||||
LEFT JOIN props.inventory skin ON a.l_skin_4 = skin.id
|
|
||||||
LEFT JOIN props.inventory clothes ON a.l_clothes_4 = clothes.id
|
|
||||||
LEFT JOIN props.inventory acc ON a.l_accessories_4 = acc.id
|
|
||||||
WHERE aa.user_id = $1 AND aa.realm_id = $2
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(realm_id)
|
|
||||||
.fetch_optional(executor)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
match render_data {
|
|
||||||
Some(row) => Ok(row.into()),
|
|
||||||
None => Ok(AvatarRenderData::default()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Simplified avatar row for center-only rendering.
|
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
|
||||||
struct SimplifiedAvatarRow {
|
|
||||||
avatar_id: Uuid,
|
|
||||||
current_emotion: i16,
|
|
||||||
skin_center: Option<String>,
|
|
||||||
clothes_center: Option<String>,
|
|
||||||
accessories_center: Option<String>,
|
|
||||||
emotion_center: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<SimplifiedAvatarRow> for AvatarRenderData {
|
|
||||||
fn from(row: SimplifiedAvatarRow) -> Self {
|
|
||||||
// For now, only populate position 4 (center)
|
|
||||||
let mut skin_layer: [Option<String>; 9] = Default::default();
|
|
||||||
let mut clothes_layer: [Option<String>; 9] = Default::default();
|
|
||||||
let mut accessories_layer: [Option<String>; 9] = Default::default();
|
|
||||||
let mut emotion_layer: [Option<String>; 9] = Default::default();
|
|
||||||
|
|
||||||
skin_layer[4] = row.skin_center;
|
|
||||||
clothes_layer[4] = row.clothes_center;
|
|
||||||
accessories_layer[4] = row.accessories_center;
|
|
||||||
emotion_layer[4] = row.emotion_center;
|
|
||||||
|
|
||||||
Self {
|
|
||||||
avatar_id: row.avatar_id,
|
|
||||||
current_emotion: row.current_emotion,
|
|
||||||
skin_layer,
|
|
||||||
clothes_layer,
|
|
||||||
accessories_layer,
|
|
||||||
emotion_layer,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get emotion availability for a user's avatar in a realm.
|
/// Get emotion availability for a user's avatar in a realm.
|
||||||
///
|
///
|
||||||
/// Returns which emotions have assets configured (any of positions 0-8 non-null)
|
/// Returns which emotions have assets configured (any of positions 0-8 non-null)
|
||||||
|
|
@ -365,3 +277,688 @@ impl From<EmotionAvailabilityRow> for EmotionAvailability {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the full avatar with all inventory UUIDs resolved to asset paths.
|
||||||
|
///
|
||||||
|
/// This function uses two queries:
|
||||||
|
/// 1. Fetch the avatar row with all 135 UUID slots
|
||||||
|
/// 2. Bulk resolve all UUIDs to asset paths with a single inventory query
|
||||||
|
///
|
||||||
|
/// The result enables client-side emotion availability computation and rendering.
|
||||||
|
pub async fn get_avatar_with_paths(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
realm_id: Uuid,
|
||||||
|
) -> Result<Option<AvatarWithPaths>, AppError> {
|
||||||
|
// Query 1: Get the avatar row with current_emotion from active_avatars
|
||||||
|
let avatar_row = sqlx::query_as::<_, AvatarWithEmotion>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
a.*,
|
||||||
|
aa.current_emotion
|
||||||
|
FROM props.active_avatars aa
|
||||||
|
JOIN props.avatars a ON aa.avatar_id = a.id
|
||||||
|
WHERE aa.user_id = $1 AND aa.realm_id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(realm_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some(avatar) = avatar_row else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collect all non-null UUIDs from the avatar slots
|
||||||
|
let mut uuids: Vec<Uuid> = Vec::new();
|
||||||
|
|
||||||
|
// Content layers
|
||||||
|
collect_uuids(&mut uuids, &[
|
||||||
|
avatar.l_skin_0, avatar.l_skin_1, avatar.l_skin_2,
|
||||||
|
avatar.l_skin_3, avatar.l_skin_4, avatar.l_skin_5,
|
||||||
|
avatar.l_skin_6, avatar.l_skin_7, avatar.l_skin_8,
|
||||||
|
]);
|
||||||
|
collect_uuids(&mut uuids, &[
|
||||||
|
avatar.l_clothes_0, avatar.l_clothes_1, avatar.l_clothes_2,
|
||||||
|
avatar.l_clothes_3, avatar.l_clothes_4, avatar.l_clothes_5,
|
||||||
|
avatar.l_clothes_6, avatar.l_clothes_7, avatar.l_clothes_8,
|
||||||
|
]);
|
||||||
|
collect_uuids(&mut uuids, &[
|
||||||
|
avatar.l_accessories_0, avatar.l_accessories_1, avatar.l_accessories_2,
|
||||||
|
avatar.l_accessories_3, avatar.l_accessories_4, avatar.l_accessories_5,
|
||||||
|
avatar.l_accessories_6, avatar.l_accessories_7, avatar.l_accessories_8,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Emotion layers (12 emotions × 9 positions)
|
||||||
|
collect_uuids(&mut uuids, &[
|
||||||
|
avatar.e_neutral_0, avatar.e_neutral_1, avatar.e_neutral_2,
|
||||||
|
avatar.e_neutral_3, avatar.e_neutral_4, avatar.e_neutral_5,
|
||||||
|
avatar.e_neutral_6, avatar.e_neutral_7, avatar.e_neutral_8,
|
||||||
|
]);
|
||||||
|
collect_uuids(&mut uuids, &[
|
||||||
|
avatar.e_happy_0, avatar.e_happy_1, avatar.e_happy_2,
|
||||||
|
avatar.e_happy_3, avatar.e_happy_4, avatar.e_happy_5,
|
||||||
|
avatar.e_happy_6, avatar.e_happy_7, avatar.e_happy_8,
|
||||||
|
]);
|
||||||
|
collect_uuids(&mut uuids, &[
|
||||||
|
avatar.e_sad_0, avatar.e_sad_1, avatar.e_sad_2,
|
||||||
|
avatar.e_sad_3, avatar.e_sad_4, avatar.e_sad_5,
|
||||||
|
avatar.e_sad_6, avatar.e_sad_7, avatar.e_sad_8,
|
||||||
|
]);
|
||||||
|
collect_uuids(&mut uuids, &[
|
||||||
|
avatar.e_angry_0, avatar.e_angry_1, avatar.e_angry_2,
|
||||||
|
avatar.e_angry_3, avatar.e_angry_4, avatar.e_angry_5,
|
||||||
|
avatar.e_angry_6, avatar.e_angry_7, avatar.e_angry_8,
|
||||||
|
]);
|
||||||
|
collect_uuids(&mut uuids, &[
|
||||||
|
avatar.e_surprised_0, avatar.e_surprised_1, avatar.e_surprised_2,
|
||||||
|
avatar.e_surprised_3, avatar.e_surprised_4, avatar.e_surprised_5,
|
||||||
|
avatar.e_surprised_6, avatar.e_surprised_7, avatar.e_surprised_8,
|
||||||
|
]);
|
||||||
|
collect_uuids(&mut uuids, &[
|
||||||
|
avatar.e_thinking_0, avatar.e_thinking_1, avatar.e_thinking_2,
|
||||||
|
avatar.e_thinking_3, avatar.e_thinking_4, avatar.e_thinking_5,
|
||||||
|
avatar.e_thinking_6, avatar.e_thinking_7, avatar.e_thinking_8,
|
||||||
|
]);
|
||||||
|
collect_uuids(&mut uuids, &[
|
||||||
|
avatar.e_laughing_0, avatar.e_laughing_1, avatar.e_laughing_2,
|
||||||
|
avatar.e_laughing_3, avatar.e_laughing_4, avatar.e_laughing_5,
|
||||||
|
avatar.e_laughing_6, avatar.e_laughing_7, avatar.e_laughing_8,
|
||||||
|
]);
|
||||||
|
collect_uuids(&mut uuids, &[
|
||||||
|
avatar.e_crying_0, avatar.e_crying_1, avatar.e_crying_2,
|
||||||
|
avatar.e_crying_3, avatar.e_crying_4, avatar.e_crying_5,
|
||||||
|
avatar.e_crying_6, avatar.e_crying_7, avatar.e_crying_8,
|
||||||
|
]);
|
||||||
|
collect_uuids(&mut uuids, &[
|
||||||
|
avatar.e_love_0, avatar.e_love_1, avatar.e_love_2,
|
||||||
|
avatar.e_love_3, avatar.e_love_4, avatar.e_love_5,
|
||||||
|
avatar.e_love_6, avatar.e_love_7, avatar.e_love_8,
|
||||||
|
]);
|
||||||
|
collect_uuids(&mut uuids, &[
|
||||||
|
avatar.e_confused_0, avatar.e_confused_1, avatar.e_confused_2,
|
||||||
|
avatar.e_confused_3, avatar.e_confused_4, avatar.e_confused_5,
|
||||||
|
avatar.e_confused_6, avatar.e_confused_7, avatar.e_confused_8,
|
||||||
|
]);
|
||||||
|
collect_uuids(&mut uuids, &[
|
||||||
|
avatar.e_sleeping_0, avatar.e_sleeping_1, avatar.e_sleeping_2,
|
||||||
|
avatar.e_sleeping_3, avatar.e_sleeping_4, avatar.e_sleeping_5,
|
||||||
|
avatar.e_sleeping_6, avatar.e_sleeping_7, avatar.e_sleeping_8,
|
||||||
|
]);
|
||||||
|
collect_uuids(&mut uuids, &[
|
||||||
|
avatar.e_wink_0, avatar.e_wink_1, avatar.e_wink_2,
|
||||||
|
avatar.e_wink_3, avatar.e_wink_4, avatar.e_wink_5,
|
||||||
|
avatar.e_wink_6, avatar.e_wink_7, avatar.e_wink_8,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Query 2: Bulk resolve all UUIDs to paths
|
||||||
|
let paths: HashMap<Uuid, String> = if uuids.is_empty() {
|
||||||
|
HashMap::new()
|
||||||
|
} else {
|
||||||
|
sqlx::query_as::<_, (Uuid, String)>(
|
||||||
|
"SELECT id, prop_asset_path FROM props.inventory WHERE id = ANY($1)",
|
||||||
|
)
|
||||||
|
.bind(&uuids)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build the AvatarWithPaths
|
||||||
|
let resolve = |uuid: Option<Uuid>| -> Option<String> {
|
||||||
|
uuid.and_then(|id| paths.get(&id).cloned())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if any UUID in the array is non-null (emotion is available)
|
||||||
|
let has_any = |slots: &[Option<Uuid>]| -> bool {
|
||||||
|
slots.iter().any(|u| u.is_some())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compute emotions_available from UUID presence (not path resolution)
|
||||||
|
let emotions_available = [
|
||||||
|
has_any(&[avatar.e_neutral_0, avatar.e_neutral_1, avatar.e_neutral_2, avatar.e_neutral_3, avatar.e_neutral_4, avatar.e_neutral_5, avatar.e_neutral_6, avatar.e_neutral_7, avatar.e_neutral_8]),
|
||||||
|
has_any(&[avatar.e_happy_0, avatar.e_happy_1, avatar.e_happy_2, avatar.e_happy_3, avatar.e_happy_4, avatar.e_happy_5, avatar.e_happy_6, avatar.e_happy_7, avatar.e_happy_8]),
|
||||||
|
has_any(&[avatar.e_sad_0, avatar.e_sad_1, avatar.e_sad_2, avatar.e_sad_3, avatar.e_sad_4, avatar.e_sad_5, avatar.e_sad_6, avatar.e_sad_7, avatar.e_sad_8]),
|
||||||
|
has_any(&[avatar.e_angry_0, avatar.e_angry_1, avatar.e_angry_2, avatar.e_angry_3, avatar.e_angry_4, avatar.e_angry_5, avatar.e_angry_6, avatar.e_angry_7, avatar.e_angry_8]),
|
||||||
|
has_any(&[avatar.e_surprised_0, avatar.e_surprised_1, avatar.e_surprised_2, avatar.e_surprised_3, avatar.e_surprised_4, avatar.e_surprised_5, avatar.e_surprised_6, avatar.e_surprised_7, avatar.e_surprised_8]),
|
||||||
|
has_any(&[avatar.e_thinking_0, avatar.e_thinking_1, avatar.e_thinking_2, avatar.e_thinking_3, avatar.e_thinking_4, avatar.e_thinking_5, avatar.e_thinking_6, avatar.e_thinking_7, avatar.e_thinking_8]),
|
||||||
|
has_any(&[avatar.e_laughing_0, avatar.e_laughing_1, avatar.e_laughing_2, avatar.e_laughing_3, avatar.e_laughing_4, avatar.e_laughing_5, avatar.e_laughing_6, avatar.e_laughing_7, avatar.e_laughing_8]),
|
||||||
|
has_any(&[avatar.e_crying_0, avatar.e_crying_1, avatar.e_crying_2, avatar.e_crying_3, avatar.e_crying_4, avatar.e_crying_5, avatar.e_crying_6, avatar.e_crying_7, avatar.e_crying_8]),
|
||||||
|
has_any(&[avatar.e_love_0, avatar.e_love_1, avatar.e_love_2, avatar.e_love_3, avatar.e_love_4, avatar.e_love_5, avatar.e_love_6, avatar.e_love_7, avatar.e_love_8]),
|
||||||
|
has_any(&[avatar.e_confused_0, avatar.e_confused_1, avatar.e_confused_2, avatar.e_confused_3, avatar.e_confused_4, avatar.e_confused_5, avatar.e_confused_6, avatar.e_confused_7, avatar.e_confused_8]),
|
||||||
|
has_any(&[avatar.e_sleeping_0, avatar.e_sleeping_1, avatar.e_sleeping_2, avatar.e_sleeping_3, avatar.e_sleeping_4, avatar.e_sleeping_5, avatar.e_sleeping_6, avatar.e_sleeping_7, avatar.e_sleeping_8]),
|
||||||
|
has_any(&[avatar.e_wink_0, avatar.e_wink_1, avatar.e_wink_2, avatar.e_wink_3, avatar.e_wink_4, avatar.e_wink_5, avatar.e_wink_6, avatar.e_wink_7, avatar.e_wink_8]),
|
||||||
|
];
|
||||||
|
|
||||||
|
Ok(Some(AvatarWithPaths {
|
||||||
|
avatar_id: avatar.id,
|
||||||
|
current_emotion: avatar.current_emotion,
|
||||||
|
skin_layer: [
|
||||||
|
resolve(avatar.l_skin_0), resolve(avatar.l_skin_1), resolve(avatar.l_skin_2),
|
||||||
|
resolve(avatar.l_skin_3), resolve(avatar.l_skin_4), resolve(avatar.l_skin_5),
|
||||||
|
resolve(avatar.l_skin_6), resolve(avatar.l_skin_7), resolve(avatar.l_skin_8),
|
||||||
|
],
|
||||||
|
clothes_layer: [
|
||||||
|
resolve(avatar.l_clothes_0), resolve(avatar.l_clothes_1), resolve(avatar.l_clothes_2),
|
||||||
|
resolve(avatar.l_clothes_3), resolve(avatar.l_clothes_4), resolve(avatar.l_clothes_5),
|
||||||
|
resolve(avatar.l_clothes_6), resolve(avatar.l_clothes_7), resolve(avatar.l_clothes_8),
|
||||||
|
],
|
||||||
|
accessories_layer: [
|
||||||
|
resolve(avatar.l_accessories_0), resolve(avatar.l_accessories_1), resolve(avatar.l_accessories_2),
|
||||||
|
resolve(avatar.l_accessories_3), resolve(avatar.l_accessories_4), resolve(avatar.l_accessories_5),
|
||||||
|
resolve(avatar.l_accessories_6), resolve(avatar.l_accessories_7), resolve(avatar.l_accessories_8),
|
||||||
|
],
|
||||||
|
emotions: [
|
||||||
|
// Neutral (0)
|
||||||
|
[
|
||||||
|
resolve(avatar.e_neutral_0), resolve(avatar.e_neutral_1), resolve(avatar.e_neutral_2),
|
||||||
|
resolve(avatar.e_neutral_3), resolve(avatar.e_neutral_4), resolve(avatar.e_neutral_5),
|
||||||
|
resolve(avatar.e_neutral_6), resolve(avatar.e_neutral_7), resolve(avatar.e_neutral_8),
|
||||||
|
],
|
||||||
|
// Happy (1)
|
||||||
|
[
|
||||||
|
resolve(avatar.e_happy_0), resolve(avatar.e_happy_1), resolve(avatar.e_happy_2),
|
||||||
|
resolve(avatar.e_happy_3), resolve(avatar.e_happy_4), resolve(avatar.e_happy_5),
|
||||||
|
resolve(avatar.e_happy_6), resolve(avatar.e_happy_7), resolve(avatar.e_happy_8),
|
||||||
|
],
|
||||||
|
// Sad (2)
|
||||||
|
[
|
||||||
|
resolve(avatar.e_sad_0), resolve(avatar.e_sad_1), resolve(avatar.e_sad_2),
|
||||||
|
resolve(avatar.e_sad_3), resolve(avatar.e_sad_4), resolve(avatar.e_sad_5),
|
||||||
|
resolve(avatar.e_sad_6), resolve(avatar.e_sad_7), resolve(avatar.e_sad_8),
|
||||||
|
],
|
||||||
|
// Angry (3)
|
||||||
|
[
|
||||||
|
resolve(avatar.e_angry_0), resolve(avatar.e_angry_1), resolve(avatar.e_angry_2),
|
||||||
|
resolve(avatar.e_angry_3), resolve(avatar.e_angry_4), resolve(avatar.e_angry_5),
|
||||||
|
resolve(avatar.e_angry_6), resolve(avatar.e_angry_7), resolve(avatar.e_angry_8),
|
||||||
|
],
|
||||||
|
// Surprised (4)
|
||||||
|
[
|
||||||
|
resolve(avatar.e_surprised_0), resolve(avatar.e_surprised_1), resolve(avatar.e_surprised_2),
|
||||||
|
resolve(avatar.e_surprised_3), resolve(avatar.e_surprised_4), resolve(avatar.e_surprised_5),
|
||||||
|
resolve(avatar.e_surprised_6), resolve(avatar.e_surprised_7), resolve(avatar.e_surprised_8),
|
||||||
|
],
|
||||||
|
// Thinking (5)
|
||||||
|
[
|
||||||
|
resolve(avatar.e_thinking_0), resolve(avatar.e_thinking_1), resolve(avatar.e_thinking_2),
|
||||||
|
resolve(avatar.e_thinking_3), resolve(avatar.e_thinking_4), resolve(avatar.e_thinking_5),
|
||||||
|
resolve(avatar.e_thinking_6), resolve(avatar.e_thinking_7), resolve(avatar.e_thinking_8),
|
||||||
|
],
|
||||||
|
// Laughing (6)
|
||||||
|
[
|
||||||
|
resolve(avatar.e_laughing_0), resolve(avatar.e_laughing_1), resolve(avatar.e_laughing_2),
|
||||||
|
resolve(avatar.e_laughing_3), resolve(avatar.e_laughing_4), resolve(avatar.e_laughing_5),
|
||||||
|
resolve(avatar.e_laughing_6), resolve(avatar.e_laughing_7), resolve(avatar.e_laughing_8),
|
||||||
|
],
|
||||||
|
// Crying (7)
|
||||||
|
[
|
||||||
|
resolve(avatar.e_crying_0), resolve(avatar.e_crying_1), resolve(avatar.e_crying_2),
|
||||||
|
resolve(avatar.e_crying_3), resolve(avatar.e_crying_4), resolve(avatar.e_crying_5),
|
||||||
|
resolve(avatar.e_crying_6), resolve(avatar.e_crying_7), resolve(avatar.e_crying_8),
|
||||||
|
],
|
||||||
|
// Love (8)
|
||||||
|
[
|
||||||
|
resolve(avatar.e_love_0), resolve(avatar.e_love_1), resolve(avatar.e_love_2),
|
||||||
|
resolve(avatar.e_love_3), resolve(avatar.e_love_4), resolve(avatar.e_love_5),
|
||||||
|
resolve(avatar.e_love_6), resolve(avatar.e_love_7), resolve(avatar.e_love_8),
|
||||||
|
],
|
||||||
|
// Confused (9)
|
||||||
|
[
|
||||||
|
resolve(avatar.e_confused_0), resolve(avatar.e_confused_1), resolve(avatar.e_confused_2),
|
||||||
|
resolve(avatar.e_confused_3), resolve(avatar.e_confused_4), resolve(avatar.e_confused_5),
|
||||||
|
resolve(avatar.e_confused_6), resolve(avatar.e_confused_7), resolve(avatar.e_confused_8),
|
||||||
|
],
|
||||||
|
// Sleeping (10)
|
||||||
|
[
|
||||||
|
resolve(avatar.e_sleeping_0), resolve(avatar.e_sleeping_1), resolve(avatar.e_sleeping_2),
|
||||||
|
resolve(avatar.e_sleeping_3), resolve(avatar.e_sleeping_4), resolve(avatar.e_sleeping_5),
|
||||||
|
resolve(avatar.e_sleeping_6), resolve(avatar.e_sleeping_7), resolve(avatar.e_sleeping_8),
|
||||||
|
],
|
||||||
|
// Wink (11)
|
||||||
|
[
|
||||||
|
resolve(avatar.e_wink_0), resolve(avatar.e_wink_1), resolve(avatar.e_wink_2),
|
||||||
|
resolve(avatar.e_wink_3), resolve(avatar.e_wink_4), resolve(avatar.e_wink_5),
|
||||||
|
resolve(avatar.e_wink_6), resolve(avatar.e_wink_7), resolve(avatar.e_wink_8),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
emotions_available,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get full avatar with all inventory UUIDs resolved to asset paths (connection variant).
|
||||||
|
///
|
||||||
|
/// This variant accepts a mutable connection reference for use with RLS-enabled connections.
|
||||||
|
pub async fn get_avatar_with_paths_conn(
|
||||||
|
conn: &mut PgConnection,
|
||||||
|
user_id: Uuid,
|
||||||
|
realm_id: Uuid,
|
||||||
|
) -> Result<Option<AvatarWithPaths>, AppError> {
|
||||||
|
// Query 1: Get the avatar row with current_emotion from active_avatars
|
||||||
|
let avatar_row = sqlx::query_as::<_, AvatarWithEmotion>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
a.*,
|
||||||
|
aa.current_emotion
|
||||||
|
FROM props.active_avatars aa
|
||||||
|
JOIN props.avatars a ON aa.avatar_id = a.id
|
||||||
|
WHERE aa.user_id = $1 AND aa.realm_id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(realm_id)
|
||||||
|
.fetch_optional(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some(avatar) = avatar_row else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collect all non-null UUIDs from the avatar slots
|
||||||
|
let mut uuids: Vec<Uuid> = Vec::new();
|
||||||
|
|
||||||
|
// Content layers
|
||||||
|
collect_uuids(&mut uuids, &[
|
||||||
|
avatar.l_skin_0, avatar.l_skin_1, avatar.l_skin_2,
|
||||||
|
avatar.l_skin_3, avatar.l_skin_4, avatar.l_skin_5,
|
||||||
|
avatar.l_skin_6, avatar.l_skin_7, avatar.l_skin_8,
|
||||||
|
]);
|
||||||
|
collect_uuids(&mut uuids, &[
|
||||||
|
avatar.l_clothes_0, avatar.l_clothes_1, avatar.l_clothes_2,
|
||||||
|
avatar.l_clothes_3, avatar.l_clothes_4, avatar.l_clothes_5,
|
||||||
|
avatar.l_clothes_6, avatar.l_clothes_7, avatar.l_clothes_8,
|
||||||
|
]);
|
||||||
|
collect_uuids(&mut uuids, &[
|
||||||
|
avatar.l_accessories_0, avatar.l_accessories_1, avatar.l_accessories_2,
|
||||||
|
avatar.l_accessories_3, avatar.l_accessories_4, avatar.l_accessories_5,
|
||||||
|
avatar.l_accessories_6, avatar.l_accessories_7, avatar.l_accessories_8,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Emotion layers (12 emotions × 9 positions)
|
||||||
|
collect_uuids(&mut uuids, &[
|
||||||
|
avatar.e_neutral_0, avatar.e_neutral_1, avatar.e_neutral_2,
|
||||||
|
avatar.e_neutral_3, avatar.e_neutral_4, avatar.e_neutral_5,
|
||||||
|
avatar.e_neutral_6, avatar.e_neutral_7, avatar.e_neutral_8,
|
||||||
|
]);
|
||||||
|
collect_uuids(&mut uuids, &[
|
||||||
|
avatar.e_happy_0, avatar.e_happy_1, avatar.e_happy_2,
|
||||||
|
avatar.e_happy_3, avatar.e_happy_4, avatar.e_happy_5,
|
||||||
|
avatar.e_happy_6, avatar.e_happy_7, avatar.e_happy_8,
|
||||||
|
]);
|
||||||
|
collect_uuids(&mut uuids, &[
|
||||||
|
avatar.e_sad_0, avatar.e_sad_1, avatar.e_sad_2,
|
||||||
|
avatar.e_sad_3, avatar.e_sad_4, avatar.e_sad_5,
|
||||||
|
avatar.e_sad_6, avatar.e_sad_7, avatar.e_sad_8,
|
||||||
|
]);
|
||||||
|
collect_uuids(&mut uuids, &[
|
||||||
|
avatar.e_angry_0, avatar.e_angry_1, avatar.e_angry_2,
|
||||||
|
avatar.e_angry_3, avatar.e_angry_4, avatar.e_angry_5,
|
||||||
|
avatar.e_angry_6, avatar.e_angry_7, avatar.e_angry_8,
|
||||||
|
]);
|
||||||
|
collect_uuids(&mut uuids, &[
|
||||||
|
avatar.e_surprised_0, avatar.e_surprised_1, avatar.e_surprised_2,
|
||||||
|
avatar.e_surprised_3, avatar.e_surprised_4, avatar.e_surprised_5,
|
||||||
|
avatar.e_surprised_6, avatar.e_surprised_7, avatar.e_surprised_8,
|
||||||
|
]);
|
||||||
|
collect_uuids(&mut uuids, &[
|
||||||
|
avatar.e_thinking_0, avatar.e_thinking_1, avatar.e_thinking_2,
|
||||||
|
avatar.e_thinking_3, avatar.e_thinking_4, avatar.e_thinking_5,
|
||||||
|
avatar.e_thinking_6, avatar.e_thinking_7, avatar.e_thinking_8,
|
||||||
|
]);
|
||||||
|
collect_uuids(&mut uuids, &[
|
||||||
|
avatar.e_laughing_0, avatar.e_laughing_1, avatar.e_laughing_2,
|
||||||
|
avatar.e_laughing_3, avatar.e_laughing_4, avatar.e_laughing_5,
|
||||||
|
avatar.e_laughing_6, avatar.e_laughing_7, avatar.e_laughing_8,
|
||||||
|
]);
|
||||||
|
collect_uuids(&mut uuids, &[
|
||||||
|
avatar.e_crying_0, avatar.e_crying_1, avatar.e_crying_2,
|
||||||
|
avatar.e_crying_3, avatar.e_crying_4, avatar.e_crying_5,
|
||||||
|
avatar.e_crying_6, avatar.e_crying_7, avatar.e_crying_8,
|
||||||
|
]);
|
||||||
|
collect_uuids(&mut uuids, &[
|
||||||
|
avatar.e_love_0, avatar.e_love_1, avatar.e_love_2,
|
||||||
|
avatar.e_love_3, avatar.e_love_4, avatar.e_love_5,
|
||||||
|
avatar.e_love_6, avatar.e_love_7, avatar.e_love_8,
|
||||||
|
]);
|
||||||
|
collect_uuids(&mut uuids, &[
|
||||||
|
avatar.e_confused_0, avatar.e_confused_1, avatar.e_confused_2,
|
||||||
|
avatar.e_confused_3, avatar.e_confused_4, avatar.e_confused_5,
|
||||||
|
avatar.e_confused_6, avatar.e_confused_7, avatar.e_confused_8,
|
||||||
|
]);
|
||||||
|
collect_uuids(&mut uuids, &[
|
||||||
|
avatar.e_sleeping_0, avatar.e_sleeping_1, avatar.e_sleeping_2,
|
||||||
|
avatar.e_sleeping_3, avatar.e_sleeping_4, avatar.e_sleeping_5,
|
||||||
|
avatar.e_sleeping_6, avatar.e_sleeping_7, avatar.e_sleeping_8,
|
||||||
|
]);
|
||||||
|
collect_uuids(&mut uuids, &[
|
||||||
|
avatar.e_wink_0, avatar.e_wink_1, avatar.e_wink_2,
|
||||||
|
avatar.e_wink_3, avatar.e_wink_4, avatar.e_wink_5,
|
||||||
|
avatar.e_wink_6, avatar.e_wink_7, avatar.e_wink_8,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Query 2: Bulk resolve all UUIDs to paths
|
||||||
|
let paths: HashMap<Uuid, String> = if uuids.is_empty() {
|
||||||
|
HashMap::new()
|
||||||
|
} else {
|
||||||
|
sqlx::query_as::<_, (Uuid, String)>(
|
||||||
|
"SELECT id, prop_asset_path FROM props.inventory WHERE id = ANY($1)",
|
||||||
|
)
|
||||||
|
.bind(&uuids)
|
||||||
|
.fetch_all(&mut *conn)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build the AvatarWithPaths
|
||||||
|
let resolve = |uuid: Option<Uuid>| -> Option<String> {
|
||||||
|
uuid.and_then(|id| paths.get(&id).cloned())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if any UUID in the array is non-null (emotion is available)
|
||||||
|
let has_any = |slots: &[Option<Uuid>]| -> bool {
|
||||||
|
slots.iter().any(|u| u.is_some())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compute emotions_available from UUID presence (not path resolution)
|
||||||
|
let emotions_available = [
|
||||||
|
has_any(&[avatar.e_neutral_0, avatar.e_neutral_1, avatar.e_neutral_2, avatar.e_neutral_3, avatar.e_neutral_4, avatar.e_neutral_5, avatar.e_neutral_6, avatar.e_neutral_7, avatar.e_neutral_8]),
|
||||||
|
has_any(&[avatar.e_happy_0, avatar.e_happy_1, avatar.e_happy_2, avatar.e_happy_3, avatar.e_happy_4, avatar.e_happy_5, avatar.e_happy_6, avatar.e_happy_7, avatar.e_happy_8]),
|
||||||
|
has_any(&[avatar.e_sad_0, avatar.e_sad_1, avatar.e_sad_2, avatar.e_sad_3, avatar.e_sad_4, avatar.e_sad_5, avatar.e_sad_6, avatar.e_sad_7, avatar.e_sad_8]),
|
||||||
|
has_any(&[avatar.e_angry_0, avatar.e_angry_1, avatar.e_angry_2, avatar.e_angry_3, avatar.e_angry_4, avatar.e_angry_5, avatar.e_angry_6, avatar.e_angry_7, avatar.e_angry_8]),
|
||||||
|
has_any(&[avatar.e_surprised_0, avatar.e_surprised_1, avatar.e_surprised_2, avatar.e_surprised_3, avatar.e_surprised_4, avatar.e_surprised_5, avatar.e_surprised_6, avatar.e_surprised_7, avatar.e_surprised_8]),
|
||||||
|
has_any(&[avatar.e_thinking_0, avatar.e_thinking_1, avatar.e_thinking_2, avatar.e_thinking_3, avatar.e_thinking_4, avatar.e_thinking_5, avatar.e_thinking_6, avatar.e_thinking_7, avatar.e_thinking_8]),
|
||||||
|
has_any(&[avatar.e_laughing_0, avatar.e_laughing_1, avatar.e_laughing_2, avatar.e_laughing_3, avatar.e_laughing_4, avatar.e_laughing_5, avatar.e_laughing_6, avatar.e_laughing_7, avatar.e_laughing_8]),
|
||||||
|
has_any(&[avatar.e_crying_0, avatar.e_crying_1, avatar.e_crying_2, avatar.e_crying_3, avatar.e_crying_4, avatar.e_crying_5, avatar.e_crying_6, avatar.e_crying_7, avatar.e_crying_8]),
|
||||||
|
has_any(&[avatar.e_love_0, avatar.e_love_1, avatar.e_love_2, avatar.e_love_3, avatar.e_love_4, avatar.e_love_5, avatar.e_love_6, avatar.e_love_7, avatar.e_love_8]),
|
||||||
|
has_any(&[avatar.e_confused_0, avatar.e_confused_1, avatar.e_confused_2, avatar.e_confused_3, avatar.e_confused_4, avatar.e_confused_5, avatar.e_confused_6, avatar.e_confused_7, avatar.e_confused_8]),
|
||||||
|
has_any(&[avatar.e_sleeping_0, avatar.e_sleeping_1, avatar.e_sleeping_2, avatar.e_sleeping_3, avatar.e_sleeping_4, avatar.e_sleeping_5, avatar.e_sleeping_6, avatar.e_sleeping_7, avatar.e_sleeping_8]),
|
||||||
|
has_any(&[avatar.e_wink_0, avatar.e_wink_1, avatar.e_wink_2, avatar.e_wink_3, avatar.e_wink_4, avatar.e_wink_5, avatar.e_wink_6, avatar.e_wink_7, avatar.e_wink_8]),
|
||||||
|
];
|
||||||
|
|
||||||
|
Ok(Some(AvatarWithPaths {
|
||||||
|
avatar_id: avatar.id,
|
||||||
|
current_emotion: avatar.current_emotion,
|
||||||
|
skin_layer: [
|
||||||
|
resolve(avatar.l_skin_0), resolve(avatar.l_skin_1), resolve(avatar.l_skin_2),
|
||||||
|
resolve(avatar.l_skin_3), resolve(avatar.l_skin_4), resolve(avatar.l_skin_5),
|
||||||
|
resolve(avatar.l_skin_6), resolve(avatar.l_skin_7), resolve(avatar.l_skin_8),
|
||||||
|
],
|
||||||
|
clothes_layer: [
|
||||||
|
resolve(avatar.l_clothes_0), resolve(avatar.l_clothes_1), resolve(avatar.l_clothes_2),
|
||||||
|
resolve(avatar.l_clothes_3), resolve(avatar.l_clothes_4), resolve(avatar.l_clothes_5),
|
||||||
|
resolve(avatar.l_clothes_6), resolve(avatar.l_clothes_7), resolve(avatar.l_clothes_8),
|
||||||
|
],
|
||||||
|
accessories_layer: [
|
||||||
|
resolve(avatar.l_accessories_0), resolve(avatar.l_accessories_1), resolve(avatar.l_accessories_2),
|
||||||
|
resolve(avatar.l_accessories_3), resolve(avatar.l_accessories_4), resolve(avatar.l_accessories_5),
|
||||||
|
resolve(avatar.l_accessories_6), resolve(avatar.l_accessories_7), resolve(avatar.l_accessories_8),
|
||||||
|
],
|
||||||
|
emotions: [
|
||||||
|
// Neutral (0)
|
||||||
|
[
|
||||||
|
resolve(avatar.e_neutral_0), resolve(avatar.e_neutral_1), resolve(avatar.e_neutral_2),
|
||||||
|
resolve(avatar.e_neutral_3), resolve(avatar.e_neutral_4), resolve(avatar.e_neutral_5),
|
||||||
|
resolve(avatar.e_neutral_6), resolve(avatar.e_neutral_7), resolve(avatar.e_neutral_8),
|
||||||
|
],
|
||||||
|
// Happy (1)
|
||||||
|
[
|
||||||
|
resolve(avatar.e_happy_0), resolve(avatar.e_happy_1), resolve(avatar.e_happy_2),
|
||||||
|
resolve(avatar.e_happy_3), resolve(avatar.e_happy_4), resolve(avatar.e_happy_5),
|
||||||
|
resolve(avatar.e_happy_6), resolve(avatar.e_happy_7), resolve(avatar.e_happy_8),
|
||||||
|
],
|
||||||
|
// Sad (2)
|
||||||
|
[
|
||||||
|
resolve(avatar.e_sad_0), resolve(avatar.e_sad_1), resolve(avatar.e_sad_2),
|
||||||
|
resolve(avatar.e_sad_3), resolve(avatar.e_sad_4), resolve(avatar.e_sad_5),
|
||||||
|
resolve(avatar.e_sad_6), resolve(avatar.e_sad_7), resolve(avatar.e_sad_8),
|
||||||
|
],
|
||||||
|
// Angry (3)
|
||||||
|
[
|
||||||
|
resolve(avatar.e_angry_0), resolve(avatar.e_angry_1), resolve(avatar.e_angry_2),
|
||||||
|
resolve(avatar.e_angry_3), resolve(avatar.e_angry_4), resolve(avatar.e_angry_5),
|
||||||
|
resolve(avatar.e_angry_6), resolve(avatar.e_angry_7), resolve(avatar.e_angry_8),
|
||||||
|
],
|
||||||
|
// Surprised (4)
|
||||||
|
[
|
||||||
|
resolve(avatar.e_surprised_0), resolve(avatar.e_surprised_1), resolve(avatar.e_surprised_2),
|
||||||
|
resolve(avatar.e_surprised_3), resolve(avatar.e_surprised_4), resolve(avatar.e_surprised_5),
|
||||||
|
resolve(avatar.e_surprised_6), resolve(avatar.e_surprised_7), resolve(avatar.e_surprised_8),
|
||||||
|
],
|
||||||
|
// Thinking (5)
|
||||||
|
[
|
||||||
|
resolve(avatar.e_thinking_0), resolve(avatar.e_thinking_1), resolve(avatar.e_thinking_2),
|
||||||
|
resolve(avatar.e_thinking_3), resolve(avatar.e_thinking_4), resolve(avatar.e_thinking_5),
|
||||||
|
resolve(avatar.e_thinking_6), resolve(avatar.e_thinking_7), resolve(avatar.e_thinking_8),
|
||||||
|
],
|
||||||
|
// Laughing (6)
|
||||||
|
[
|
||||||
|
resolve(avatar.e_laughing_0), resolve(avatar.e_laughing_1), resolve(avatar.e_laughing_2),
|
||||||
|
resolve(avatar.e_laughing_3), resolve(avatar.e_laughing_4), resolve(avatar.e_laughing_5),
|
||||||
|
resolve(avatar.e_laughing_6), resolve(avatar.e_laughing_7), resolve(avatar.e_laughing_8),
|
||||||
|
],
|
||||||
|
// Crying (7)
|
||||||
|
[
|
||||||
|
resolve(avatar.e_crying_0), resolve(avatar.e_crying_1), resolve(avatar.e_crying_2),
|
||||||
|
resolve(avatar.e_crying_3), resolve(avatar.e_crying_4), resolve(avatar.e_crying_5),
|
||||||
|
resolve(avatar.e_crying_6), resolve(avatar.e_crying_7), resolve(avatar.e_crying_8),
|
||||||
|
],
|
||||||
|
// Love (8)
|
||||||
|
[
|
||||||
|
resolve(avatar.e_love_0), resolve(avatar.e_love_1), resolve(avatar.e_love_2),
|
||||||
|
resolve(avatar.e_love_3), resolve(avatar.e_love_4), resolve(avatar.e_love_5),
|
||||||
|
resolve(avatar.e_love_6), resolve(avatar.e_love_7), resolve(avatar.e_love_8),
|
||||||
|
],
|
||||||
|
// Confused (9)
|
||||||
|
[
|
||||||
|
resolve(avatar.e_confused_0), resolve(avatar.e_confused_1), resolve(avatar.e_confused_2),
|
||||||
|
resolve(avatar.e_confused_3), resolve(avatar.e_confused_4), resolve(avatar.e_confused_5),
|
||||||
|
resolve(avatar.e_confused_6), resolve(avatar.e_confused_7), resolve(avatar.e_confused_8),
|
||||||
|
],
|
||||||
|
// Sleeping (10)
|
||||||
|
[
|
||||||
|
resolve(avatar.e_sleeping_0), resolve(avatar.e_sleeping_1), resolve(avatar.e_sleeping_2),
|
||||||
|
resolve(avatar.e_sleeping_3), resolve(avatar.e_sleeping_4), resolve(avatar.e_sleeping_5),
|
||||||
|
resolve(avatar.e_sleeping_6), resolve(avatar.e_sleeping_7), resolve(avatar.e_sleeping_8),
|
||||||
|
],
|
||||||
|
// Wink (11)
|
||||||
|
[
|
||||||
|
resolve(avatar.e_wink_0), resolve(avatar.e_wink_1), resolve(avatar.e_wink_2),
|
||||||
|
resolve(avatar.e_wink_3), resolve(avatar.e_wink_4), resolve(avatar.e_wink_5),
|
||||||
|
resolve(avatar.e_wink_6), resolve(avatar.e_wink_7), resolve(avatar.e_wink_8),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
emotions_available,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to collect non-null UUIDs into a Vec.
|
||||||
|
fn collect_uuids(dest: &mut Vec<Uuid>, sources: &[Option<Uuid>]) {
|
||||||
|
for uuid in sources {
|
||||||
|
if let Some(id) = uuid {
|
||||||
|
dest.push(*id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Avatar row with current_emotion from active_avatars join.
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
struct AvatarWithEmotion {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub current_emotion: i16,
|
||||||
|
// Content layers
|
||||||
|
pub l_skin_0: Option<Uuid>,
|
||||||
|
pub l_skin_1: Option<Uuid>,
|
||||||
|
pub l_skin_2: Option<Uuid>,
|
||||||
|
pub l_skin_3: Option<Uuid>,
|
||||||
|
pub l_skin_4: Option<Uuid>,
|
||||||
|
pub l_skin_5: Option<Uuid>,
|
||||||
|
pub l_skin_6: Option<Uuid>,
|
||||||
|
pub l_skin_7: Option<Uuid>,
|
||||||
|
pub l_skin_8: Option<Uuid>,
|
||||||
|
pub l_clothes_0: Option<Uuid>,
|
||||||
|
pub l_clothes_1: Option<Uuid>,
|
||||||
|
pub l_clothes_2: Option<Uuid>,
|
||||||
|
pub l_clothes_3: Option<Uuid>,
|
||||||
|
pub l_clothes_4: Option<Uuid>,
|
||||||
|
pub l_clothes_5: Option<Uuid>,
|
||||||
|
pub l_clothes_6: Option<Uuid>,
|
||||||
|
pub l_clothes_7: Option<Uuid>,
|
||||||
|
pub l_clothes_8: Option<Uuid>,
|
||||||
|
pub l_accessories_0: Option<Uuid>,
|
||||||
|
pub l_accessories_1: Option<Uuid>,
|
||||||
|
pub l_accessories_2: Option<Uuid>,
|
||||||
|
pub l_accessories_3: Option<Uuid>,
|
||||||
|
pub l_accessories_4: Option<Uuid>,
|
||||||
|
pub l_accessories_5: Option<Uuid>,
|
||||||
|
pub l_accessories_6: Option<Uuid>,
|
||||||
|
pub l_accessories_7: Option<Uuid>,
|
||||||
|
pub l_accessories_8: Option<Uuid>,
|
||||||
|
// Emotion layers
|
||||||
|
pub e_neutral_0: Option<Uuid>,
|
||||||
|
pub e_neutral_1: Option<Uuid>,
|
||||||
|
pub e_neutral_2: Option<Uuid>,
|
||||||
|
pub e_neutral_3: Option<Uuid>,
|
||||||
|
pub e_neutral_4: Option<Uuid>,
|
||||||
|
pub e_neutral_5: Option<Uuid>,
|
||||||
|
pub e_neutral_6: Option<Uuid>,
|
||||||
|
pub e_neutral_7: Option<Uuid>,
|
||||||
|
pub e_neutral_8: Option<Uuid>,
|
||||||
|
pub e_happy_0: Option<Uuid>,
|
||||||
|
pub e_happy_1: Option<Uuid>,
|
||||||
|
pub e_happy_2: Option<Uuid>,
|
||||||
|
pub e_happy_3: Option<Uuid>,
|
||||||
|
pub e_happy_4: Option<Uuid>,
|
||||||
|
pub e_happy_5: Option<Uuid>,
|
||||||
|
pub e_happy_6: Option<Uuid>,
|
||||||
|
pub e_happy_7: Option<Uuid>,
|
||||||
|
pub e_happy_8: Option<Uuid>,
|
||||||
|
pub e_sad_0: Option<Uuid>,
|
||||||
|
pub e_sad_1: Option<Uuid>,
|
||||||
|
pub e_sad_2: Option<Uuid>,
|
||||||
|
pub e_sad_3: Option<Uuid>,
|
||||||
|
pub e_sad_4: Option<Uuid>,
|
||||||
|
pub e_sad_5: Option<Uuid>,
|
||||||
|
pub e_sad_6: Option<Uuid>,
|
||||||
|
pub e_sad_7: Option<Uuid>,
|
||||||
|
pub e_sad_8: Option<Uuid>,
|
||||||
|
pub e_angry_0: Option<Uuid>,
|
||||||
|
pub e_angry_1: Option<Uuid>,
|
||||||
|
pub e_angry_2: Option<Uuid>,
|
||||||
|
pub e_angry_3: Option<Uuid>,
|
||||||
|
pub e_angry_4: Option<Uuid>,
|
||||||
|
pub e_angry_5: Option<Uuid>,
|
||||||
|
pub e_angry_6: Option<Uuid>,
|
||||||
|
pub e_angry_7: Option<Uuid>,
|
||||||
|
pub e_angry_8: Option<Uuid>,
|
||||||
|
pub e_surprised_0: Option<Uuid>,
|
||||||
|
pub e_surprised_1: Option<Uuid>,
|
||||||
|
pub e_surprised_2: Option<Uuid>,
|
||||||
|
pub e_surprised_3: Option<Uuid>,
|
||||||
|
pub e_surprised_4: Option<Uuid>,
|
||||||
|
pub e_surprised_5: Option<Uuid>,
|
||||||
|
pub e_surprised_6: Option<Uuid>,
|
||||||
|
pub e_surprised_7: Option<Uuid>,
|
||||||
|
pub e_surprised_8: Option<Uuid>,
|
||||||
|
pub e_thinking_0: Option<Uuid>,
|
||||||
|
pub e_thinking_1: Option<Uuid>,
|
||||||
|
pub e_thinking_2: Option<Uuid>,
|
||||||
|
pub e_thinking_3: Option<Uuid>,
|
||||||
|
pub e_thinking_4: Option<Uuid>,
|
||||||
|
pub e_thinking_5: Option<Uuid>,
|
||||||
|
pub e_thinking_6: Option<Uuid>,
|
||||||
|
pub e_thinking_7: Option<Uuid>,
|
||||||
|
pub e_thinking_8: Option<Uuid>,
|
||||||
|
pub e_laughing_0: Option<Uuid>,
|
||||||
|
pub e_laughing_1: Option<Uuid>,
|
||||||
|
pub e_laughing_2: Option<Uuid>,
|
||||||
|
pub e_laughing_3: Option<Uuid>,
|
||||||
|
pub e_laughing_4: Option<Uuid>,
|
||||||
|
pub e_laughing_5: Option<Uuid>,
|
||||||
|
pub e_laughing_6: Option<Uuid>,
|
||||||
|
pub e_laughing_7: Option<Uuid>,
|
||||||
|
pub e_laughing_8: Option<Uuid>,
|
||||||
|
pub e_crying_0: Option<Uuid>,
|
||||||
|
pub e_crying_1: Option<Uuid>,
|
||||||
|
pub e_crying_2: Option<Uuid>,
|
||||||
|
pub e_crying_3: Option<Uuid>,
|
||||||
|
pub e_crying_4: Option<Uuid>,
|
||||||
|
pub e_crying_5: Option<Uuid>,
|
||||||
|
pub e_crying_6: Option<Uuid>,
|
||||||
|
pub e_crying_7: Option<Uuid>,
|
||||||
|
pub e_crying_8: Option<Uuid>,
|
||||||
|
pub e_love_0: Option<Uuid>,
|
||||||
|
pub e_love_1: Option<Uuid>,
|
||||||
|
pub e_love_2: Option<Uuid>,
|
||||||
|
pub e_love_3: Option<Uuid>,
|
||||||
|
pub e_love_4: Option<Uuid>,
|
||||||
|
pub e_love_5: Option<Uuid>,
|
||||||
|
pub e_love_6: Option<Uuid>,
|
||||||
|
pub e_love_7: Option<Uuid>,
|
||||||
|
pub e_love_8: Option<Uuid>,
|
||||||
|
pub e_confused_0: Option<Uuid>,
|
||||||
|
pub e_confused_1: Option<Uuid>,
|
||||||
|
pub e_confused_2: Option<Uuid>,
|
||||||
|
pub e_confused_3: Option<Uuid>,
|
||||||
|
pub e_confused_4: Option<Uuid>,
|
||||||
|
pub e_confused_5: Option<Uuid>,
|
||||||
|
pub e_confused_6: Option<Uuid>,
|
||||||
|
pub e_confused_7: Option<Uuid>,
|
||||||
|
pub e_confused_8: Option<Uuid>,
|
||||||
|
pub e_sleeping_0: Option<Uuid>,
|
||||||
|
pub e_sleeping_1: Option<Uuid>,
|
||||||
|
pub e_sleeping_2: Option<Uuid>,
|
||||||
|
pub e_sleeping_3: Option<Uuid>,
|
||||||
|
pub e_sleeping_4: Option<Uuid>,
|
||||||
|
pub e_sleeping_5: Option<Uuid>,
|
||||||
|
pub e_sleeping_6: Option<Uuid>,
|
||||||
|
pub e_sleeping_7: Option<Uuid>,
|
||||||
|
pub e_sleeping_8: Option<Uuid>,
|
||||||
|
pub e_wink_0: Option<Uuid>,
|
||||||
|
pub e_wink_1: Option<Uuid>,
|
||||||
|
pub e_wink_2: Option<Uuid>,
|
||||||
|
pub e_wink_3: Option<Uuid>,
|
||||||
|
pub e_wink_4: Option<Uuid>,
|
||||||
|
pub e_wink_5: Option<Uuid>,
|
||||||
|
pub e_wink_6: Option<Uuid>,
|
||||||
|
pub e_wink_7: Option<Uuid>,
|
||||||
|
pub e_wink_8: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the current emotion for a user (simplified - no path lookup).
|
||||||
|
///
|
||||||
|
/// This is used when the user's client has the full avatar cached locally
|
||||||
|
/// and can render the emotion from its local cache.
|
||||||
|
pub async fn set_emotion_simple<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
user_id: Uuid,
|
||||||
|
realm_id: Uuid,
|
||||||
|
emotion: i16,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
if emotion < 0 || emotion > 11 {
|
||||||
|
return Err(AppError::Validation("Emotion must be 0-11".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE props.active_avatars
|
||||||
|
SET current_emotion = $3, updated_at = now()
|
||||||
|
WHERE user_id = $1 AND realm_id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(realm_id)
|
||||||
|
.bind(emotion)
|
||||||
|
.execute(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(AppError::NotFound(
|
||||||
|
"No active avatar for this user in this realm".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,12 @@ pub enum ClientMessage {
|
||||||
|
|
||||||
/// Ping to keep connection alive.
|
/// Ping to keep connection alive.
|
||||||
Ping,
|
Ping,
|
||||||
|
|
||||||
|
/// Send a chat message to the channel.
|
||||||
|
SendChatMessage {
|
||||||
|
/// Message content (max 500 chars).
|
||||||
|
content: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Server-to-client WebSocket messages.
|
/// Server-to-client WebSocket messages.
|
||||||
|
|
@ -89,4 +95,26 @@ pub enum ServerMessage {
|
||||||
/// Error message.
|
/// Error message.
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// A chat message was received.
|
||||||
|
ChatMessageReceived {
|
||||||
|
/// Unique message ID.
|
||||||
|
message_id: Uuid,
|
||||||
|
/// User ID of sender (if authenticated user).
|
||||||
|
user_id: Option<Uuid>,
|
||||||
|
/// Guest session ID (if guest).
|
||||||
|
guest_session_id: Option<Uuid>,
|
||||||
|
/// Display name of sender.
|
||||||
|
display_name: String,
|
||||||
|
/// Message content.
|
||||||
|
content: String,
|
||||||
|
/// Current emotion of sender (0-11) for bubble styling.
|
||||||
|
emotion: u8,
|
||||||
|
/// Sender's X position at time of message.
|
||||||
|
x: f64,
|
||||||
|
/// Sender's Y position at time of message.
|
||||||
|
y: f64,
|
||||||
|
/// Server timestamp (milliseconds since epoch).
|
||||||
|
timestamp: i64,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,61 +1,42 @@
|
||||||
//! Avatar API handlers for user UI.
|
//! Avatar API handlers for user UI.
|
||||||
//!
|
//!
|
||||||
//! Handles avatar rendering data retrieval.
|
//! Handles avatar data retrieval.
|
||||||
//! Note: Emotion switching is now handled via WebSocket.
|
//! Note: Emotion switching is handled via WebSocket.
|
||||||
|
|
||||||
use axum::{
|
use axum::extract::Path;
|
||||||
extract::{Path, State},
|
use axum::Json;
|
||||||
Json,
|
|
||||||
};
|
|
||||||
use sqlx::PgPool;
|
|
||||||
|
|
||||||
use chattyness_db::{
|
use chattyness_db::{
|
||||||
models::{AvatarRenderData, EmotionAvailability},
|
models::AvatarWithPaths,
|
||||||
queries::{avatars, realms},
|
queries::{avatars, realms},
|
||||||
};
|
};
|
||||||
use chattyness_error::AppError;
|
use chattyness_error::AppError;
|
||||||
|
|
||||||
use crate::auth::AuthUser;
|
use crate::auth::{AuthUser, RlsConn};
|
||||||
|
|
||||||
/// Get current avatar render data.
|
/// Get full avatar with all paths resolved.
|
||||||
///
|
///
|
||||||
/// GET /api/realms/{slug}/avatar/current
|
/// GET /api/realms/{slug}/avatar
|
||||||
///
|
///
|
||||||
/// Returns the render data for the user's active avatar in this realm.
|
/// Returns the complete avatar data with all inventory UUIDs resolved to asset paths.
|
||||||
pub async fn get_current_avatar(
|
/// This enables client-side emotion availability computation and rendering without
|
||||||
State(pool): State<PgPool>,
|
/// additional server queries.
|
||||||
|
pub async fn get_avatar(
|
||||||
|
rls_conn: RlsConn,
|
||||||
AuthUser(user): AuthUser,
|
AuthUser(user): AuthUser,
|
||||||
Path(slug): Path<String>,
|
Path(slug): Path<String>,
|
||||||
) -> Result<Json<AvatarRenderData>, AppError> {
|
) -> Result<Json<AvatarWithPaths>, AppError> {
|
||||||
|
let mut conn = rls_conn.acquire().await;
|
||||||
|
|
||||||
// Get realm
|
// Get realm
|
||||||
let realm = realms::get_realm_by_slug(&pool, &slug)
|
let realm = realms::get_realm_by_slug(&mut *conn, &slug)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
|
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
|
||||||
|
|
||||||
// Get render data
|
// Get full avatar with paths
|
||||||
let render_data = avatars::get_avatar_render_data(&pool, user.id, realm.id).await?;
|
let avatar = avatars::get_avatar_with_paths_conn(&mut *conn, user.id, realm.id)
|
||||||
|
|
||||||
Ok(Json(render_data))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get emotion availability for the user's avatar.
|
|
||||||
///
|
|
||||||
/// GET /api/realms/{slug}/avatar/emotions
|
|
||||||
///
|
|
||||||
/// Returns which emotions are available (have configured assets) for the user's
|
|
||||||
/// active avatar in this realm, along with preview paths for the emotion picker UI.
|
|
||||||
pub async fn get_emotion_availability(
|
|
||||||
State(pool): State<PgPool>,
|
|
||||||
AuthUser(user): AuthUser,
|
|
||||||
Path(slug): Path<String>,
|
|
||||||
) -> Result<Json<EmotionAvailability>, AppError> {
|
|
||||||
// Get realm
|
|
||||||
let realm = realms::get_realm_by_slug(&pool, &slug)
|
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
|
.unwrap_or_default();
|
||||||
|
|
||||||
// Get emotion availability
|
Ok(Json(avatar))
|
||||||
let availability = avatars::get_emotion_availability(&pool, user.id, realm.id).await?;
|
|
||||||
|
|
||||||
Ok(Json(availability))
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,12 +50,5 @@ pub fn api_router() -> Router<AppState> {
|
||||||
get(websocket::ws_handler::<AppState>),
|
get(websocket::ws_handler::<AppState>),
|
||||||
)
|
)
|
||||||
// Avatar routes (require authentication)
|
// Avatar routes (require authentication)
|
||||||
.route(
|
.route("/realms/{slug}/avatar", get(avatars::get_avatar))
|
||||||
"/realms/{slug}/avatar/current",
|
|
||||||
get(avatars::get_current_avatar),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/realms/{slug}/avatar/emotions",
|
|
||||||
get(avatars::get_emotion_availability),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -191,7 +191,7 @@ async fn handle_socket(
|
||||||
tracing::info!("[WS] Channel joined");
|
tracing::info!("[WS] Channel joined");
|
||||||
|
|
||||||
// Get initial state
|
// 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, channel_id, realm_id).await {
|
||||||
Ok(m) => m,
|
Ok(m) => m,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("[WS] Failed to get members: {:?}", e);
|
tracing::error!("[WS] Failed to get members: {:?}", e);
|
||||||
|
|
@ -231,8 +231,11 @@ async fn handle_socket(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast join to others
|
// Broadcast join to others
|
||||||
let avatar = avatars::get_avatar_render_data(&mut *conn, user.id, realm_id)
|
let avatar = avatars::get_avatar_with_paths_conn(&mut *conn, user.id, realm_id)
|
||||||
.await
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|a| a.to_render_data())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let join_msg = ServerMessage::MemberJoined {
|
let join_msg = ServerMessage::MemberJoined {
|
||||||
member: ChannelMemberWithAvatar { member, avatar },
|
member: ChannelMemberWithAvatar { member, avatar },
|
||||||
|
|
@ -322,6 +325,36 @@ async fn handle_socket(
|
||||||
// Respond with pong directly (not broadcast)
|
// Respond with pong directly (not broadcast)
|
||||||
// This is handled in the send task via individual message
|
// This is handled in the send task via individual message
|
||||||
}
|
}
|
||||||
|
ClientMessage::SendChatMessage { content } => {
|
||||||
|
// Validate message
|
||||||
|
if content.is_empty() || content.len() > 500 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get member's current position and emotion
|
||||||
|
let member_info = channel_members::get_channel_member(
|
||||||
|
&mut *recv_conn,
|
||||||
|
channel_id,
|
||||||
|
user_id,
|
||||||
|
realm_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Ok(Some(member)) = member_info {
|
||||||
|
let msg = ServerMessage::ChatMessageReceived {
|
||||||
|
message_id: Uuid::new_v4(),
|
||||||
|
user_id: Some(user_id),
|
||||||
|
guest_session_id: None,
|
||||||
|
display_name: member.display_name.clone(),
|
||||||
|
content,
|
||||||
|
emotion: member.current_emotion as u8,
|
||||||
|
x: member.position_x,
|
||||||
|
y: member.position_y,
|
||||||
|
timestamp: chrono::Utc::now().timestamp_millis(),
|
||||||
|
};
|
||||||
|
let _ = tx.send(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -376,25 +409,32 @@ async fn handle_socket(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper: Get all channel members with their avatar render data.
|
/// Helper: Get all channel members with their avatar render data.
|
||||||
async fn get_members_with_avatars<'e>(
|
async fn get_members_with_avatars(
|
||||||
executor: impl sqlx::PgExecutor<'e>,
|
conn: &mut sqlx::pool::PoolConnection<sqlx::Postgres>,
|
||||||
channel_id: Uuid,
|
channel_id: Uuid,
|
||||||
realm_id: Uuid,
|
realm_id: Uuid,
|
||||||
) -> Result<Vec<ChannelMemberWithAvatar>, AppError> {
|
) -> Result<Vec<ChannelMemberWithAvatar>, AppError> {
|
||||||
// Get members first, then we need to get avatars
|
// Get members first
|
||||||
// But executor is consumed by the first query, so we need the pool
|
let members = channel_members::get_channel_members(&mut **conn, channel_id, realm_id).await?;
|
||||||
// Actually, let's just inline this to avoid the complexity
|
|
||||||
let members = channel_members::get_channel_members(executor, channel_id, realm_id).await?;
|
|
||||||
|
|
||||||
// For avatar data, we'll just return default for now since the query
|
// Fetch avatar data for each member using full avatar with paths
|
||||||
// would need another executor
|
// This avoids the CASE statement approach and handles all emotions correctly
|
||||||
let result: Vec<ChannelMemberWithAvatar> = members
|
let mut result = Vec::with_capacity(members.len());
|
||||||
.into_iter()
|
for member in members {
|
||||||
.map(|member| ChannelMemberWithAvatar {
|
let avatar = if let Some(user_id) = member.user_id {
|
||||||
member,
|
// Get full avatar and convert to render data for current emotion
|
||||||
avatar: AvatarRenderData::default(),
|
avatars::get_avatar_with_paths_conn(&mut **conn, user_id, realm_id)
|
||||||
})
|
.await
|
||||||
.collect();
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|a| a.to_render_data())
|
||||||
|
.unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
// Guest users don't have avatars
|
||||||
|
AvatarRenderData::default()
|
||||||
|
};
|
||||||
|
result.push(ChannelMemberWithAvatar { member, avatar });
|
||||||
|
}
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
//! Reusable UI components.
|
//! Reusable UI components.
|
||||||
|
|
||||||
pub mod chat;
|
pub mod chat;
|
||||||
|
pub mod chat_types;
|
||||||
pub mod editor;
|
pub mod editor;
|
||||||
pub mod forms;
|
pub mod forms;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
|
|
@ -9,6 +10,7 @@ pub mod scene_viewer;
|
||||||
pub mod ws_client;
|
pub mod ws_client;
|
||||||
|
|
||||||
pub use chat::*;
|
pub use chat::*;
|
||||||
|
pub use chat_types::*;
|
||||||
pub use editor::*;
|
pub use editor::*;
|
||||||
pub use forms::*;
|
pub use forms::*;
|
||||||
pub use layout::*;
|
pub use layout::*;
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,17 @@ pub fn ChatInput(
|
||||||
apply_emotion(emotion_idx);
|
apply_emotion(emotion_idx);
|
||||||
ev.prevent_default();
|
ev.prevent_default();
|
||||||
}
|
}
|
||||||
|
} else if !msg.trim().is_empty() {
|
||||||
|
// Send regular chat message
|
||||||
|
ws_sender.with_value(|sender| {
|
||||||
|
if let Some(send_fn) = sender {
|
||||||
|
send_fn(ClientMessage::SendChatMessage {
|
||||||
|
content: msg.trim().to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
set_message.set(String::new());
|
||||||
|
ev.prevent_default();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -282,7 +293,7 @@ fn EmoteListPopup(
|
||||||
aria-label="Available emotions"
|
aria-label="Available emotions"
|
||||||
>
|
>
|
||||||
<div class="text-gray-400 text-xs mb-2 px-1">"Select an emotion:"</div>
|
<div class="text-gray-400 text-xs mb-2 px-1">"Select an emotion:"</div>
|
||||||
<div class="grid grid-cols-2 gap-1 max-h-64 overflow-y-auto">
|
<div class="grid grid-cols-3 gap-1 max-h-64 overflow-y-auto">
|
||||||
<For
|
<For
|
||||||
each=move || available_emotions()
|
each=move || available_emotions()
|
||||||
key=|(idx, _, _): &(u8, &str, Option<String>)| *idx
|
key=|(idx, _, _): &(u8, &str, Option<String>)| *idx
|
||||||
|
|
@ -301,7 +312,7 @@ fn EmoteListPopup(
|
||||||
emotion_path=preview_path.clone()
|
emotion_path=preview_path.clone()
|
||||||
/>
|
/>
|
||||||
<span class="text-white text-sm">
|
<span class="text-white text-sm">
|
||||||
":"
|
":e "
|
||||||
{emotion_name}
|
{emotion_name}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,15 @@
|
||||||
//! - Background canvas: Static, drawn once when scene loads
|
//! - Background canvas: Static, drawn once when scene loads
|
||||||
//! - Avatar canvas: Dynamic, redrawn when members change
|
//! - Avatar canvas: Dynamic, redrawn when members change
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use chattyness_db::models::{ChannelMemberWithAvatar, Scene};
|
use chattyness_db::models::{ChannelMemberWithAvatar, Scene};
|
||||||
|
|
||||||
|
use super::chat_types::{emotion_bubble_colors, ActiveBubble};
|
||||||
|
|
||||||
/// Parse bounds WKT to extract width and height.
|
/// Parse bounds WKT to extract width and height.
|
||||||
///
|
///
|
||||||
/// Expected format: "POLYGON((0 0, WIDTH 0, WIDTH HEIGHT, 0 HEIGHT, 0 0))"
|
/// Expected format: "POLYGON((0 0, WIDTH 0, WIDTH HEIGHT, 0 HEIGHT, 0 0))"
|
||||||
|
|
@ -59,6 +64,8 @@ pub fn RealmSceneViewer(
|
||||||
#[prop(into)]
|
#[prop(into)]
|
||||||
members: Signal<Vec<ChannelMemberWithAvatar>>,
|
members: Signal<Vec<ChannelMemberWithAvatar>>,
|
||||||
#[prop(into)]
|
#[prop(into)]
|
||||||
|
active_bubbles: Signal<HashMap<(Option<Uuid>, Option<Uuid>), ActiveBubble>>,
|
||||||
|
#[prop(into)]
|
||||||
on_move: Callback<(f64, f64)>,
|
on_move: Callback<(f64, f64)>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let dimensions = parse_bounds_dimensions(&scene.bounds_wkt);
|
let dimensions = parse_bounds_dimensions(&scene.bounds_wkt);
|
||||||
|
|
@ -225,11 +232,12 @@ pub fn RealmSceneViewer(
|
||||||
});
|
});
|
||||||
|
|
||||||
// =========================================================
|
// =========================================================
|
||||||
// Avatar Effect - runs when members change, redraws avatars only
|
// Avatar Effect - runs when members or bubbles change
|
||||||
// =========================================================
|
// =========================================================
|
||||||
Effect::new(move |_| {
|
Effect::new(move |_| {
|
||||||
// Track members signal - this Effect reruns when members change
|
// Track both signals - this Effect reruns when either changes
|
||||||
let current_members = members.get();
|
let current_members = members.get();
|
||||||
|
let current_bubbles = active_bubbles.get();
|
||||||
|
|
||||||
let Some(canvas) = avatar_canvas_ref.get() else {
|
let Some(canvas) = avatar_canvas_ref.get() else {
|
||||||
return;
|
return;
|
||||||
|
|
@ -265,8 +273,21 @@ pub fn RealmSceneViewer(
|
||||||
let ox = offset_x.get_value();
|
let ox = offset_x.get_value();
|
||||||
let oy = offset_y.get_value();
|
let oy = offset_y.get_value();
|
||||||
|
|
||||||
// Draw avatars
|
// Draw avatars first
|
||||||
draw_avatars(&ctx, ¤t_members, sx, sy, ox, oy);
|
draw_avatars(&ctx, ¤t_members, sx, sy, ox, oy);
|
||||||
|
|
||||||
|
// Draw speech bubbles on top
|
||||||
|
let current_time = js_sys::Date::now() as i64;
|
||||||
|
draw_speech_bubbles(
|
||||||
|
&ctx,
|
||||||
|
¤t_members,
|
||||||
|
¤t_bubbles,
|
||||||
|
sx,
|
||||||
|
sy,
|
||||||
|
ox,
|
||||||
|
oy,
|
||||||
|
current_time,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}) as Box<dyn FnOnce()>);
|
}) as Box<dyn FnOnce()>);
|
||||||
|
|
||||||
|
|
@ -419,3 +440,166 @@ fn draw_avatars(
|
||||||
let _ = ctx.fill_text(&member.member.display_name, x, y + 10.0 * scale_y);
|
let _ = ctx.fill_text(&member.member.display_name, x, y + 10.0 * scale_y);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Draw speech bubbles above avatars.
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
fn draw_speech_bubbles(
|
||||||
|
ctx: &web_sys::CanvasRenderingContext2d,
|
||||||
|
members: &[ChannelMemberWithAvatar],
|
||||||
|
bubbles: &HashMap<(Option<Uuid>, Option<Uuid>), ActiveBubble>,
|
||||||
|
scale_x: f64,
|
||||||
|
scale_y: f64,
|
||||||
|
offset_x: f64,
|
||||||
|
offset_y: f64,
|
||||||
|
current_time_ms: i64,
|
||||||
|
) {
|
||||||
|
let scale = scale_x.min(scale_y);
|
||||||
|
let avatar_size = 48.0 * scale;
|
||||||
|
let max_bubble_width = 200.0 * scale;
|
||||||
|
let padding = 8.0 * scale;
|
||||||
|
let font_size = 12.0 * scale;
|
||||||
|
let line_height = 16.0 * scale;
|
||||||
|
let tail_size = 8.0 * scale;
|
||||||
|
let border_radius = 8.0 * scale;
|
||||||
|
|
||||||
|
for member in members {
|
||||||
|
let key = (member.member.user_id, member.member.guest_session_id);
|
||||||
|
|
||||||
|
if let Some(bubble) = bubbles.get(&key) {
|
||||||
|
// Skip expired bubbles
|
||||||
|
if bubble.expires_at < current_time_ms {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use member's CURRENT position, not message position
|
||||||
|
let x = member.member.position_x * scale_x + offset_x;
|
||||||
|
let y = member.member.position_y * scale_y + offset_y;
|
||||||
|
|
||||||
|
// Get emotion colors
|
||||||
|
let (bg_color, border_color, text_color) =
|
||||||
|
emotion_bubble_colors(bubble.message.emotion);
|
||||||
|
|
||||||
|
// Measure and wrap text
|
||||||
|
ctx.set_font(&format!("{}px sans-serif", font_size));
|
||||||
|
let lines = wrap_text(ctx, &bubble.message.content, max_bubble_width - padding * 2.0);
|
||||||
|
|
||||||
|
// Calculate bubble dimensions
|
||||||
|
let bubble_width = lines
|
||||||
|
.iter()
|
||||||
|
.map(|line: &String| -> f64 {
|
||||||
|
ctx.measure_text(line)
|
||||||
|
.map(|m: web_sys::TextMetrics| m.width())
|
||||||
|
.unwrap_or(0.0)
|
||||||
|
})
|
||||||
|
.fold(0.0_f64, |a: f64, b: f64| a.max(b))
|
||||||
|
+ padding * 2.0;
|
||||||
|
let bubble_width = bubble_width.max(60.0 * scale); // Minimum width
|
||||||
|
let bubble_height = (lines.len() as f64) * line_height + padding * 2.0;
|
||||||
|
|
||||||
|
// Position bubble above avatar
|
||||||
|
let bubble_x = x - bubble_width / 2.0;
|
||||||
|
let bubble_y = y - avatar_size - bubble_height - tail_size - 5.0 * scale;
|
||||||
|
|
||||||
|
// Draw bubble background with rounded corners
|
||||||
|
draw_rounded_rect(ctx, bubble_x, bubble_y, bubble_width, bubble_height, border_radius);
|
||||||
|
ctx.set_fill_style_str(bg_color);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.set_stroke_style_str(border_color);
|
||||||
|
ctx.set_line_width(2.0);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Draw tail (triangle pointing down)
|
||||||
|
ctx.begin_path();
|
||||||
|
ctx.move_to(x - tail_size, bubble_y + bubble_height);
|
||||||
|
ctx.line_to(x, bubble_y + bubble_height + tail_size);
|
||||||
|
ctx.line_to(x + tail_size, bubble_y + bubble_height);
|
||||||
|
ctx.close_path();
|
||||||
|
ctx.set_fill_style_str(bg_color);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.set_stroke_style_str(border_color);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Draw text
|
||||||
|
ctx.set_fill_style_str(text_color);
|
||||||
|
ctx.set_text_align("left");
|
||||||
|
ctx.set_text_baseline("top");
|
||||||
|
for (i, line) in lines.iter().enumerate() {
|
||||||
|
let _ = ctx.fill_text(
|
||||||
|
line,
|
||||||
|
bubble_x + padding,
|
||||||
|
bubble_y + padding + (i as f64) * line_height,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrap text to fit within max_width.
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
fn wrap_text(ctx: &web_sys::CanvasRenderingContext2d, text: &str, max_width: f64) -> Vec<String> {
|
||||||
|
let words: Vec<&str> = text.split_whitespace().collect();
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
let mut current_line = String::new();
|
||||||
|
|
||||||
|
for word in words {
|
||||||
|
let test_line = if current_line.is_empty() {
|
||||||
|
word.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{} {}", current_line, word)
|
||||||
|
};
|
||||||
|
|
||||||
|
let width = ctx
|
||||||
|
.measure_text(&test_line)
|
||||||
|
.map(|m: web_sys::TextMetrics| m.width())
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
|
||||||
|
if width > max_width && !current_line.is_empty() {
|
||||||
|
lines.push(current_line);
|
||||||
|
current_line = word.to_string();
|
||||||
|
} else {
|
||||||
|
current_line = test_line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !current_line.is_empty() {
|
||||||
|
lines.push(current_line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit to 4 lines max
|
||||||
|
if lines.len() > 4 {
|
||||||
|
lines.truncate(3);
|
||||||
|
if let Some(last) = lines.last_mut() {
|
||||||
|
last.push_str("...");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle empty text
|
||||||
|
if lines.is_empty() {
|
||||||
|
lines.push(text.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a rounded rectangle path.
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
fn draw_rounded_rect(
|
||||||
|
ctx: &web_sys::CanvasRenderingContext2d,
|
||||||
|
x: f64,
|
||||||
|
y: f64,
|
||||||
|
width: f64,
|
||||||
|
height: f64,
|
||||||
|
radius: f64,
|
||||||
|
) {
|
||||||
|
ctx.begin_path();
|
||||||
|
ctx.move_to(x + radius, y);
|
||||||
|
ctx.line_to(x + width - radius, y);
|
||||||
|
ctx.arc_to(x + width, y, x + width, y + radius, radius).ok();
|
||||||
|
ctx.line_to(x + width, y + height - radius);
|
||||||
|
ctx.arc_to(x + width, y + height, x + width - radius, y + height, radius).ok();
|
||||||
|
ctx.line_to(x + radius, y + height);
|
||||||
|
ctx.arc_to(x, y + height, x, y + height - radius, radius).ok();
|
||||||
|
ctx.line_to(x, y + radius);
|
||||||
|
ctx.arc_to(x, y, x + radius, y, radius).ok();
|
||||||
|
ctx.close_path();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ use leptos::reactive::owner::LocalStorage;
|
||||||
use chattyness_db::models::ChannelMemberWithAvatar;
|
use chattyness_db::models::ChannelMemberWithAvatar;
|
||||||
use chattyness_db::ws_messages::{ClientMessage, ServerMessage};
|
use chattyness_db::ws_messages::{ClientMessage, ServerMessage};
|
||||||
|
|
||||||
|
use super::chat_types::ChatMessage;
|
||||||
|
|
||||||
/// WebSocket connection state.
|
/// WebSocket connection state.
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
pub enum WsState {
|
pub enum WsState {
|
||||||
|
|
@ -38,6 +40,7 @@ pub fn use_channel_websocket(
|
||||||
realm_slug: Signal<String>,
|
realm_slug: Signal<String>,
|
||||||
channel_id: Signal<Option<uuid::Uuid>>,
|
channel_id: Signal<Option<uuid::Uuid>>,
|
||||||
on_members_update: Callback<Vec<ChannelMemberWithAvatar>>,
|
on_members_update: Callback<Vec<ChannelMemberWithAvatar>>,
|
||||||
|
on_chat_message: Callback<ChatMessage>,
|
||||||
) -> (Signal<WsState>, WsSenderStorage) {
|
) -> (Signal<WsState>, WsSenderStorage) {
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
@ -129,6 +132,7 @@ pub fn use_channel_websocket(
|
||||||
// onmessage
|
// onmessage
|
||||||
let members_for_msg = members_clone.clone();
|
let members_for_msg = members_clone.clone();
|
||||||
let on_members_update_clone = on_members_update.clone();
|
let on_members_update_clone = on_members_update.clone();
|
||||||
|
let on_chat_message_clone = on_chat_message.clone();
|
||||||
let onmessage = Closure::wrap(Box::new(move |e: MessageEvent| {
|
let onmessage = Closure::wrap(Box::new(move |e: MessageEvent| {
|
||||||
if let Ok(text) = e.data().dyn_into::<js_sys::JsString>() {
|
if let Ok(text) = e.data().dyn_into::<js_sys::JsString>() {
|
||||||
let text: String = text.into();
|
let text: String = text.into();
|
||||||
|
|
@ -136,7 +140,12 @@ pub fn use_channel_websocket(
|
||||||
web_sys::console::log_1(&format!("[WS<-Server] {}", text).into());
|
web_sys::console::log_1(&format!("[WS<-Server] {}", text).into());
|
||||||
|
|
||||||
if let Ok(msg) = serde_json::from_str::<ServerMessage>(&text) {
|
if let Ok(msg) = serde_json::from_str::<ServerMessage>(&text) {
|
||||||
handle_server_message(msg, &members_for_msg, &on_members_update_clone);
|
handle_server_message(
|
||||||
|
msg,
|
||||||
|
&members_for_msg,
|
||||||
|
&on_members_update_clone,
|
||||||
|
&on_chat_message_clone,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}) as Box<dyn FnMut(MessageEvent)>);
|
}) as Box<dyn FnMut(MessageEvent)>);
|
||||||
|
|
@ -177,6 +186,7 @@ fn handle_server_message(
|
||||||
msg: ServerMessage,
|
msg: ServerMessage,
|
||||||
members: &std::rc::Rc<std::cell::RefCell<Vec<ChannelMemberWithAvatar>>>,
|
members: &std::rc::Rc<std::cell::RefCell<Vec<ChannelMemberWithAvatar>>>,
|
||||||
on_update: &Callback<Vec<ChannelMemberWithAvatar>>,
|
on_update: &Callback<Vec<ChannelMemberWithAvatar>>,
|
||||||
|
on_chat_message: &Callback<ChatMessage>,
|
||||||
) {
|
) {
|
||||||
let mut members_vec = members.borrow_mut();
|
let mut members_vec = members.borrow_mut();
|
||||||
|
|
||||||
|
|
@ -241,6 +251,30 @@ fn handle_server_message(
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
web_sys::console::error_1(&format!("[WS] Server error: {} - {}", code, message).into());
|
web_sys::console::error_1(&format!("[WS] Server error: {} - {}", code, message).into());
|
||||||
}
|
}
|
||||||
|
ServerMessage::ChatMessageReceived {
|
||||||
|
message_id,
|
||||||
|
user_id,
|
||||||
|
guest_session_id,
|
||||||
|
display_name,
|
||||||
|
content,
|
||||||
|
emotion,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
timestamp,
|
||||||
|
} => {
|
||||||
|
let chat_msg = ChatMessage {
|
||||||
|
message_id,
|
||||||
|
user_id,
|
||||||
|
guest_session_id,
|
||||||
|
display_name,
|
||||||
|
content,
|
||||||
|
emotion,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
on_chat_message.run(chat_msg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -250,6 +284,7 @@ pub fn use_channel_websocket(
|
||||||
_realm_slug: Signal<String>,
|
_realm_slug: Signal<String>,
|
||||||
_channel_id: Signal<Option<uuid::Uuid>>,
|
_channel_id: Signal<Option<uuid::Uuid>>,
|
||||||
_on_members_update: Callback<Vec<ChannelMemberWithAvatar>>,
|
_on_members_update: Callback<Vec<ChannelMemberWithAvatar>>,
|
||||||
|
_on_chat_message: Callback<ChatMessage>,
|
||||||
) -> (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);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
//! Realm landing page after login.
|
//! Realm landing page after login.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::reactive::owner::LocalStorage;
|
use leptos::reactive::owner::LocalStorage;
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
|
|
@ -7,12 +9,17 @@ use leptos::task::spawn_local;
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
use leptos_router::hooks::use_navigate;
|
use leptos_router::hooks::use_navigate;
|
||||||
use leptos_router::hooks::use_params_map;
|
use leptos_router::hooks::use_params_map;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::components::{Card, ChatInput, RealmHeader, RealmSceneViewer};
|
use crate::components::{
|
||||||
|
ActiveBubble, Card, ChatInput, ChatMessage, MessageLog, RealmHeader, RealmSceneViewer,
|
||||||
|
DEFAULT_BUBBLE_TIMEOUT_MS,
|
||||||
|
};
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
use crate::components::use_channel_websocket;
|
use crate::components::use_channel_websocket;
|
||||||
use chattyness_db::models::{
|
use chattyness_db::models::{
|
||||||
ChannelMemberWithAvatar, EmotionAvailability, RealmRole, RealmWithUserRole, Scene,
|
AvatarWithPaths, ChannelMemberWithAvatar, EmotionAvailability, RealmRole, RealmWithUserRole,
|
||||||
|
Scene,
|
||||||
};
|
};
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
use chattyness_db::ws_messages::ClientMessage;
|
use chattyness_db::ws_messages::ClientMessage;
|
||||||
|
|
@ -42,6 +49,12 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
// Skin preview path for emote picker (position 4 of skin layer)
|
// Skin preview path for emote picker (position 4 of skin layer)
|
||||||
let (skin_preview_path, set_skin_preview_path) = signal(Option::<String>::None);
|
let (skin_preview_path, set_skin_preview_path) = signal(Option::<String>::None);
|
||||||
|
|
||||||
|
// Chat message state - use StoredValue for WASM compatibility (single-threaded)
|
||||||
|
let message_log: StoredValue<MessageLog, LocalStorage> =
|
||||||
|
StoredValue::new_local(MessageLog::new());
|
||||||
|
let (active_bubbles, set_active_bubbles) =
|
||||||
|
signal(HashMap::<(Option<Uuid>, Option<Uuid>), ActiveBubble>::new());
|
||||||
|
|
||||||
let realm_data = LocalResource::new(move || {
|
let realm_data = LocalResource::new(move || {
|
||||||
let slug = slug.get();
|
let slug = slug.get();
|
||||||
async move {
|
async move {
|
||||||
|
|
@ -93,45 +106,31 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch emotion availability and avatar render data for emote picker
|
// Fetch full avatar with paths for client-side emotion computation
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
{
|
{
|
||||||
let slug_for_emotions = slug.clone();
|
let slug_for_avatar = slug.clone();
|
||||||
Effect::new(move |_| {
|
Effect::new(move |_| {
|
||||||
use gloo_net::http::Request;
|
use gloo_net::http::Request;
|
||||||
|
|
||||||
let current_slug = slug_for_emotions.get();
|
let current_slug = slug_for_avatar.get();
|
||||||
if current_slug.is_empty() {
|
if current_slug.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch emotion availability
|
// Fetch full avatar with all paths resolved
|
||||||
let slug_clone = current_slug.clone();
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
let response = Request::get(&format!("/api/realms/{}/avatar/emotions", slug_clone))
|
let response = Request::get(&format!("/api/realms/{}/avatar", current_slug))
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
if let Ok(resp) = response {
|
if let Ok(resp) = response {
|
||||||
if resp.ok() {
|
if resp.ok() {
|
||||||
if let Ok(avail) = resp.json::<EmotionAvailability>().await {
|
if let Ok(avatar) = resp.json::<AvatarWithPaths>().await {
|
||||||
|
// Compute emotion availability client-side
|
||||||
|
let avail = avatar.compute_emotion_availability();
|
||||||
set_emotion_availability.set(Some(avail));
|
set_emotion_availability.set(Some(avail));
|
||||||
}
|
// Get skin layer position 4 (center) for preview
|
||||||
}
|
set_skin_preview_path.set(avatar.skin_layer[4].clone());
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch avatar render data for skin preview
|
|
||||||
let slug_clone2 = current_slug.clone();
|
|
||||||
spawn_local(async move {
|
|
||||||
use chattyness_db::models::AvatarRenderData;
|
|
||||||
let response = Request::get(&format!("/api/realms/{}/avatar/current", slug_clone2))
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
if let Ok(resp) = response {
|
|
||||||
if resp.ok() {
|
|
||||||
if let Ok(render_data) = resp.json::<AvatarRenderData>().await {
|
|
||||||
// Get skin layer position 4 (center)
|
|
||||||
set_skin_preview_path.set(render_data.skin_layer[4].clone());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -145,11 +144,33 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
set_members.set(new_members);
|
set_members.set(new_members);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Chat message callback
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let on_chat_message = Callback::new(move |msg: ChatMessage| {
|
||||||
|
// Add to message log
|
||||||
|
message_log.update_value(|log| log.push(msg.clone()));
|
||||||
|
|
||||||
|
// Update active bubbles
|
||||||
|
let key = (msg.user_id, msg.guest_session_id);
|
||||||
|
let expires_at = msg.timestamp + DEFAULT_BUBBLE_TIMEOUT_MS;
|
||||||
|
|
||||||
|
set_active_bubbles.update(|bubbles| {
|
||||||
|
bubbles.insert(
|
||||||
|
key,
|
||||||
|
ActiveBubble {
|
||||||
|
message: msg,
|
||||||
|
expires_at,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
let (_ws_state, ws_sender) = use_channel_websocket(
|
let (_ws_state, ws_sender) = use_channel_websocket(
|
||||||
slug,
|
slug,
|
||||||
Signal::derive(move || channel_id.get()),
|
Signal::derive(move || channel_id.get()),
|
||||||
on_members_update,
|
on_members_update,
|
||||||
|
on_chat_message,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set channel ID when scene loads (triggers WebSocket connection)
|
// Set channel ID when scene loads (triggers WebSocket connection)
|
||||||
|
|
@ -163,6 +184,21 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cleanup expired speech bubbles every 5 seconds
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
use gloo_timers::callback::Interval;
|
||||||
|
|
||||||
|
let cleanup_interval = Interval::new(5000, move || {
|
||||||
|
let now = js_sys::Date::now() as i64;
|
||||||
|
set_active_bubbles.update(|bubbles| {
|
||||||
|
bubbles.retain(|_, bubble| bubble.expires_at > now);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Keep interval alive
|
||||||
|
std::mem::forget(cleanup_interval);
|
||||||
|
}
|
||||||
|
|
||||||
// Handle position update via WebSocket
|
// Handle position update via WebSocket
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
let on_move = Callback::new(move |(x, y): (f64, f64)| {
|
let on_move = Callback::new(move |(x, y): (f64, f64)| {
|
||||||
|
|
@ -211,7 +247,8 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
let key = ev.key();
|
let key = ev.key();
|
||||||
|
|
||||||
// If chat is focused, let it handle all keys
|
// If chat is focused, let it handle all keys
|
||||||
if chat_focused.get() {
|
// Use get_untracked() since we're in a JS event handler, not a reactive context
|
||||||
|
if chat_focused.get_untracked() {
|
||||||
*e_pressed_clone.borrow_mut() = false;
|
*e_pressed_clone.borrow_mut() = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -374,12 +411,14 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
let ws_for_chat = ws_sender_clone.clone();
|
let ws_for_chat = ws_sender_clone.clone();
|
||||||
#[cfg(not(feature = "hydrate"))]
|
#[cfg(not(feature = "hydrate"))]
|
||||||
let ws_for_chat: StoredValue<Option<WsSender>, LocalStorage> = StoredValue::new_local(None);
|
let ws_for_chat: StoredValue<Option<WsSender>, LocalStorage> = StoredValue::new_local(None);
|
||||||
|
let active_bubbles_signal = Signal::derive(move || active_bubbles.get());
|
||||||
view! {
|
view! {
|
||||||
<div class="relative w-full">
|
<div class="relative w-full">
|
||||||
<RealmSceneViewer
|
<RealmSceneViewer
|
||||||
scene=scene
|
scene=scene
|
||||||
realm_slug=realm_slug_for_viewer.clone()
|
realm_slug=realm_slug_for_viewer.clone()
|
||||||
members=members_signal
|
members=members_signal
|
||||||
|
active_bubbles=active_bubbles_signal
|
||||||
on_move=on_move.clone()
|
on_move=on_move.clone()
|
||||||
/>
|
/>
|
||||||
<div class="absolute bottom-0 left-0 right-0 z-10 pb-4 px-4 pointer-events-none">
|
<div class="absolute bottom-0 left-0 right-0 z-10 pb-4 px-4 pointer-events-none">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue