add :emote and :list to chat
This commit is contained in:
parent
1ca300098f
commit
bd28e201a2
7 changed files with 741 additions and 22 deletions
|
|
@ -1695,3 +1695,30 @@ pub struct JoinChannelResponse {
|
||||||
pub member: ChannelMemberInfo,
|
pub member: ChannelMemberInfo,
|
||||||
pub members: Vec<ChannelMemberWithAvatar>,
|
pub members: Vec<ChannelMemberWithAvatar>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Emotion Availability
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Emotion availability data for the emote command UI.
|
||||||
|
///
|
||||||
|
/// Indicates which of the 12 emotions have assets configured for the user's avatar,
|
||||||
|
/// and provides preview paths (position 4/center) for rendering in the emotion picker.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct EmotionAvailability {
|
||||||
|
/// Which emotions have at least one non-null asset slot (positions 0-8).
|
||||||
|
/// Index corresponds to emotion: 0=neutral, 1=happy, 2=sad, etc.
|
||||||
|
pub available: [bool; 12],
|
||||||
|
/// Center position (4) asset path for each emotion, used for preview rendering.
|
||||||
|
/// None if that emotion has no center asset.
|
||||||
|
pub preview_paths: [Option<String>; 12],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EmotionAvailability {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
available: [false; 12],
|
||||||
|
preview_paths: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
use sqlx::PgExecutor;
|
use sqlx::PgExecutor;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::models::{ActiveAvatar, AvatarRenderData};
|
use crate::models::{ActiveAvatar, AvatarRenderData, 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.
|
||||||
|
|
@ -35,8 +35,8 @@ pub async fn set_emotion<'e>(
|
||||||
realm_id: Uuid,
|
realm_id: Uuid,
|
||||||
emotion: i16,
|
emotion: i16,
|
||||||
) -> Result<[Option<String>; 9], AppError> {
|
) -> Result<[Option<String>; 9], AppError> {
|
||||||
if emotion < 0 || emotion > 9 {
|
if emotion < 0 || emotion > 11 {
|
||||||
return Err(AppError::Validation("Emotion must be 0-9".to_string()));
|
return Err(AppError::Validation("Emotion must be 0-11".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map emotion index to column prefix
|
// Map emotion index to column prefix
|
||||||
|
|
@ -51,7 +51,9 @@ pub async fn set_emotion<'e>(
|
||||||
7 => "e_crying",
|
7 => "e_crying",
|
||||||
8 => "e_love",
|
8 => "e_love",
|
||||||
9 => "e_confused",
|
9 => "e_confused",
|
||||||
_ => return Err(AppError::Validation("Emotion must be 0-9".to_string())),
|
10 => "e_sleeping",
|
||||||
|
11 => "e_wink",
|
||||||
|
_ => return Err(AppError::Validation("Emotion must be 0-11".to_string())),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build dynamic query for the specific emotion's 9 positions
|
// Build dynamic query for the specific emotion's 9 positions
|
||||||
|
|
@ -199,3 +201,167 @@ impl From<SimplifiedAvatarRow> for AvatarRenderData {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get emotion availability for a user's avatar in a realm.
|
||||||
|
///
|
||||||
|
/// Returns which emotions have assets configured (any of positions 0-8 non-null)
|
||||||
|
/// and the center position (4) preview path for each emotion.
|
||||||
|
pub async fn get_emotion_availability<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
user_id: Uuid,
|
||||||
|
realm_id: Uuid,
|
||||||
|
) -> Result<EmotionAvailability, AppError> {
|
||||||
|
let row = sqlx::query_as::<_, EmotionAvailabilityRow>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
-- Neutral (0): check if any position has asset
|
||||||
|
(a.e_neutral_0 IS NOT NULL OR a.e_neutral_1 IS NOT NULL OR a.e_neutral_2 IS NOT NULL OR
|
||||||
|
a.e_neutral_3 IS NOT NULL OR a.e_neutral_4 IS NOT NULL OR a.e_neutral_5 IS NOT NULL OR
|
||||||
|
a.e_neutral_6 IS NOT NULL OR a.e_neutral_7 IS NOT NULL OR a.e_neutral_8 IS NOT NULL) as avail_0,
|
||||||
|
(SELECT prop_asset_path FROM props.inventory WHERE id = a.e_neutral_4) as preview_0,
|
||||||
|
|
||||||
|
-- Happy (1)
|
||||||
|
(a.e_happy_0 IS NOT NULL OR a.e_happy_1 IS NOT NULL OR a.e_happy_2 IS NOT NULL OR
|
||||||
|
a.e_happy_3 IS NOT NULL OR a.e_happy_4 IS NOT NULL OR a.e_happy_5 IS NOT NULL OR
|
||||||
|
a.e_happy_6 IS NOT NULL OR a.e_happy_7 IS NOT NULL OR a.e_happy_8 IS NOT NULL) as avail_1,
|
||||||
|
(SELECT prop_asset_path FROM props.inventory WHERE id = a.e_happy_4) as preview_1,
|
||||||
|
|
||||||
|
-- Sad (2)
|
||||||
|
(a.e_sad_0 IS NOT NULL OR a.e_sad_1 IS NOT NULL OR a.e_sad_2 IS NOT NULL OR
|
||||||
|
a.e_sad_3 IS NOT NULL OR a.e_sad_4 IS NOT NULL OR a.e_sad_5 IS NOT NULL OR
|
||||||
|
a.e_sad_6 IS NOT NULL OR a.e_sad_7 IS NOT NULL OR a.e_sad_8 IS NOT NULL) as avail_2,
|
||||||
|
(SELECT prop_asset_path FROM props.inventory WHERE id = a.e_sad_4) as preview_2,
|
||||||
|
|
||||||
|
-- Angry (3)
|
||||||
|
(a.e_angry_0 IS NOT NULL OR a.e_angry_1 IS NOT NULL OR a.e_angry_2 IS NOT NULL OR
|
||||||
|
a.e_angry_3 IS NOT NULL OR a.e_angry_4 IS NOT NULL OR a.e_angry_5 IS NOT NULL OR
|
||||||
|
a.e_angry_6 IS NOT NULL OR a.e_angry_7 IS NOT NULL OR a.e_angry_8 IS NOT NULL) as avail_3,
|
||||||
|
(SELECT prop_asset_path FROM props.inventory WHERE id = a.e_angry_4) as preview_3,
|
||||||
|
|
||||||
|
-- Surprised (4)
|
||||||
|
(a.e_surprised_0 IS NOT NULL OR a.e_surprised_1 IS NOT NULL OR a.e_surprised_2 IS NOT NULL OR
|
||||||
|
a.e_surprised_3 IS NOT NULL OR a.e_surprised_4 IS NOT NULL OR a.e_surprised_5 IS NOT NULL OR
|
||||||
|
a.e_surprised_6 IS NOT NULL OR a.e_surprised_7 IS NOT NULL OR a.e_surprised_8 IS NOT NULL) as avail_4,
|
||||||
|
(SELECT prop_asset_path FROM props.inventory WHERE id = a.e_surprised_4) as preview_4,
|
||||||
|
|
||||||
|
-- Thinking (5)
|
||||||
|
(a.e_thinking_0 IS NOT NULL OR a.e_thinking_1 IS NOT NULL OR a.e_thinking_2 IS NOT NULL OR
|
||||||
|
a.e_thinking_3 IS NOT NULL OR a.e_thinking_4 IS NOT NULL OR a.e_thinking_5 IS NOT NULL OR
|
||||||
|
a.e_thinking_6 IS NOT NULL OR a.e_thinking_7 IS NOT NULL OR a.e_thinking_8 IS NOT NULL) as avail_5,
|
||||||
|
(SELECT prop_asset_path FROM props.inventory WHERE id = a.e_thinking_4) as preview_5,
|
||||||
|
|
||||||
|
-- Laughing (6)
|
||||||
|
(a.e_laughing_0 IS NOT NULL OR a.e_laughing_1 IS NOT NULL OR a.e_laughing_2 IS NOT NULL OR
|
||||||
|
a.e_laughing_3 IS NOT NULL OR a.e_laughing_4 IS NOT NULL OR a.e_laughing_5 IS NOT NULL OR
|
||||||
|
a.e_laughing_6 IS NOT NULL OR a.e_laughing_7 IS NOT NULL OR a.e_laughing_8 IS NOT NULL) as avail_6,
|
||||||
|
(SELECT prop_asset_path FROM props.inventory WHERE id = a.e_laughing_4) as preview_6,
|
||||||
|
|
||||||
|
-- Crying (7)
|
||||||
|
(a.e_crying_0 IS NOT NULL OR a.e_crying_1 IS NOT NULL OR a.e_crying_2 IS NOT NULL OR
|
||||||
|
a.e_crying_3 IS NOT NULL OR a.e_crying_4 IS NOT NULL OR a.e_crying_5 IS NOT NULL OR
|
||||||
|
a.e_crying_6 IS NOT NULL OR a.e_crying_7 IS NOT NULL OR a.e_crying_8 IS NOT NULL) as avail_7,
|
||||||
|
(SELECT prop_asset_path FROM props.inventory WHERE id = a.e_crying_4) as preview_7,
|
||||||
|
|
||||||
|
-- Love (8)
|
||||||
|
(a.e_love_0 IS NOT NULL OR a.e_love_1 IS NOT NULL OR a.e_love_2 IS NOT NULL OR
|
||||||
|
a.e_love_3 IS NOT NULL OR a.e_love_4 IS NOT NULL OR a.e_love_5 IS NOT NULL OR
|
||||||
|
a.e_love_6 IS NOT NULL OR a.e_love_7 IS NOT NULL OR a.e_love_8 IS NOT NULL) as avail_8,
|
||||||
|
(SELECT prop_asset_path FROM props.inventory WHERE id = a.e_love_4) as preview_8,
|
||||||
|
|
||||||
|
-- Confused (9)
|
||||||
|
(a.e_confused_0 IS NOT NULL OR a.e_confused_1 IS NOT NULL OR a.e_confused_2 IS NOT NULL OR
|
||||||
|
a.e_confused_3 IS NOT NULL OR a.e_confused_4 IS NOT NULL OR a.e_confused_5 IS NOT NULL OR
|
||||||
|
a.e_confused_6 IS NOT NULL OR a.e_confused_7 IS NOT NULL OR a.e_confused_8 IS NOT NULL) as avail_9,
|
||||||
|
(SELECT prop_asset_path FROM props.inventory WHERE id = a.e_confused_4) as preview_9,
|
||||||
|
|
||||||
|
-- Sleeping (10)
|
||||||
|
(a.e_sleeping_0 IS NOT NULL OR a.e_sleeping_1 IS NOT NULL OR a.e_sleeping_2 IS NOT NULL OR
|
||||||
|
a.e_sleeping_3 IS NOT NULL OR a.e_sleeping_4 IS NOT NULL OR a.e_sleeping_5 IS NOT NULL OR
|
||||||
|
a.e_sleeping_6 IS NOT NULL OR a.e_sleeping_7 IS NOT NULL OR a.e_sleeping_8 IS NOT NULL) as avail_10,
|
||||||
|
(SELECT prop_asset_path FROM props.inventory WHERE id = a.e_sleeping_4) as preview_10,
|
||||||
|
|
||||||
|
-- Wink (11)
|
||||||
|
(a.e_wink_0 IS NOT NULL OR a.e_wink_1 IS NOT NULL OR a.e_wink_2 IS NOT NULL OR
|
||||||
|
a.e_wink_3 IS NOT NULL OR a.e_wink_4 IS NOT NULL OR a.e_wink_5 IS NOT NULL OR
|
||||||
|
a.e_wink_6 IS NOT NULL OR a.e_wink_7 IS NOT NULL OR a.e_wink_8 IS NOT NULL) as avail_11,
|
||||||
|
(SELECT prop_asset_path FROM props.inventory WHERE id = a.e_wink_4) as preview_11
|
||||||
|
|
||||||
|
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(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
Some(r) => Ok(r.into()),
|
||||||
|
None => Ok(EmotionAvailability::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Row type for emotion availability query.
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
struct EmotionAvailabilityRow {
|
||||||
|
avail_0: Option<bool>,
|
||||||
|
preview_0: Option<String>,
|
||||||
|
avail_1: Option<bool>,
|
||||||
|
preview_1: Option<String>,
|
||||||
|
avail_2: Option<bool>,
|
||||||
|
preview_2: Option<String>,
|
||||||
|
avail_3: Option<bool>,
|
||||||
|
preview_3: Option<String>,
|
||||||
|
avail_4: Option<bool>,
|
||||||
|
preview_4: Option<String>,
|
||||||
|
avail_5: Option<bool>,
|
||||||
|
preview_5: Option<String>,
|
||||||
|
avail_6: Option<bool>,
|
||||||
|
preview_6: Option<String>,
|
||||||
|
avail_7: Option<bool>,
|
||||||
|
preview_7: Option<String>,
|
||||||
|
avail_8: Option<bool>,
|
||||||
|
preview_8: Option<String>,
|
||||||
|
avail_9: Option<bool>,
|
||||||
|
preview_9: Option<String>,
|
||||||
|
avail_10: Option<bool>,
|
||||||
|
preview_10: Option<String>,
|
||||||
|
avail_11: Option<bool>,
|
||||||
|
preview_11: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<EmotionAvailabilityRow> for EmotionAvailability {
|
||||||
|
fn from(row: EmotionAvailabilityRow) -> Self {
|
||||||
|
Self {
|
||||||
|
available: [
|
||||||
|
row.avail_0.unwrap_or(false),
|
||||||
|
row.avail_1.unwrap_or(false),
|
||||||
|
row.avail_2.unwrap_or(false),
|
||||||
|
row.avail_3.unwrap_or(false),
|
||||||
|
row.avail_4.unwrap_or(false),
|
||||||
|
row.avail_5.unwrap_or(false),
|
||||||
|
row.avail_6.unwrap_or(false),
|
||||||
|
row.avail_7.unwrap_or(false),
|
||||||
|
row.avail_8.unwrap_or(false),
|
||||||
|
row.avail_9.unwrap_or(false),
|
||||||
|
row.avail_10.unwrap_or(false),
|
||||||
|
row.avail_11.unwrap_or(false),
|
||||||
|
],
|
||||||
|
preview_paths: [
|
||||||
|
row.preview_0,
|
||||||
|
row.preview_1,
|
||||||
|
row.preview_2,
|
||||||
|
row.preview_3,
|
||||||
|
row.preview_4,
|
||||||
|
row.preview_5,
|
||||||
|
row.preview_6,
|
||||||
|
row.preview_7,
|
||||||
|
row.preview_8,
|
||||||
|
row.preview_9,
|
||||||
|
row.preview_10,
|
||||||
|
row.preview_11,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ use axum::{
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
use chattyness_db::{
|
use chattyness_db::{
|
||||||
models::AvatarRenderData,
|
models::{AvatarRenderData, EmotionAvailability},
|
||||||
queries::{avatars, realms},
|
queries::{avatars, realms},
|
||||||
};
|
};
|
||||||
use chattyness_error::AppError;
|
use chattyness_error::AppError;
|
||||||
|
|
@ -37,3 +37,25 @@ pub async fn get_current_avatar(
|
||||||
|
|
||||||
Ok(Json(render_data))
|
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?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
|
||||||
|
|
||||||
|
// Get emotion availability
|
||||||
|
let availability = avatars::get_emotion_availability(&pool, user.id, realm.id).await?;
|
||||||
|
|
||||||
|
Ok(Json(availability))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,4 +54,8 @@ pub fn api_router() -> Router<AppState> {
|
||||||
"/realms/{slug}/avatar/current",
|
"/realms/{slug}/avatar/current",
|
||||||
get(avatars::get_current_avatar),
|
get(avatars::get_current_avatar),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/realms/{slug}/avatar/emotions",
|
||||||
|
get(avatars::get_emotion_availability),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -292,7 +292,8 @@ async fn handle_socket(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
ClientMessage::UpdateEmotion { emotion } => {
|
ClientMessage::UpdateEmotion { emotion } => {
|
||||||
if emotion > 9 {
|
// We have 12 emotions (0-11)
|
||||||
|
if emotion > 11 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let emotion_layer = match avatars::set_emotion(
|
let emotion_layer = match avatars::set_emotion(
|
||||||
|
|
|
||||||
|
|
@ -2,25 +2,238 @@
|
||||||
|
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
|
||||||
/// Chat input component (placeholder UI).
|
use chattyness_db::models::EmotionAvailability;
|
||||||
|
use chattyness_db::ws_messages::ClientMessage;
|
||||||
|
|
||||||
|
use super::ws_client::WsSenderStorage;
|
||||||
|
|
||||||
|
/// Emotion names indexed by emotion slot (0-11).
|
||||||
|
const EMOTIONS: &[&str] = &[
|
||||||
|
"neutral", // 0
|
||||||
|
"happy", // 1
|
||||||
|
"sad", // 2
|
||||||
|
"angry", // 3
|
||||||
|
"surprised", // 4
|
||||||
|
"thinking", // 5
|
||||||
|
"laughing", // 6
|
||||||
|
"crying", // 7
|
||||||
|
"love", // 8
|
||||||
|
"confused", // 9
|
||||||
|
"sleeping", // 10
|
||||||
|
"wink", // 11
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Command mode state for the chat input.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
enum CommandMode {
|
||||||
|
/// Normal chat mode, no command active.
|
||||||
|
None,
|
||||||
|
/// Showing command hint (`:e[mote], :l[ist]`).
|
||||||
|
ShowingHint,
|
||||||
|
/// Showing emotion list popup.
|
||||||
|
ShowingList,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an emote command and return the emotion index if valid.
|
||||||
///
|
///
|
||||||
/// Displays a text input field for typing messages.
|
/// Supports `:e name`, `:emote name` with partial matching.
|
||||||
/// Currently non-functional - just UI placeholder.
|
fn parse_emote_command(cmd: &str) -> Option<u8> {
|
||||||
|
let cmd = cmd.trim().to_lowercase();
|
||||||
|
|
||||||
|
// Strip the leading colon if present
|
||||||
|
let cmd = cmd.strip_prefix(':').unwrap_or(&cmd);
|
||||||
|
|
||||||
|
// Check for `:e <name>` or `:emote <name>`
|
||||||
|
let name = cmd
|
||||||
|
.strip_prefix("emote ")
|
||||||
|
.or_else(|| cmd.strip_prefix("e "))
|
||||||
|
.map(str::trim);
|
||||||
|
|
||||||
|
name.and_then(|n| {
|
||||||
|
EMOTIONS
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.find(|(_, ename)| ename.starts_with(n) || n.starts_with(**ename))
|
||||||
|
.map(|(idx, _)| idx as u8)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chat input component with emote command support.
|
||||||
|
///
|
||||||
|
/// Props:
|
||||||
|
/// - `ws_sender`: WebSocket sender for emotion updates
|
||||||
|
/// - `emotion_availability`: Which emotions are available for the user's avatar
|
||||||
|
/// - `skin_preview_path`: Path to the user's skin layer center asset (for previews)
|
||||||
|
/// - `focus_trigger`: Signal that triggers focus when set to true
|
||||||
|
/// - `on_focus_change`: Callback when focus state changes
|
||||||
#[component]
|
#[component]
|
||||||
pub fn ChatInput() -> impl IntoView {
|
pub fn ChatInput(
|
||||||
|
ws_sender: WsSenderStorage,
|
||||||
|
emotion_availability: Signal<Option<EmotionAvailability>>,
|
||||||
|
skin_preview_path: Signal<Option<String>>,
|
||||||
|
focus_trigger: Signal<bool>,
|
||||||
|
on_focus_change: Callback<bool>,
|
||||||
|
) -> impl IntoView {
|
||||||
let (message, set_message) = signal(String::new());
|
let (message, set_message) = signal(String::new());
|
||||||
|
let (command_mode, set_command_mode) = signal(CommandMode::None);
|
||||||
|
let input_ref = NodeRef::<leptos::html::Input>::new();
|
||||||
|
|
||||||
|
// Handle focus trigger from parent (when ':' is pressed globally)
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
Effect::new(move |_| {
|
||||||
|
if focus_trigger.get() {
|
||||||
|
if let Some(input) = input_ref.get() {
|
||||||
|
let _ = input.focus();
|
||||||
|
// Also set the message to ':' and show the hint
|
||||||
|
set_message.set(":".to_string());
|
||||||
|
set_command_mode.set(CommandMode::ShowingHint);
|
||||||
|
// Update the input value directly
|
||||||
|
input.set_value(":");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply emotion via WebSocket
|
||||||
|
let apply_emotion = {
|
||||||
|
move |emotion_idx: u8| {
|
||||||
|
ws_sender.with_value(|sender| {
|
||||||
|
if let Some(send_fn) = sender {
|
||||||
|
send_fn(ClientMessage::UpdateEmotion {
|
||||||
|
emotion: emotion_idx,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Clear input and close popup
|
||||||
|
set_message.set(String::new());
|
||||||
|
set_command_mode.set(CommandMode::None);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle input changes to detect commands
|
||||||
|
let on_input = move |ev| {
|
||||||
|
let value = event_target_value(&ev);
|
||||||
|
set_message.set(value.clone());
|
||||||
|
|
||||||
|
if value.starts_with(':') {
|
||||||
|
let cmd = value[1..].to_lowercase();
|
||||||
|
|
||||||
|
// Check for list command
|
||||||
|
if cmd == "l" || cmd == "list" {
|
||||||
|
set_command_mode.set(CommandMode::ShowingList);
|
||||||
|
} else if cmd.is_empty()
|
||||||
|
|| cmd.starts_with('e')
|
||||||
|
|| cmd.starts_with('l')
|
||||||
|
|| cmd.starts_with("em")
|
||||||
|
|| cmd.starts_with("li")
|
||||||
|
{
|
||||||
|
// Show hint for incomplete commands
|
||||||
|
set_command_mode.set(CommandMode::ShowingHint);
|
||||||
|
} else if cmd.starts_with("e ") || cmd.starts_with("emote ") {
|
||||||
|
// Typing an emote command - keep hint visible
|
||||||
|
set_command_mode.set(CommandMode::ShowingHint);
|
||||||
|
} else {
|
||||||
|
set_command_mode.set(CommandMode::None);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
set_command_mode.set(CommandMode::None);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle key presses (Enter to execute, Escape to close)
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let on_keydown = {
|
||||||
|
let apply_emotion = apply_emotion.clone();
|
||||||
|
move |ev: web_sys::KeyboardEvent| {
|
||||||
|
let key = ev.key();
|
||||||
|
|
||||||
|
if key == "Escape" {
|
||||||
|
set_command_mode.set(CommandMode::None);
|
||||||
|
set_message.set(String::new());
|
||||||
|
ev.prevent_default();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if key == "Enter" {
|
||||||
|
let msg = message.get();
|
||||||
|
if msg.starts_with(':') {
|
||||||
|
// Try to parse as emote command
|
||||||
|
if let Some(emotion_idx) = parse_emote_command(&msg) {
|
||||||
|
apply_emotion(emotion_idx);
|
||||||
|
ev.prevent_default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(feature = "hydrate"))]
|
||||||
|
let on_keydown = move |_ev| {};
|
||||||
|
|
||||||
|
// Focus/blur handlers
|
||||||
|
let on_focus = {
|
||||||
|
let on_focus_change = on_focus_change.clone();
|
||||||
|
move |_ev| {
|
||||||
|
on_focus_change.run(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_blur = {
|
||||||
|
move |_ev| {
|
||||||
|
on_focus_change.run(false);
|
||||||
|
// Note: We don't close the popup on blur to allow click events on popup items to fire
|
||||||
|
// The popup is closed when an item is selected or Escape is pressed
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Popup select handler
|
||||||
|
let on_popup_select = Callback::new(move |emotion_idx: u8| {
|
||||||
|
apply_emotion(emotion_idx);
|
||||||
|
});
|
||||||
|
|
||||||
|
let on_popup_close = Callback::new(move |_: ()| {
|
||||||
|
set_command_mode.set(CommandMode::None);
|
||||||
|
});
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="chat-input-container w-full max-w-4xl mx-auto">
|
<div class="chat-input-container w-full max-w-4xl mx-auto pointer-events-auto relative">
|
||||||
|
// Command hint bar
|
||||||
|
<Show when=move || command_mode.get() == CommandMode::ShowingHint>
|
||||||
|
<div class="absolute bottom-full left-0 mb-1 px-3 py-1 bg-gray-700/90 backdrop-blur-sm rounded text-sm">
|
||||||
|
<span class="text-gray-400">":"</span>
|
||||||
|
<span class="text-blue-400">"e"</span>
|
||||||
|
<span class="text-gray-500">"[mote] name"</span>
|
||||||
|
<span class="text-gray-600 mx-2">"|"</span>
|
||||||
|
<span class="text-gray-400">":"</span>
|
||||||
|
<span class="text-blue-400">"l"</span>
|
||||||
|
<span class="text-gray-500">"[ist]"</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
// Emotion list popup
|
||||||
|
<Show when=move || command_mode.get() == CommandMode::ShowingList>
|
||||||
|
<EmoteListPopup
|
||||||
|
emotion_availability=emotion_availability
|
||||||
|
skin_preview_path=skin_preview_path
|
||||||
|
on_select=on_popup_select
|
||||||
|
on_close=on_popup_close
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<div class="flex gap-2 items-center bg-gray-900/80 backdrop-blur-sm rounded-lg p-2 border border-gray-600">
|
<div class="flex gap-2 items-center bg-gray-900/80 backdrop-blur-sm rounded-lg p-2 border border-gray-600">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Type a message..."
|
placeholder="Type a message... (: for commands)"
|
||||||
class="flex-1 bg-transparent text-white placeholder-gray-500 px-3 py-2 outline-none"
|
class="flex-1 bg-transparent text-white placeholder-gray-500 px-3 py-2 outline-none"
|
||||||
prop:value=move || message.get()
|
prop:value=move || message.get()
|
||||||
on:input=move |ev| {
|
on:input=on_input
|
||||||
set_message.set(event_target_value(&ev));
|
on:keydown=on_keydown
|
||||||
}
|
on:focus=on_focus
|
||||||
|
on:blur=on_blur
|
||||||
|
node_ref=input_ref
|
||||||
|
autocomplete="off"
|
||||||
|
aria-label="Chat message input"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -30,9 +243,194 @@ pub fn ChatInput() -> impl IntoView {
|
||||||
"Send"
|
"Send"
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-gray-500 text-xs mt-2 text-center">
|
|
||||||
"Chat functionality coming soon"
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Emote list popup component.
|
||||||
|
///
|
||||||
|
/// Shows available emotions in a 2-column grid with avatar previews.
|
||||||
|
#[component]
|
||||||
|
fn EmoteListPopup(
|
||||||
|
emotion_availability: Signal<Option<EmotionAvailability>>,
|
||||||
|
skin_preview_path: Signal<Option<String>>,
|
||||||
|
on_select: Callback<u8>,
|
||||||
|
on_close: Callback<()>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
// Get list of available emotions
|
||||||
|
let available_emotions = move || {
|
||||||
|
emotion_availability
|
||||||
|
.get()
|
||||||
|
.map(|avail| {
|
||||||
|
EMOTIONS
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(idx, _)| avail.available.get(*idx).copied().unwrap_or(false))
|
||||||
|
.map(|(idx, name)| {
|
||||||
|
let preview = avail.preview_paths.get(idx).cloned().flatten();
|
||||||
|
(idx as u8, *name, preview)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div
|
||||||
|
class="absolute bottom-full left-0 mb-2 w-full max-w-lg bg-gray-800/95 backdrop-blur-sm rounded-lg shadow-2xl border border-gray-700 p-3 z-50"
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Available emotions"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
<For
|
||||||
|
each=move || available_emotions()
|
||||||
|
key=|(idx, _, _): &(u8, &str, Option<String>)| *idx
|
||||||
|
children=move |(emotion_idx, emotion_name, preview_path): (u8, &str, Option<String>)| {
|
||||||
|
let on_select = on_select.clone();
|
||||||
|
let skin_path = skin_preview_path.get();
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-2 p-2 rounded hover:bg-gray-700 transition-colors text-left w-full"
|
||||||
|
on:click=move |_| on_select.run(emotion_idx)
|
||||||
|
role="option"
|
||||||
|
>
|
||||||
|
<EmotionPreview
|
||||||
|
skin_path=skin_path.clone()
|
||||||
|
emotion_path=preview_path.clone()
|
||||||
|
/>
|
||||||
|
<span class="text-white text-sm">
|
||||||
|
":"
|
||||||
|
{emotion_name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Show when=move || available_emotions().is_empty()>
|
||||||
|
<div class="text-gray-500 text-sm text-center py-4">
|
||||||
|
"No emotions configured for your avatar"
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emotion preview component.
|
||||||
|
///
|
||||||
|
/// Renders a small canvas with the avatar skin and emotion overlay.
|
||||||
|
#[component]
|
||||||
|
fn EmotionPreview(skin_path: Option<String>, emotion_path: Option<String>) -> impl IntoView {
|
||||||
|
let canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
||||||
|
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
use wasm_bindgen::{closure::Closure, JsCast};
|
||||||
|
|
||||||
|
let skin_path_clone = skin_path.clone();
|
||||||
|
let emotion_path_clone = emotion_path.clone();
|
||||||
|
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let Some(canvas) = canvas_ref.get() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
|
||||||
|
canvas_el.set_width(32);
|
||||||
|
canvas_el.set_height(32);
|
||||||
|
|
||||||
|
let Ok(Some(ctx)) = canvas_el.get_context("2d") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let ctx: web_sys::CanvasRenderingContext2d = ctx.dyn_into().unwrap();
|
||||||
|
|
||||||
|
// Clear canvas
|
||||||
|
ctx.clear_rect(0.0, 0.0, 32.0, 32.0);
|
||||||
|
|
||||||
|
// Helper to normalize asset paths
|
||||||
|
fn normalize_path(path: &str) -> String {
|
||||||
|
if path.starts_with('/') {
|
||||||
|
path.to_string()
|
||||||
|
} else {
|
||||||
|
format!("/static/{}", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw skin layer first
|
||||||
|
if let Some(ref skin) = skin_path_clone {
|
||||||
|
let skin_url = normalize_path(skin);
|
||||||
|
let ctx_clone = ctx.clone();
|
||||||
|
|
||||||
|
let img = web_sys::HtmlImageElement::new().unwrap();
|
||||||
|
let img_clone = img.clone();
|
||||||
|
|
||||||
|
// Load and draw emotion on top after skin loads
|
||||||
|
let emotion_path_for_closure = emotion_path_clone.clone();
|
||||||
|
|
||||||
|
let onload = Closure::once(Box::new(move || {
|
||||||
|
ctx_clone.draw_image_with_html_image_element_and_dw_and_dh(
|
||||||
|
&img_clone, 0.0, 0.0, 32.0, 32.0,
|
||||||
|
).ok();
|
||||||
|
|
||||||
|
// Now draw emotion overlay
|
||||||
|
if let Some(ref emotion) = emotion_path_for_closure {
|
||||||
|
let emotion_url = normalize_path(emotion);
|
||||||
|
let ctx_emotion = ctx_clone.clone();
|
||||||
|
|
||||||
|
let emotion_img = web_sys::HtmlImageElement::new().unwrap();
|
||||||
|
let emotion_img_clone = emotion_img.clone();
|
||||||
|
|
||||||
|
let emotion_onload = Closure::once(Box::new(move || {
|
||||||
|
ctx_emotion.draw_image_with_html_image_element_and_dw_and_dh(
|
||||||
|
&emotion_img_clone, 0.0, 0.0, 32.0, 32.0,
|
||||||
|
).ok();
|
||||||
|
}) as Box<dyn FnOnce()>);
|
||||||
|
|
||||||
|
emotion_img.set_onload(Some(emotion_onload.as_ref().unchecked_ref()));
|
||||||
|
emotion_onload.forget();
|
||||||
|
emotion_img.set_src(&emotion_url);
|
||||||
|
}
|
||||||
|
}) as Box<dyn FnOnce()>);
|
||||||
|
|
||||||
|
img.set_onload(Some(onload.as_ref().unchecked_ref()));
|
||||||
|
onload.forget();
|
||||||
|
img.set_src(&skin_url);
|
||||||
|
} else if let Some(ref emotion) = emotion_path_clone {
|
||||||
|
// No skin, just draw emotion
|
||||||
|
let emotion_url = normalize_path(emotion);
|
||||||
|
let ctx_clone = ctx.clone();
|
||||||
|
|
||||||
|
let img = web_sys::HtmlImageElement::new().unwrap();
|
||||||
|
let img_clone = img.clone();
|
||||||
|
|
||||||
|
let onload = Closure::once(Box::new(move || {
|
||||||
|
ctx_clone.draw_image_with_html_image_element_and_dw_and_dh(
|
||||||
|
&img_clone, 0.0, 0.0, 32.0, 32.0,
|
||||||
|
).ok();
|
||||||
|
}) as Box<dyn FnOnce()>);
|
||||||
|
|
||||||
|
img.set_onload(Some(onload.as_ref().unchecked_ref()));
|
||||||
|
onload.forget();
|
||||||
|
img.set_src(&emotion_url);
|
||||||
|
} else {
|
||||||
|
// No assets - draw placeholder circle
|
||||||
|
ctx.set_fill_style_str("#4B5563");
|
||||||
|
ctx.begin_path();
|
||||||
|
ctx.arc(16.0, 16.0, 14.0, 0.0, std::f64::consts::PI * 2.0).ok();
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<canvas
|
||||||
|
node_ref=canvas_ref
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
class="w-8 h-8 rounded"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
//! Realm landing page after login.
|
//! Realm landing page after login.
|
||||||
|
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
use leptos::reactive::owner::LocalStorage;
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
|
|
@ -10,10 +11,14 @@ use leptos_router::hooks::use_params_map;
|
||||||
use crate::components::{Card, ChatInput, RealmHeader, RealmSceneViewer};
|
use crate::components::{Card, ChatInput, RealmHeader, RealmSceneViewer};
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
use crate::components::use_channel_websocket;
|
use crate::components::use_channel_websocket;
|
||||||
use chattyness_db::models::{ChannelMemberWithAvatar, RealmRole, RealmWithUserRole, Scene};
|
use chattyness_db::models::{
|
||||||
|
ChannelMemberWithAvatar, EmotionAvailability, RealmRole, RealmWithUserRole, Scene,
|
||||||
|
};
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
use chattyness_db::ws_messages::ClientMessage;
|
use chattyness_db::ws_messages::ClientMessage;
|
||||||
|
|
||||||
|
use crate::components::ws_client::WsSender;
|
||||||
|
|
||||||
/// Realm landing page component.
|
/// Realm landing page component.
|
||||||
#[component]
|
#[component]
|
||||||
pub fn RealmPage() -> impl IntoView {
|
pub fn RealmPage() -> impl IntoView {
|
||||||
|
|
@ -27,6 +32,16 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
let (members, set_members) = signal(Vec::<ChannelMemberWithAvatar>::new());
|
let (members, set_members) = signal(Vec::<ChannelMemberWithAvatar>::new());
|
||||||
let (channel_id, set_channel_id) = signal(Option::<uuid::Uuid>::None);
|
let (channel_id, set_channel_id) = signal(Option::<uuid::Uuid>::None);
|
||||||
|
|
||||||
|
// Chat focus coordination
|
||||||
|
let (chat_focused, set_chat_focused) = signal(false);
|
||||||
|
let (focus_chat_trigger, set_focus_chat_trigger) = signal(false);
|
||||||
|
|
||||||
|
// Emotion availability for emote picker
|
||||||
|
let (emotion_availability, set_emotion_availability) =
|
||||||
|
signal(Option::<EmotionAvailability>::None);
|
||||||
|
// Skin preview path for emote picker (position 4 of skin layer)
|
||||||
|
let (skin_preview_path, set_skin_preview_path) = signal(Option::<String>::None);
|
||||||
|
|
||||||
let realm_data = LocalResource::new(move || {
|
let realm_data = LocalResource::new(move || {
|
||||||
let slug = slug.get();
|
let slug = slug.get();
|
||||||
async move {
|
async move {
|
||||||
|
|
@ -78,6 +93,52 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch emotion availability and avatar render data for emote picker
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
let slug_for_emotions = slug.clone();
|
||||||
|
Effect::new(move |_| {
|
||||||
|
use gloo_net::http::Request;
|
||||||
|
|
||||||
|
let current_slug = slug_for_emotions.get();
|
||||||
|
if current_slug.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch emotion availability
|
||||||
|
let slug_clone = current_slug.clone();
|
||||||
|
spawn_local(async move {
|
||||||
|
let response = Request::get(&format!("/api/realms/{}/avatar/emotions", slug_clone))
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
if let Ok(resp) = response {
|
||||||
|
if resp.ok() {
|
||||||
|
if let Ok(avail) = resp.json::<EmotionAvailability>().await {
|
||||||
|
set_emotion_availability.set(Some(avail));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// WebSocket connection for real-time updates
|
// WebSocket connection for real-time updates
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
let on_members_update = Callback::new(move |new_members: Vec<ChannelMemberWithAvatar>| {
|
let on_members_update = Callback::new(move |new_members: Vec<ChannelMemberWithAvatar>| {
|
||||||
|
|
@ -115,7 +176,7 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
#[cfg(not(feature = "hydrate"))]
|
#[cfg(not(feature = "hydrate"))]
|
||||||
let on_move = Callback::new(move |(_x, _y): (f64, f64)| {});
|
let on_move = Callback::new(move |(_x, _y): (f64, f64)| {});
|
||||||
|
|
||||||
// Handle emotion change via keyboard (e then 0-9)
|
// Handle global keyboard shortcuts (e+0-9 for emotions, : for chat focus)
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
{
|
{
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
|
|
@ -149,6 +210,25 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
let closure = Closure::new(move |ev: web_sys::KeyboardEvent| {
|
let closure = Closure::new(move |ev: web_sys::KeyboardEvent| {
|
||||||
let key = ev.key();
|
let key = ev.key();
|
||||||
|
|
||||||
|
// If chat is focused, let it handle all keys
|
||||||
|
if chat_focused.get() {
|
||||||
|
*e_pressed_clone.borrow_mut() = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle ':' to focus chat input
|
||||||
|
if key == ":" {
|
||||||
|
set_focus_chat_trigger.set(true);
|
||||||
|
// Reset trigger after a short delay so it can be triggered again
|
||||||
|
use gloo_timers::callback::Timeout;
|
||||||
|
Timeout::new(100, move || {
|
||||||
|
set_focus_chat_trigger.set(false);
|
||||||
|
})
|
||||||
|
.forget();
|
||||||
|
ev.prevent_default();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if 'e' key was pressed
|
// Check if 'e' key was pressed
|
||||||
if key == "e" || key == "E" {
|
if key == "e" || key == "E" {
|
||||||
*e_pressed_clone.borrow_mut() = true;
|
*e_pressed_clone.borrow_mut() = true;
|
||||||
|
|
@ -189,6 +269,11 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Callback for chat focus changes
|
||||||
|
let on_chat_focus_change = Callback::new(move |focused: bool| {
|
||||||
|
set_chat_focused.set(focused);
|
||||||
|
});
|
||||||
|
|
||||||
// Create logout callback (WebSocket disconnects automatically)
|
// Create logout callback (WebSocket disconnects automatically)
|
||||||
let on_logout = Callback::new(move |_: ()| {
|
let on_logout = Callback::new(move |_: ()| {
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
|
|
@ -272,13 +357,23 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
}>
|
}>
|
||||||
{move || {
|
{move || {
|
||||||
let on_move = on_move.clone();
|
let on_move = on_move.clone();
|
||||||
|
let on_chat_focus_change = on_chat_focus_change.clone();
|
||||||
let realm_slug_for_viewer = realm_slug_val.clone();
|
let realm_slug_for_viewer = realm_slug_val.clone();
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let ws_sender_clone = ws_sender.clone();
|
||||||
entry_scene
|
entry_scene
|
||||||
.get()
|
.get()
|
||||||
.map(|maybe_scene| {
|
.map(|maybe_scene| {
|
||||||
match maybe_scene {
|
match maybe_scene {
|
||||||
Some(scene) => {
|
Some(scene) => {
|
||||||
let members_signal = Signal::derive(move || members.get());
|
let members_signal = Signal::derive(move || members.get());
|
||||||
|
let emotion_avail_signal = Signal::derive(move || emotion_availability.get());
|
||||||
|
let skin_path_signal = Signal::derive(move || skin_preview_path.get());
|
||||||
|
let focus_trigger_signal = Signal::derive(move || focus_chat_trigger.get());
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let ws_for_chat = ws_sender_clone.clone();
|
||||||
|
#[cfg(not(feature = "hydrate"))]
|
||||||
|
let ws_for_chat: StoredValue<Option<WsSender>, LocalStorage> = StoredValue::new_local(None);
|
||||||
view! {
|
view! {
|
||||||
<div class="relative w-full">
|
<div class="relative w-full">
|
||||||
<RealmSceneViewer
|
<RealmSceneViewer
|
||||||
|
|
@ -287,8 +382,14 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
members=members_signal
|
members=members_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">
|
<div class="absolute bottom-0 left-0 right-0 z-10 pb-4 px-4 pointer-events-none">
|
||||||
<ChatInput />
|
<ChatInput
|
||||||
|
ws_sender=ws_for_chat
|
||||||
|
emotion_availability=emotion_avail_signal
|
||||||
|
skin_preview_path=skin_path_signal
|
||||||
|
focus_trigger=focus_trigger_signal
|
||||||
|
on_focus_change=on_chat_focus_change.clone()
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue