add :emote and :list to chat
This commit is contained in:
parent
1ca300098f
commit
bd28e201a2
7 changed files with 741 additions and 22 deletions
|
|
@ -10,7 +10,7 @@ use axum::{
|
|||
use sqlx::PgPool;
|
||||
|
||||
use chattyness_db::{
|
||||
models::AvatarRenderData,
|
||||
models::{AvatarRenderData, EmotionAvailability},
|
||||
queries::{avatars, realms},
|
||||
};
|
||||
use chattyness_error::AppError;
|
||||
|
|
@ -37,3 +37,25 @@ pub async fn get_current_avatar(
|
|||
|
||||
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",
|
||||
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 } => {
|
||||
if emotion > 9 {
|
||||
// We have 12 emotions (0-11)
|
||||
if emotion > 11 {
|
||||
continue;
|
||||
}
|
||||
let emotion_layer = match avatars::set_emotion(
|
||||
|
|
|
|||
|
|
@ -2,25 +2,238 @@
|
|||
|
||||
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.
|
||||
/// Currently non-functional - just UI placeholder.
|
||||
/// Supports `:e name`, `:emote name` with partial matching.
|
||||
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]
|
||||
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 (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! {
|
||||
<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">
|
||||
<input
|
||||
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"
|
||||
prop:value=move || message.get()
|
||||
on:input=move |ev| {
|
||||
set_message.set(event_target_value(&ev));
|
||||
}
|
||||
on:input=on_input
|
||||
on:keydown=on_keydown
|
||||
on:focus=on_focus
|
||||
on:blur=on_blur
|
||||
node_ref=input_ref
|
||||
autocomplete="off"
|
||||
aria-label="Chat message input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -30,9 +243,194 @@ pub fn ChatInput() -> impl IntoView {
|
|||
"Send"
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-gray-500 text-xs mt-2 text-center">
|
||||
"Chat functionality coming soon"
|
||||
</p>
|
||||
</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.
|
||||
|
||||
use leptos::prelude::*;
|
||||
use leptos::reactive::owner::LocalStorage;
|
||||
#[cfg(feature = "hydrate")]
|
||||
use leptos::task::spawn_local;
|
||||
#[cfg(feature = "hydrate")]
|
||||
|
|
@ -10,10 +11,14 @@ use leptos_router::hooks::use_params_map;
|
|||
use crate::components::{Card, ChatInput, RealmHeader, RealmSceneViewer};
|
||||
#[cfg(feature = "hydrate")]
|
||||
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")]
|
||||
use chattyness_db::ws_messages::ClientMessage;
|
||||
|
||||
use crate::components::ws_client::WsSender;
|
||||
|
||||
/// Realm landing page component.
|
||||
#[component]
|
||||
pub fn RealmPage() -> impl IntoView {
|
||||
|
|
@ -27,6 +32,16 @@ pub fn RealmPage() -> impl IntoView {
|
|||
let (members, set_members) = signal(Vec::<ChannelMemberWithAvatar>::new());
|
||||
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 slug = slug.get();
|
||||
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
|
||||
#[cfg(feature = "hydrate")]
|
||||
let on_members_update = Callback::new(move |new_members: Vec<ChannelMemberWithAvatar>| {
|
||||
|
|
@ -115,7 +176,7 @@ pub fn RealmPage() -> impl IntoView {
|
|||
#[cfg(not(feature = "hydrate"))]
|
||||
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")]
|
||||
{
|
||||
use std::cell::RefCell;
|
||||
|
|
@ -149,6 +210,25 @@ pub fn RealmPage() -> impl IntoView {
|
|||
let closure = Closure::new(move |ev: web_sys::KeyboardEvent| {
|
||||
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
|
||||
if key == "e" || key == "E" {
|
||||
*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)
|
||||
let on_logout = Callback::new(move |_: ()| {
|
||||
#[cfg(feature = "hydrate")]
|
||||
|
|
@ -272,13 +357,23 @@ pub fn RealmPage() -> impl IntoView {
|
|||
}>
|
||||
{move || {
|
||||
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();
|
||||
#[cfg(feature = "hydrate")]
|
||||
let ws_sender_clone = ws_sender.clone();
|
||||
entry_scene
|
||||
.get()
|
||||
.map(|maybe_scene| {
|
||||
match maybe_scene {
|
||||
Some(scene) => {
|
||||
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! {
|
||||
<div class="relative w-full">
|
||||
<RealmSceneViewer
|
||||
|
|
@ -287,8 +382,14 @@ pub fn RealmPage() -> impl IntoView {
|
|||
members=members_signal
|
||||
on_move=on_move.clone()
|
||||
/>
|
||||
<div class="absolute bottom-0 left-0 right-0 z-10 pb-4 px-4">
|
||||
<ChatInput />
|
||||
<div class="absolute bottom-0 left-0 right-0 z-10 pb-4 px-4 pointer-events-none">
|
||||
<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>
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue