Compare commits

..

No commits in common. "29f29358fd14d316426174551a6b27389a8312c620f526d27d0a3353e24100fd" and "af1c767f5f1b2d4d67f487455e9cb928c3402c063eee165c5e1e38e68e6cf74e" have entirely different histories.

25 changed files with 444 additions and 2026 deletions

View file

@ -259,15 +259,6 @@ pub fn CombinedApp() -> impl IntoView {
<Route path=StaticSegment("home") view=HomePage /> <Route path=StaticSegment("home") view=HomePage />
<Route path=StaticSegment("password-reset") view=PasswordResetPage /> <Route path=StaticSegment("password-reset") view=PasswordResetPage />
<Route path=(StaticSegment("realms"), ParamSegment("slug")) view=RealmPage /> <Route path=(StaticSegment("realms"), ParamSegment("slug")) view=RealmPage />
<Route
path=(
StaticSegment("realms"),
ParamSegment("slug"),
StaticSegment("scenes"),
ParamSegment("scene_slug"),
)
view=RealmPage
/>
// ========================================== // ==========================================
// Admin routes (lazy loading) // Admin routes (lazy loading)

View file

@ -75,14 +75,4 @@
.error-message { .error-message {
@apply p-4 bg-red-900/50 border border-red-500 rounded-lg text-red-200; @apply p-4 bg-red-900/50 border border-red-500 rounded-lg text-red-200;
} }
/* Reconnection overlay spinner animation */
.reconnect-spinner {
animation: reconnect-pulse 1.5s ease-in-out infinite;
}
}
@keyframes reconnect-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
} }

View file

@ -451,13 +451,6 @@ pub struct User {
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
} }
impl User {
/// Check if this user is a guest (has the Guest tag).
pub fn is_guest(&self) -> bool {
self.tags.contains(&UserTag::Guest)
}
}
/// Minimal user info for display purposes. /// Minimal user info for display purposes.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserSummary { pub struct UserSummary {
@ -489,7 +482,6 @@ pub struct Realm {
pub thumbnail_path: Option<String>, pub thumbnail_path: Option<String>,
pub max_users: i32, pub max_users: i32,
pub allow_guest_access: bool, pub allow_guest_access: bool,
pub allow_user_teleport: bool,
pub default_scene_id: Option<Uuid>, pub default_scene_id: Option<Uuid>,
pub member_count: i32, pub member_count: i32,
pub current_user_count: i32, pub current_user_count: i32,
@ -517,7 +509,6 @@ pub struct CreateRealmRequest {
pub is_nsfw: bool, pub is_nsfw: bool,
pub max_users: i32, pub max_users: i32,
pub allow_guest_access: bool, pub allow_guest_access: bool,
pub allow_user_teleport: bool,
pub theme_color: Option<String>, pub theme_color: Option<String>,
} }
@ -1363,7 +1354,6 @@ pub struct RealmDetail {
pub thumbnail_path: Option<String>, pub thumbnail_path: Option<String>,
pub max_users: i32, pub max_users: i32,
pub allow_guest_access: bool, pub allow_guest_access: bool,
pub allow_user_teleport: bool,
pub member_count: i32, pub member_count: i32,
pub current_user_count: i32, pub current_user_count: i32,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
@ -1380,7 +1370,6 @@ pub struct UpdateRealmRequest {
pub is_nsfw: bool, pub is_nsfw: bool,
pub max_users: i32, pub max_users: i32,
pub allow_guest_access: bool, pub allow_guest_access: bool,
pub allow_user_teleport: bool,
pub theme_color: Option<String>, pub theme_color: Option<String>,
} }
@ -1840,9 +1829,6 @@ pub struct ChannelMemberInfo {
/// Current emotion slot (0-9) /// Current emotion slot (0-9)
pub current_emotion: i16, pub current_emotion: i16,
pub joined_at: DateTime<Utc>, pub joined_at: DateTime<Utc>,
/// Whether this user is a guest (has the 'guest' tag)
#[serde(default)]
pub is_guest: bool,
} }
/// Request to update position in a channel. /// Request to update position in a channel.

View file

@ -67,7 +67,7 @@ pub async fn ensure_active_avatar<'e>(
sqlx::query( sqlx::query(
r#" r#"
INSERT INTO auth.active_avatars (user_id, realm_id, avatar_id, current_emotion) INSERT INTO auth.active_avatars (user_id, realm_id, avatar_id, current_emotion)
SELECT $1, $2, id, 1 SELECT $1, $2, id, 0
FROM auth.avatars FROM auth.avatars
WHERE user_id = $1 AND slot_number = 0 WHERE user_id = $1 AND slot_number = 0
ON CONFLICT (user_id, realm_id) DO NOTHING ON CONFLICT (user_id, realm_id) DO NOTHING
@ -176,8 +176,7 @@ pub async fn get_channel_members<'e>(
cm.is_moving, cm.is_moving,
cm.is_afk, cm.is_afk,
COALESCE(aa.current_emotion, 0::smallint) as current_emotion, COALESCE(aa.current_emotion, 0::smallint) as current_emotion,
cm.joined_at, cm.joined_at
COALESCE('guest' = ANY(u.tags), false) as is_guest
FROM scene.instance_members cm FROM scene.instance_members cm
LEFT JOIN auth.users u ON cm.user_id = u.id LEFT JOIN auth.users u ON cm.user_id = u.id
LEFT JOIN auth.guest_sessions gs ON cm.guest_session_id = gs.id LEFT JOIN auth.guest_sessions gs ON cm.guest_session_id = gs.id
@ -215,8 +214,7 @@ pub async fn get_channel_member<'e>(
cm.is_moving, cm.is_moving,
cm.is_afk, cm.is_afk,
COALESCE(aa.current_emotion, 0::smallint) as current_emotion, COALESCE(aa.current_emotion, 0::smallint) as current_emotion,
cm.joined_at, cm.joined_at
COALESCE('guest' = ANY(u.tags), false) as is_guest
FROM scene.instance_members cm FROM scene.instance_members cm
LEFT JOIN auth.users u ON cm.user_id = u.id LEFT JOIN auth.users u ON cm.user_id = u.id
LEFT JOIN auth.active_avatars aa ON cm.user_id = aa.user_id AND aa.realm_id = $3 LEFT JOIN auth.active_avatars aa ON cm.user_id = aa.user_id AND aa.realm_id = $3

View file

@ -30,12 +30,7 @@ pub async fn list_realms_with_owner(
r.owner_id, r.owner_id,
u.username as owner_username, u.username as owner_username,
r.member_count, r.member_count,
COALESCE(( r.current_user_count,
SELECT COUNT(*)::INTEGER
FROM scene.instance_members im
JOIN realm.scenes s ON im.instance_id = s.id
WHERE s.realm_id = r.id
), 0) AS current_user_count,
r.created_at r.created_at
FROM realm.realms r FROM realm.realms r
JOIN auth.users u ON r.owner_id = u.id JOIN auth.users u ON r.owner_id = u.id
@ -70,12 +65,7 @@ pub async fn search_realms(
r.owner_id, r.owner_id,
u.username as owner_username, u.username as owner_username,
r.member_count, r.member_count,
COALESCE(( r.current_user_count,
SELECT COUNT(*)::INTEGER
FROM scene.instance_members im
JOIN realm.scenes s ON im.instance_id = s.id
WHERE s.realm_id = r.id
), 0) AS current_user_count,
r.created_at r.created_at
FROM realm.realms r FROM realm.realms r
JOIN auth.users u ON r.owner_id = u.id JOIN auth.users u ON r.owner_id = u.id
@ -254,14 +244,8 @@ pub async fn get_realm_by_slug(pool: &PgPool, slug: &str) -> Result<RealmDetail,
r.thumbnail_path, r.thumbnail_path,
r.max_users, r.max_users,
r.allow_guest_access, r.allow_guest_access,
r.allow_user_teleport,
r.member_count, r.member_count,
COALESCE(( r.current_user_count,
SELECT COUNT(*)::INTEGER
FROM scene.instance_members im
JOIN realm.scenes s ON im.instance_id = s.id
WHERE s.realm_id = r.id
), 0) AS current_user_count,
r.created_at, r.created_at,
r.updated_at r.updated_at
FROM realm.realms r FROM realm.realms r
@ -295,10 +279,9 @@ pub async fn update_realm(
is_nsfw = $5, is_nsfw = $5,
max_users = $6, max_users = $6,
allow_guest_access = $7, allow_guest_access = $7,
allow_user_teleport = $8, theme_color = $8,
theme_color = $9,
updated_at = now() updated_at = now()
WHERE id = $10 WHERE id = $9
"#, "#,
) )
.bind(&req.name) .bind(&req.name)
@ -308,7 +291,6 @@ pub async fn update_realm(
.bind(req.is_nsfw) .bind(req.is_nsfw)
.bind(req.max_users) .bind(req.max_users)
.bind(req.allow_guest_access) .bind(req.allow_guest_access)
.bind(req.allow_user_teleport)
.bind(&req.theme_color) .bind(&req.theme_color)
.bind(realm_id) .bind(realm_id)
.execute(pool) .execute(pool)
@ -334,14 +316,8 @@ pub async fn update_realm(
r.thumbnail_path, r.thumbnail_path,
r.max_users, r.max_users,
r.allow_guest_access, r.allow_guest_access,
r.allow_user_teleport,
r.member_count, r.member_count,
COALESCE(( r.current_user_count,
SELECT COUNT(*)::INTEGER
FROM scene.instance_members im
JOIN realm.scenes s ON im.instance_id = s.id
WHERE s.realm_id = r.id
), 0) AS current_user_count,
r.created_at, r.created_at,
r.updated_at r.updated_at
FROM realm.realms r FROM realm.realms r

View file

@ -262,22 +262,17 @@ pub async fn list_all_realms(pool: &PgPool) -> Result<Vec<RealmSummary>, AppErro
let realms = sqlx::query_as::<_, RealmSummary>( let realms = sqlx::query_as::<_, RealmSummary>(
r#" r#"
SELECT SELECT
r.id, id,
r.name, name,
r.slug, slug,
r.tagline, tagline,
r.privacy, privacy,
r.is_nsfw, is_nsfw,
r.thumbnail_path, thumbnail_path,
r.member_count, member_count,
COALESCE(( current_user_count
SELECT COUNT(*)::INTEGER FROM realm.realms
FROM scene.instance_members im ORDER BY name
JOIN realm.scenes s ON im.instance_id = s.id
WHERE s.realm_id = r.id
), 0) AS current_user_count
FROM realm.realms r
ORDER BY r.name
"#, "#,
) )
.fetch_all(pool) .fetch_all(pool)

View file

@ -17,9 +17,9 @@ pub async fn create_realm(
r#" r#"
INSERT INTO realm.realms ( INSERT INTO realm.realms (
name, slug, description, tagline, owner_id, name, slug, description, tagline, owner_id,
privacy, is_nsfw, max_users, allow_guest_access, allow_user_teleport, theme_color privacy, is_nsfw, max_users, allow_guest_access, theme_color
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING RETURNING
id, id,
name, name,
@ -35,7 +35,6 @@ pub async fn create_realm(
thumbnail_path, thumbnail_path,
max_users, max_users,
allow_guest_access, allow_guest_access,
allow_user_teleport,
default_scene_id, default_scene_id,
member_count, member_count,
current_user_count, current_user_count,
@ -52,7 +51,6 @@ pub async fn create_realm(
.bind(req.is_nsfw) .bind(req.is_nsfw)
.bind(req.max_users) .bind(req.max_users)
.bind(req.allow_guest_access) .bind(req.allow_guest_access)
.bind(req.allow_user_teleport)
.bind(&req.theme_color) .bind(&req.theme_color)
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;
@ -79,33 +77,27 @@ pub async fn get_realm_by_slug<'e>(
let realm = sqlx::query_as::<_, Realm>( let realm = sqlx::query_as::<_, Realm>(
r#" r#"
SELECT SELECT
r.id, id,
r.name, name,
r.slug, slug,
r.description, description,
r.tagline, tagline,
r.owner_id, owner_id,
r.privacy, privacy,
r.is_nsfw, is_nsfw,
r.min_reputation_tier, min_reputation_tier,
r.theme_color, theme_color,
r.banner_image_path, banner_image_path,
r.thumbnail_path, thumbnail_path,
r.max_users, max_users,
r.allow_guest_access, allow_guest_access,
r.allow_user_teleport, default_scene_id,
r.default_scene_id, member_count,
r.member_count, current_user_count,
COALESCE(( created_at,
SELECT COUNT(*)::INTEGER updated_at
FROM scene.instance_members im FROM realm.realms
JOIN realm.scenes s ON im.instance_id = s.id WHERE slug = $1
WHERE s.realm_id = r.id
), 0) AS current_user_count,
r.created_at,
r.updated_at
FROM realm.realms r
WHERE r.slug = $1
"#, "#,
) )
.bind(slug) .bind(slug)
@ -120,33 +112,27 @@ pub async fn get_realm_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Realm>, A
let realm = sqlx::query_as::<_, Realm>( let realm = sqlx::query_as::<_, Realm>(
r#" r#"
SELECT SELECT
r.id, id,
r.name, name,
r.slug, slug,
r.description, description,
r.tagline, tagline,
r.owner_id, owner_id,
r.privacy, privacy,
r.is_nsfw, is_nsfw,
r.min_reputation_tier, min_reputation_tier,
r.theme_color, theme_color,
r.banner_image_path, banner_image_path,
r.thumbnail_path, thumbnail_path,
r.max_users, max_users,
r.allow_guest_access, allow_guest_access,
r.allow_user_teleport, default_scene_id,
r.default_scene_id, member_count,
r.member_count, current_user_count,
COALESCE(( created_at,
SELECT COUNT(*)::INTEGER updated_at
FROM scene.instance_members im FROM realm.realms
JOIN realm.scenes s ON im.instance_id = s.id WHERE id = $1
WHERE s.realm_id = r.id
), 0) AS current_user_count,
r.created_at,
r.updated_at
FROM realm.realms r
WHERE r.id = $1
"#, "#,
) )
.bind(id) .bind(id)
@ -167,23 +153,18 @@ pub async fn list_public_realms(
sqlx::query_as::<_, RealmSummary>( sqlx::query_as::<_, RealmSummary>(
r#" r#"
SELECT SELECT
r.id, id,
r.name, name,
r.slug, slug,
r.tagline, tagline,
r.privacy, privacy,
r.is_nsfw, is_nsfw,
r.thumbnail_path, thumbnail_path,
r.member_count, member_count,
COALESCE(( current_user_count
SELECT COUNT(*)::INTEGER FROM realm.realms
FROM scene.instance_members im WHERE privacy = 'public'
JOIN realm.scenes s ON im.instance_id = s.id ORDER BY current_user_count DESC, member_count DESC
WHERE s.realm_id = r.id
), 0) AS current_user_count
FROM realm.realms r
WHERE r.privacy = 'public'
ORDER BY current_user_count DESC, r.member_count DESC
LIMIT $1 OFFSET $2 LIMIT $1 OFFSET $2
"#, "#,
) )
@ -195,23 +176,18 @@ pub async fn list_public_realms(
sqlx::query_as::<_, RealmSummary>( sqlx::query_as::<_, RealmSummary>(
r#" r#"
SELECT SELECT
r.id, id,
r.name, name,
r.slug, slug,
r.tagline, tagline,
r.privacy, privacy,
r.is_nsfw, is_nsfw,
r.thumbnail_path, thumbnail_path,
r.member_count, member_count,
COALESCE(( current_user_count
SELECT COUNT(*)::INTEGER FROM realm.realms
FROM scene.instance_members im WHERE privacy = 'public' AND is_nsfw = false
JOIN realm.scenes s ON im.instance_id = s.id ORDER BY current_user_count DESC, member_count DESC
WHERE s.realm_id = r.id
), 0) AS current_user_count
FROM realm.realms r
WHERE r.privacy = 'public' AND r.is_nsfw = false
ORDER BY current_user_count DESC, r.member_count DESC
LIMIT $1 OFFSET $2 LIMIT $1 OFFSET $2
"#, "#,
) )
@ -229,23 +205,18 @@ pub async fn get_user_realms(pool: &PgPool, user_id: Uuid) -> Result<Vec<RealmSu
let realms = sqlx::query_as::<_, RealmSummary>( let realms = sqlx::query_as::<_, RealmSummary>(
r#" r#"
SELECT SELECT
r.id, id,
r.name, name,
r.slug, slug,
r.tagline, tagline,
r.privacy, privacy,
r.is_nsfw, is_nsfw,
r.thumbnail_path, thumbnail_path,
r.member_count, member_count,
COALESCE(( current_user_count
SELECT COUNT(*)::INTEGER FROM realm.realms
FROM scene.instance_members im WHERE owner_id = $1
JOIN realm.scenes s ON im.instance_id = s.id ORDER BY created_at DESC
WHERE s.realm_id = r.id
), 0) AS current_user_count
FROM realm.realms r
WHERE r.owner_id = $1
ORDER BY r.created_at DESC
"#, "#,
) )
.bind(user_id) .bind(user_id)

View file

@ -20,14 +20,6 @@ pub struct WsConfig {
pub ping_interval_secs: u64, pub ping_interval_secs: u64,
} }
/// WebSocket close codes (custom range: 4000-4999).
pub mod close_codes {
/// Scene change (user navigating to different scene).
pub const SCENE_CHANGE: u16 = 4000;
/// Server timeout (no message received within timeout period).
pub const SERVER_TIMEOUT: u16 = 4001;
}
/// Reason for member disconnect. /// Reason for member disconnect.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
@ -84,12 +76,6 @@ pub enum ClientMessage {
/// Request to broadcast avatar appearance to other users. /// Request to broadcast avatar appearance to other users.
SyncAvatar, SyncAvatar,
/// Request to teleport to a different scene.
Teleport {
/// Scene ID to teleport to.
scene_id: Uuid,
},
} }
/// Server-to-client WebSocket messages. /// Server-to-client WebSocket messages.
@ -226,12 +212,4 @@ pub enum ServerMessage {
/// Updated avatar render data. /// Updated avatar render data.
avatar: AvatarRenderData, avatar: AvatarRenderData,
}, },
/// Teleport approved - client should disconnect and reconnect to new scene.
TeleportApproved {
/// Scene ID to navigate to.
scene_id: Uuid,
/// Scene slug for URL.
scene_slug: String,
},
} }

View file

@ -53,13 +53,6 @@ pub async fn assign_slot(
Path(slug): Path<String>, Path(slug): Path<String>,
Json(req): Json<AssignSlotRequest>, Json(req): Json<AssignSlotRequest>,
) -> Result<Json<AvatarWithPaths>, AppError> { ) -> Result<Json<AvatarWithPaths>, AppError> {
// Guests cannot customize their avatar
if user.is_guest() {
return Err(AppError::Forbidden(
"Avatar customization is disabled for guests, please register first.".to_string(),
));
}
req.validate()?; req.validate()?;
let mut conn = rls_conn.acquire().await; let mut conn = rls_conn.acquire().await;
@ -100,13 +93,6 @@ pub async fn clear_slot(
Path(slug): Path<String>, Path(slug): Path<String>,
Json(req): Json<ClearSlotRequest>, Json(req): Json<ClearSlotRequest>,
) -> Result<Json<AvatarWithPaths>, AppError> { ) -> Result<Json<AvatarWithPaths>, AppError> {
// Guests cannot customize their avatar
if user.is_guest() {
return Err(AppError::Forbidden(
"Avatar customization is disabled for guests, please register first.".to_string(),
));
}
req.validate()?; req.validate()?;
let mut conn = rls_conn.acquire().await; let mut conn = rls_conn.acquire().await;

View file

@ -20,11 +20,14 @@ use uuid::Uuid;
use chattyness_db::{ use chattyness_db::{
models::{AvatarRenderData, ChannelMemberWithAvatar, EmotionState, User}, models::{AvatarRenderData, ChannelMemberWithAvatar, EmotionState, User},
queries::{avatars, channel_members, loose_props, realms, scenes}, queries::{avatars, channel_members, loose_props, realms, scenes},
ws_messages::{close_codes, ClientMessage, DisconnectReason, ServerMessage, WsConfig}, ws_messages::{ClientMessage, DisconnectReason, ServerMessage, WsConfig},
}; };
use chattyness_error::AppError; use chattyness_error::AppError;
use chattyness_shared::WebSocketConfig; use chattyness_shared::WebSocketConfig;
/// Close code for scene change (custom code).
const SCENE_CHANGE_CLOSE_CODE: u16 = 4000;
use crate::auth::AuthUser; use crate::auth::AuthUser;
/// Channel state for broadcasting updates. /// Channel state for broadcasting updates.
@ -350,7 +353,6 @@ async fn handle_socket(
let _ = channel_state.tx.send(join_msg); let _ = channel_state.tx.send(join_msg);
let user_id = user.id; let user_id = user.id;
let is_guest = user.is_guest();
let tx = channel_state.tx.clone(); let tx = channel_state.tx.clone();
// Acquire a second dedicated connection for the receive task // Acquire a second dedicated connection for the receive task
@ -388,20 +390,11 @@ async fn handle_socket(
// Clone ws_state for use in recv_task // Clone ws_state for use in recv_task
let ws_state_for_recv = ws_state.clone(); let ws_state_for_recv = ws_state.clone();
// Clone pool for use in recv_task (for teleport queries)
let pool_for_recv = pool.clone();
// Create recv timeout from config // Create recv timeout from config
let recv_timeout = Duration::from_secs(ws_config.recv_timeout_secs); let recv_timeout = Duration::from_secs(ws_config.recv_timeout_secs);
// Channel for sending close frame requests from recv_task to send_task
let (close_tx, mut close_rx) = mpsc::channel::<(u16, String)>(1);
// Spawn task to handle incoming messages from client // Spawn task to handle incoming messages from client
let close_tx_for_recv = close_tx.clone();
let recv_task = tokio::spawn(async move { let recv_task = tokio::spawn(async move {
let close_tx = close_tx_for_recv;
let pool = pool_for_recv;
let ws_state = ws_state_for_recv; let ws_state = ws_state_for_recv;
let mut disconnect_reason = DisconnectReason::Graceful; let mut disconnect_reason = DisconnectReason::Graceful;
@ -516,17 +509,6 @@ async fn handle_socket(
// Handle whisper (direct message) vs broadcast // Handle whisper (direct message) vs broadcast
if let Some(target_name) = target_display_name { if let Some(target_name) = target_display_name {
// Check if guest is trying to whisper
if is_guest {
let _ = direct_tx
.send(ServerMessage::Error {
code: "GUEST_FEATURE_DISABLED".to_string(),
message: "Private messaging is disabled for guests, please register first.".to_string(),
})
.await;
continue;
}
// Whisper: send directly to target user // Whisper: send directly to target user
if let Some((_target_user_id, target_conn)) = ws_state if let Some((_target_user_id, target_conn)) = ws_state
.find_user_by_display_name(realm_id, &target_name) .find_user_by_display_name(realm_id, &target_name)
@ -730,96 +712,12 @@ async fn handle_socket(
} }
} }
} }
ClientMessage::Teleport { scene_id } => {
// Validate teleport permission and scene
// 1. Check realm allows user teleport
let realm = match realms::get_realm_by_id(
&pool,
realm_id,
)
.await
{
Ok(Some(r)) => r,
Ok(None) => {
let _ = direct_tx.send(ServerMessage::Error {
code: "REALM_NOT_FOUND".to_string(),
message: "Realm not found".to_string(),
}).await;
continue;
}
Err(e) => {
tracing::error!("[WS] Teleport realm lookup failed: {:?}", e);
let _ = direct_tx.send(ServerMessage::Error {
code: "TELEPORT_FAILED".to_string(),
message: "Failed to verify teleport permission".to_string(),
}).await;
continue;
}
};
if !realm.allow_user_teleport {
let _ = direct_tx.send(ServerMessage::Error {
code: "TELEPORT_DISABLED".to_string(),
message: "Teleporting is not enabled for this realm".to_string(),
}).await;
continue;
}
// 2. Validate scene exists, belongs to realm, and is not hidden
let scene = match scenes::get_scene_by_id(&pool, scene_id).await {
Ok(Some(s)) => s,
Ok(None) => {
let _ = direct_tx.send(ServerMessage::Error {
code: "SCENE_NOT_FOUND".to_string(),
message: "Scene not found".to_string(),
}).await;
continue;
}
Err(e) => {
tracing::error!("[WS] Teleport scene lookup failed: {:?}", e);
let _ = direct_tx.send(ServerMessage::Error {
code: "TELEPORT_FAILED".to_string(),
message: "Failed to verify scene".to_string(),
}).await;
continue;
}
};
if scene.realm_id != realm_id {
let _ = direct_tx.send(ServerMessage::Error {
code: "SCENE_NOT_IN_REALM".to_string(),
message: "Scene does not belong to this realm".to_string(),
}).await;
continue;
}
if scene.is_hidden {
let _ = direct_tx.send(ServerMessage::Error {
code: "SCENE_HIDDEN".to_string(),
message: "Cannot teleport to a hidden scene".to_string(),
}).await;
continue;
}
// 3. Send approval - client will disconnect and reconnect
#[cfg(debug_assertions)]
tracing::debug!(
"[WS] User {} teleporting to scene {} ({})",
user_id,
scene.name,
scene.slug
);
let _ = direct_tx.send(ServerMessage::TeleportApproved {
scene_id: scene.id,
scene_slug: scene.slug,
}).await;
}
} }
} }
Message::Close(close_frame) => { Message::Close(close_frame) => {
// Check close code for scene change // Check close code for scene change
if let Some(CloseFrame { code, .. }) = close_frame { if let Some(CloseFrame { code, .. }) = close_frame {
if code == close_codes::SCENE_CHANGE { if code == SCENE_CHANGE_CLOSE_CODE {
disconnect_reason = DisconnectReason::SceneChange; disconnect_reason = DisconnectReason::SceneChange;
} else { } else {
disconnect_reason = DisconnectReason::Graceful; disconnect_reason = DisconnectReason::Graceful;
@ -845,12 +743,6 @@ async fn handle_socket(
Err(_) => { Err(_) => {
// Timeout elapsed - connection likely lost // Timeout elapsed - connection likely lost
tracing::info!("[WS] Connection timeout for user {}", user_id); tracing::info!("[WS] Connection timeout for user {}", user_id);
// Send close frame with timeout code so client can attempt silent reconnection
let _ = close_tx
.send((close_codes::SERVER_TIMEOUT, "timeout".to_string()))
.await;
// Brief delay to allow close frame to be sent
tokio::time::sleep(Duration::from_millis(100)).await;
disconnect_reason = DisconnectReason::Timeout; disconnect_reason = DisconnectReason::Timeout;
break; break;
} }
@ -860,21 +752,10 @@ async fn handle_socket(
(recv_conn, disconnect_reason) (recv_conn, disconnect_reason)
}); });
// Spawn task to forward broadcasts, direct messages, and close frames to this client // Spawn task to forward broadcasts and direct messages to this client
let send_task = tokio::spawn(async move { let send_task = tokio::spawn(async move {
loop { loop {
tokio::select! { tokio::select! {
// Handle close frame requests (from timeout)
Some((code, reason)) = close_rx.recv() => {
#[cfg(debug_assertions)]
tracing::debug!("[WS->Client] Sending close frame: code={}, reason={}", code, reason);
let close_frame = CloseFrame {
code,
reason: reason.into(),
};
let _ = sender.send(Message::Close(Some(close_frame))).await;
break;
}
// Handle broadcast messages // Handle broadcast messages
Ok(msg) = rx.recv() => { Ok(msg) = rx.recv() => {
if let Ok(json) = serde_json::to_string(&msg) { if let Ok(json) = serde_json::to_string(&msg) {

View file

@ -16,12 +16,10 @@ pub mod layout;
pub mod modals; pub mod modals;
pub mod notification_history; pub mod notification_history;
pub mod notifications; pub mod notifications;
pub mod scene_list_popup;
pub mod scene_viewer; pub mod scene_viewer;
pub mod settings; pub mod settings;
pub mod settings_popup; pub mod settings_popup;
pub mod tabs; pub mod tabs;
pub mod reconnection_overlay;
pub mod ws_client; pub mod ws_client;
pub use avatar_canvas::*; pub use avatar_canvas::*;
@ -40,8 +38,6 @@ pub use layout::*;
pub use modals::*; pub use modals::*;
pub use notification_history::*; pub use notification_history::*;
pub use notifications::*; pub use notifications::*;
pub use reconnection_overlay::*;
pub use scene_list_popup::*;
pub use scene_viewer::*; pub use scene_viewer::*;
pub use settings::*; pub use settings::*;
pub use settings_popup::*; pub use settings_popup::*;

View file

@ -226,235 +226,6 @@ fn determine_bubble_position(
} }
} }
/// Unified layout context for avatar canvas rendering.
///
/// This struct computes all derived layout values once from the inputs,
/// providing a single source of truth for:
/// - Canvas dimensions and position
/// - Avatar positioning within the canvas
/// - Coordinate transformations between canvas-local and screen space
/// - Bubble positioning and clamping
///
/// By centralizing these calculations, we avoid scattered, duplicated logic
/// and ensure the style closure, Effect, and draw_bubble all use consistent values.
#[derive(Clone, Copy)]
#[allow(dead_code)] // Some fields kept for potential future use
struct CanvasLayout {
// Core dimensions
prop_size: f64,
avatar_size: f64,
// Content offset from grid center
content_x_offset: f64,
content_y_offset: f64,
// Text scaling
text_scale: f64,
bubble_max_width: f64,
// Canvas dimensions
canvas_width: f64,
canvas_height: f64,
// Canvas position in screen space
canvas_screen_x: f64,
canvas_screen_y: f64,
// Avatar center within canvas (canvas-local coordinates)
avatar_cx: f64,
avatar_cy: f64,
// Scene boundaries for clamping
boundaries: ScreenBoundaries,
// Bubble state
bubble_position: BubblePosition,
bubble_height_reserved: f64,
// Content row info for positioning
empty_top_rows: usize,
empty_bottom_rows: usize,
}
impl CanvasLayout {
/// Create a new layout from all input parameters.
fn new(
content_bounds: &ContentBounds,
prop_size: f64,
text_em_size: f64,
avatar_screen_x: f64,
avatar_screen_y: f64,
boundaries: ScreenBoundaries,
has_bubble: bool,
bubble_text: Option<&str>,
) -> Self {
let avatar_size = prop_size * 3.0;
let text_scale = text_em_size * BASE_TEXT_SCALE;
let bubble_max_width = 200.0 * text_scale;
// Content offsets from grid center
let content_x_offset = content_bounds.x_offset(prop_size);
let content_y_offset = content_bounds.y_offset(prop_size);
// Empty rows for positioning elements relative to content
let empty_top_rows = content_bounds.empty_top_rows();
let empty_bottom_rows = content_bounds.empty_bottom_rows();
// Content dimensions for clamping
let content_half_width = content_bounds.content_width(prop_size) / 2.0;
let content_half_height = content_bounds.content_height(prop_size) / 2.0;
// Clamp avatar so content stays within scene
let (clamped_x, clamped_y) = boundaries.clamp_avatar_center(
avatar_screen_x,
avatar_screen_y,
content_half_width,
content_half_height,
);
// Calculate bubble height and position
let bubble_height_reserved = if has_bubble {
(4.0 * 16.0 + 16.0 + 8.0 + 5.0) * text_scale
} else {
0.0
};
let name_height = 20.0 * text_scale;
// Determine bubble position (above or below)
// Use actual content height, not full 3x3 grid size
let bubble_position = if has_bubble {
let estimated_height = bubble_text
.map(|t| estimate_bubble_height(t, text_scale))
.unwrap_or(0.0);
// clamped_y is the content center, so use content_half_height
// to find the actual top of the visible avatar content
determine_bubble_position(
clamped_y,
content_half_height,
estimated_height,
0.0,
0.0,
boundaries.min_y,
)
} else {
BubblePosition::Above
};
// Canvas dimensions - wide enough to fit shifted bubble
let extra_margin = if has_bubble { bubble_max_width } else { 0.0 };
let canvas_width = avatar_size.max(bubble_max_width) + extra_margin;
let canvas_height = avatar_size + bubble_height_reserved + name_height;
// Canvas position in screen space
// The avatar grid center maps to canvas_width/2, but we need to account
// for the content offset so the visible content aligns with clamped_x/y
let canvas_x = clamped_x - avatar_size / 2.0 - content_x_offset;
let canvas_screen_x = canvas_x - (canvas_width - avatar_size) / 2.0;
let canvas_y = clamped_y - avatar_size / 2.0 - content_y_offset;
let canvas_screen_y = match bubble_position {
BubblePosition::Above => canvas_y - bubble_height_reserved,
BubblePosition::Below => canvas_y,
};
// Avatar center within canvas
let avatar_cx = canvas_width / 2.0;
let avatar_cy = match bubble_position {
BubblePosition::Above => bubble_height_reserved + avatar_size / 2.0,
BubblePosition::Below => avatar_size / 2.0,
};
Self {
prop_size,
avatar_size,
content_x_offset,
content_y_offset,
text_scale,
bubble_max_width,
canvas_width,
canvas_height,
canvas_screen_x,
canvas_screen_y,
avatar_cx,
avatar_cy,
boundaries,
bubble_position,
bubble_height_reserved,
empty_top_rows,
empty_bottom_rows,
}
}
/// CSS style string for positioning the canvas element.
fn css_style(&self, z_index: i32, pointer_events: &str, opacity: f64) -> String {
format!(
"position: absolute; \
left: 0; top: 0; \
transform: translate({}px, {}px); \
z-index: {}; \
pointer-events: {}; \
width: {}px; \
height: {}px; \
opacity: {};",
self.canvas_screen_x,
self.canvas_screen_y,
z_index,
pointer_events,
self.canvas_width,
self.canvas_height,
opacity
)
}
/// Content center X in canvas-local coordinates.
fn content_center_x(&self) -> f64 {
self.avatar_cx + self.content_x_offset
}
/// Top of avatar in canvas-local coordinates.
fn avatar_top_y(&self) -> f64 {
self.avatar_cy - self.avatar_size / 2.0
}
/// Bottom of avatar in canvas-local coordinates.
fn avatar_bottom_y(&self) -> f64 {
self.avatar_cy + self.avatar_size / 2.0
}
/// Convert canvas-local X to screen X.
fn canvas_to_screen_x(&self, x: f64) -> f64 {
self.canvas_screen_x + x
}
/// Clamp a bubble's X position to stay within scene boundaries.
/// Takes and returns canvas-local coordinates.
fn clamp_bubble_x(&self, bubble_x: f64, bubble_width: f64) -> f64 {
// Convert to screen space
let screen_left = self.canvas_to_screen_x(bubble_x);
let screen_right = screen_left + bubble_width;
// Calculate shifts needed to stay within bounds
let shift_right = (self.boundaries.min_x - screen_left).max(0.0);
let shift_left = (screen_right - self.boundaries.max_x).max(0.0);
// Apply shift and clamp to canvas bounds
let shifted = bubble_x + shift_right - shift_left;
shifted.max(0.0).min(self.canvas_width - bubble_width)
}
/// Adjustment for bubble Y position based on empty rows at top.
/// Returns the distance in pixels from grid top to content top.
fn content_top_adjustment(&self) -> f64 {
self.empty_top_rows as f64 * self.prop_size
}
/// Adjustment for name Y position based on empty rows at bottom.
/// Returns the distance in pixels from grid bottom to content bottom.
fn content_bottom_adjustment(&self) -> f64 {
self.empty_bottom_rows as f64 * self.prop_size
}
}
/// Get a unique key for a member (for Leptos For keying). /// Get a unique key for a member (for Leptos For keying).
pub fn member_key(m: &ChannelMemberWithAvatar) -> (Option<Uuid>, Option<Uuid>) { pub fn member_key(m: &ChannelMemberWithAvatar) -> (Option<Uuid>, Option<Uuid>) {
(m.member.user_id, m.member.guest_session_id) (m.member.user_id, m.member.guest_session_id)
@ -522,29 +293,102 @@ pub fn AvatarCanvas(
&m.avatar.emotion_layer, &m.avatar.emotion_layer,
); );
// Get offsets from grid center to content center
let x_content_offset = content_bounds.x_offset(ps);
let y_content_offset = content_bounds.y_offset(ps);
// Avatar is a 3x3 grid of props, each prop is prop_size
let avatar_size = ps * 3.0;
// Get scene dimensions (use large defaults if not provided) // Get scene dimensions (use large defaults if not provided)
let sw = scene_width.map(|s| s.get()).unwrap_or(10000.0); let sw = scene_width.map(|s| s.get()).unwrap_or(10000.0);
let sh = scene_height.map(|s| s.get()).unwrap_or(10000.0); let sh = scene_height.map(|s| s.get()).unwrap_or(10000.0);
// Compute screen boundaries and avatar screen position // Compute screen boundaries for avatar clamping
let boundaries = ScreenBoundaries::from_transform(sw, sh, sx, sy, ox, oy); let boundaries = ScreenBoundaries::from_transform(sw, sh, sx, sy, ox, oy);
// Calculate raw avatar screen position
let avatar_screen_x = m.member.position_x * sx + ox; let avatar_screen_x = m.member.position_x * sx + ox;
let avatar_screen_y = m.member.position_y * sy + oy; let avatar_screen_y = m.member.position_y * sy + oy;
// Create unified layout - all calculations happen in one place // Clamp avatar center so visual bounds stay within screen boundaries
let layout = CanvasLayout::new( // Use actual content extent rather than full 3x3 grid
&content_bounds, let content_half_width = content_bounds.content_width(ps) / 2.0;
ps, let content_half_height = content_bounds.content_height(ps) / 2.0;
te, let (clamped_x, clamped_y) = boundaries.clamp_avatar_center(
avatar_screen_x, avatar_screen_x,
avatar_screen_y, avatar_screen_y,
boundaries, content_half_width,
bubble.is_some(), content_half_height,
bubble.as_ref().map(|b| b.message.content.as_str()),
); );
// Generate CSS style from layout // Calculate canvas position from clamped screen coordinates, adjusted for content bounds
layout.css_style(z_index, pointer_events, opacity) let canvas_x = clamped_x - avatar_size / 2.0 - x_content_offset;
let canvas_y = clamped_y - avatar_size / 2.0 - y_content_offset;
// Fixed text dimensions (independent of prop_size/zoom)
let text_scale = te * BASE_TEXT_SCALE;
let fixed_bubble_height = if bubble.is_some() {
(4.0 * 16.0 + 16.0 + 8.0 + 5.0) * text_scale
} else {
0.0
};
let fixed_name_height = 20.0 * text_scale;
let fixed_text_width = 200.0 * text_scale;
// Determine bubble position based on available space above avatar
// This must match the logic in the Effect that draws the bubble
let bubble_position = if bubble.is_some() {
// Use clamped avatar screen position for bubble calculation
let avatar_half_height = avatar_size / 2.0 + y_content_offset;
// Calculate bubble height using actual content (includes tail + gap)
let estimated_bubble_height = bubble
.as_ref()
.map(|b| estimate_bubble_height(&b.message.content, text_scale))
.unwrap_or(0.0);
determine_bubble_position(
clamped_y,
avatar_half_height,
estimated_bubble_height,
0.0, // Already included in estimate_bubble_height
0.0, // Already included in estimate_bubble_height
boundaries.min_y,
)
} else {
BubblePosition::Above // Default when no bubble
};
// Canvas must fit avatar, text, AND bubble (positioned based on bubble location)
let canvas_width = avatar_size.max(fixed_text_width);
let canvas_height = avatar_size + fixed_bubble_height + fixed_name_height;
// Adjust position based on bubble position
// When bubble is above: offset canvas upward to make room at top
// When bubble is below: no upward offset, bubble goes below avatar
let adjusted_y = match bubble_position {
BubblePosition::Above => canvas_y - fixed_bubble_height,
BubblePosition::Below => canvas_y,
};
format!(
"position: absolute; \
left: 0; top: 0; \
transform: translate({}px, {}px); \
z-index: {}; \
pointer-events: {}; \
width: {}px; \
height: {}px; \
opacity: {};",
canvas_x - (canvas_width - avatar_size) / 2.0,
adjusted_y,
z_index,
pointer_events,
canvas_width,
canvas_height,
opacity
)
}; };
// Store references for the effect // Store references for the effect
@ -578,7 +422,7 @@ pub fn AvatarCanvas(
return; return;
}; };
// Calculate content bounds for the avatar // Calculate dimensions (same as in style closure)
let content_bounds = ContentBounds::from_layers( let content_bounds = ContentBounds::from_layers(
&m.avatar.skin_layer, &m.avatar.skin_layer,
&m.avatar.clothes_layer, &m.avatar.clothes_layer,
@ -586,35 +430,61 @@ pub fn AvatarCanvas(
&m.avatar.emotion_layer, &m.avatar.emotion_layer,
); );
// Get scene dimensions and transform parameters let avatar_size = ps * 3.0;
let sx = scale_x.get(); let text_scale = te * BASE_TEXT_SCALE;
let sy = scale_y.get(); let fixed_bubble_height = if bubble.is_some() {
let ox = offset_x.get(); (4.0 * 16.0 + 16.0 + 8.0 + 5.0) * text_scale
let oy = offset_y.get(); } else {
let sw = scene_width.map(|s| s.get()).unwrap_or(10000.0); 0.0
let sh = scene_height.map(|s| s.get()).unwrap_or(10000.0); };
let fixed_name_height = 20.0 * text_scale;
let fixed_text_width = 200.0 * text_scale;
// Create unified layout - same calculation as style closure // Determine bubble position early so we can position the avatar correctly
let boundaries = ScreenBoundaries::from_transform(sw, sh, sx, sy, ox, oy); let y_content_offset = content_bounds.y_offset(ps);
let avatar_screen_x = m.member.position_x * sx + ox; let bubble_position = if bubble.is_some() {
let avatar_screen_y = m.member.position_y * sy + oy; let sx = scale_x.get();
let sy = scale_y.get();
let ox = offset_x.get();
let oy = offset_y.get();
let layout = CanvasLayout::new( // Get scene dimensions (use large defaults if not provided)
&content_bounds, let sw = scene_width.map(|s| s.get()).unwrap_or(10000.0);
ps, let sh = scene_height.map(|s| s.get()).unwrap_or(10000.0);
te,
avatar_screen_x, // Compute screen boundaries
avatar_screen_y, let boundaries = ScreenBoundaries::from_transform(sw, sh, sx, sy, ox, oy);
boundaries,
bubble.is_some(), // Calculate avatar's screen position
bubble.as_ref().map(|b| b.message.content.as_str()), let avatar_screen_y = m.member.position_y * sy + oy;
); let avatar_half_height = avatar_size / 2.0 + y_content_offset;
// Calculate bubble height using actual content (includes tail + gap)
let estimated_bubble_height = bubble
.as_ref()
.map(|b| estimate_bubble_height(&b.message.content, text_scale))
.unwrap_or(0.0);
determine_bubble_position(
avatar_screen_y,
avatar_half_height,
estimated_bubble_height,
0.0, // Already included in estimate_bubble_height
0.0, // Already included in estimate_bubble_height
boundaries.min_y,
)
} else {
BubblePosition::Above
};
let canvas_width = avatar_size.max(fixed_text_width);
let canvas_height = avatar_size + fixed_bubble_height + fixed_name_height;
let canvas_el: &web_sys::HtmlCanvasElement = &canvas; let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
// Set canvas resolution from layout // Set canvas resolution
canvas_el.set_width(layout.canvas_width as u32); canvas_el.set_width(canvas_width as u32);
canvas_el.set_height(layout.canvas_height as u32); canvas_el.set_height(canvas_height as u32);
let Ok(Some(ctx)) = canvas_el.get_context("2d") else { let Ok(Some(ctx)) = canvas_el.get_context("2d") else {
return; return;
@ -622,7 +492,16 @@ pub fn AvatarCanvas(
let ctx: web_sys::CanvasRenderingContext2d = ctx.dyn_into().unwrap(); let ctx: web_sys::CanvasRenderingContext2d = ctx.dyn_into().unwrap();
// Clear canvas // Clear canvas
ctx.clear_rect(0.0, 0.0, layout.canvas_width, layout.canvas_height); ctx.clear_rect(0.0, 0.0, canvas_width, canvas_height);
// Avatar center position within the canvas
// When bubble is above: avatar is below the bubble space
// When bubble is below: avatar is at the top, bubble space is below
let avatar_cx = canvas_width / 2.0;
let avatar_cy = match bubble_position {
BubblePosition::Above => fixed_bubble_height + avatar_size / 2.0,
BubblePosition::Below => avatar_size / 2.0,
};
// Helper to load and draw an image // Helper to load and draw an image
// Images are cached; when loaded, triggers a redraw via signal // Images are cached; when loaded, triggers a redraw via signal
@ -665,9 +544,9 @@ pub fn AvatarCanvas(
}; };
// Draw all 9 positions of the avatar grid (3x3 layout) // Draw all 9 positions of the avatar grid (3x3 layout)
let cell_size = layout.prop_size; let cell_size = ps;
let grid_origin_x = layout.avatar_cx - layout.avatar_size / 2.0; let grid_origin_x = avatar_cx - avatar_size / 2.0;
let grid_origin_y = layout.avatar_cy - layout.avatar_size / 2.0; let grid_origin_y = avatar_cy - avatar_size / 2.0;
// Draw skin layer for all 9 positions // Draw skin layer for all 9 positions
for pos in 0..9 { for pos in 0..9 {
@ -737,9 +616,9 @@ pub fn AvatarCanvas(
// Draw emotion badge if non-neutral // Draw emotion badge if non-neutral
let current_emotion = m.member.current_emotion; let current_emotion = m.member.current_emotion;
if current_emotion > 0 { if current_emotion > 0 {
let badge_size = 16.0 * layout.text_scale; let badge_size = 16.0 * text_scale;
let badge_x = layout.avatar_cx + layout.avatar_size / 2.0 - badge_size / 2.0; let badge_x = avatar_cx + avatar_size / 2.0 - badge_size / 2.0;
let badge_y = layout.avatar_cy - layout.avatar_size / 2.0 - badge_size / 2.0; let badge_y = avatar_cy - avatar_size / 2.0 - badge_size / 2.0;
ctx.begin_path(); ctx.begin_path();
let _ = ctx.arc( let _ = ctx.arc(
@ -753,21 +632,23 @@ pub fn AvatarCanvas(
ctx.fill(); ctx.fill();
ctx.set_fill_style_str("#000"); ctx.set_fill_style_str("#000");
ctx.set_font(&format!("bold {}px sans-serif", 10.0 * layout.text_scale)); ctx.set_font(&format!("bold {}px sans-serif", 10.0 * text_scale));
ctx.set_text_align("center"); ctx.set_text_align("center");
ctx.set_text_baseline("middle"); ctx.set_text_baseline("middle");
let _ = ctx.fill_text(&format!("{}", current_emotion), badge_x, badge_y); let _ = ctx.fill_text(&format!("{}", current_emotion), badge_x, badge_y);
} }
// Draw display name below avatar (with black outline for readability) // Calculate content bounds for name positioning
let name_x = layout.content_center_x(); let name_x = avatar_cx + content_bounds.x_offset(cell_size);
let name_y = layout.avatar_bottom_y() - layout.content_bottom_adjustment() let empty_bottom_rows = content_bounds.empty_bottom_rows();
+ 15.0 * layout.text_scale;
// Draw display name below avatar (with black outline for readability)
let display_name = &m.member.display_name; let display_name = &m.member.display_name;
ctx.set_font(&format!("{}px sans-serif", 12.0 * layout.text_scale)); ctx.set_font(&format!("{}px sans-serif", 12.0 * text_scale));
ctx.set_text_align("center"); ctx.set_text_align("center");
ctx.set_text_baseline("alphabetic"); ctx.set_text_baseline("alphabetic");
let name_y = avatar_cy + avatar_size / 2.0 - (empty_bottom_rows as f64 * cell_size)
+ 15.0 * text_scale;
// Black outline // Black outline
ctx.set_stroke_style_str("#000"); ctx.set_stroke_style_str("#000");
ctx.set_line_width(3.0); ctx.set_line_width(3.0);
@ -780,7 +661,35 @@ pub fn AvatarCanvas(
if let Some(ref b) = bubble { if let Some(ref b) = bubble {
let current_time = js_sys::Date::now() as i64; let current_time = js_sys::Date::now() as i64;
if b.expires_at >= current_time { if b.expires_at >= current_time {
draw_bubble_with_layout(&ctx, b, &layout, te); let content_x_offset = content_bounds.x_offset(cell_size);
let content_top_adjustment = content_bounds.empty_top_rows() as f64 * cell_size;
// Get screen boundaries for horizontal clamping
let sx = scale_x.get();
let sy = scale_y.get();
let ox = offset_x.get();
let oy = offset_y.get();
let sw = scene_width.map(|s| s.get()).unwrap_or(10000.0);
let sh = scene_height.map(|s| s.get()).unwrap_or(10000.0);
let boundaries = ScreenBoundaries::from_transform(sw, sh, sx, sy, ox, oy);
// Avatar top and bottom Y within the canvas
let avatar_top_y = avatar_cy - avatar_size / 2.0;
let avatar_bottom_y = avatar_cy + avatar_size / 2.0;
// Use the pre-calculated bubble_position from earlier
draw_bubble(
&ctx,
b,
avatar_cx,
avatar_top_y,
avatar_bottom_y,
content_x_offset,
content_top_adjustment,
te,
bubble_position,
Some(&boundaries),
);
} }
} }
}); });
@ -815,18 +724,33 @@ fn normalize_asset_path(path: &str) -> String {
} }
} }
/// Draw a speech bubble using the unified CanvasLayout. /// Draw a speech bubble relative to the avatar with boundary awareness.
/// ///
/// This is the preferred method for drawing bubbles - it uses the layout's /// # Arguments
/// coordinate transformation and clamping methods, ensuring consistency /// * `ctx` - Canvas rendering context
/// with the canvas positioning. /// * `bubble` - The active bubble data
/// * `center_x` - Avatar center X in canvas coordinates
/// * `top_y` - Avatar top edge Y in canvas coordinates
/// * `bottom_y` - Avatar bottom edge Y in canvas coordinates
/// * `content_x_offset` - X offset to center on content
/// * `content_top_adjustment` - Y adjustment for empty top rows
/// * `text_em_size` - Text size multiplier
/// * `position` - Whether to render above or below the avatar
/// * `boundaries` - Screen boundaries for horizontal clamping (optional)
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
fn draw_bubble_with_layout( fn draw_bubble(
ctx: &web_sys::CanvasRenderingContext2d, ctx: &web_sys::CanvasRenderingContext2d,
bubble: &ActiveBubble, bubble: &ActiveBubble,
layout: &CanvasLayout, center_x: f64,
top_y: f64,
bottom_y: f64,
content_x_offset: f64,
content_top_adjustment: f64,
text_em_size: f64, text_em_size: f64,
position: BubblePosition,
boundaries: Option<&ScreenBoundaries>,
) { ) {
// Text scale independent of zoom - only affected by user's text_em_size setting
let text_scale = text_em_size * BASE_TEXT_SCALE; let text_scale = text_em_size * BASE_TEXT_SCALE;
let max_bubble_width = 200.0 * text_scale; let max_bubble_width = 200.0 * text_scale;
let padding = 8.0 * text_scale; let padding = 8.0 * text_scale;
@ -847,7 +771,11 @@ fn draw_bubble_with_layout(
// Measure and wrap text // Measure and wrap text
ctx.set_font(&format!("{}{}px sans-serif", font_style, font_size)); ctx.set_font(&format!("{}{}px sans-serif", font_style, font_size));
let lines = wrap_text(ctx, &bubble.message.content, max_bubble_width - padding * 2.0); let lines = wrap_text(
ctx,
&bubble.message.content,
max_bubble_width - padding * 2.0,
);
// Calculate bubble dimensions // Calculate bubble dimensions
let bubble_width = lines let bubble_width = lines
@ -858,14 +786,25 @@ fn draw_bubble_with_layout(
let bubble_width = bubble_width.max(60.0 * text_scale); let bubble_width = bubble_width.max(60.0 * text_scale);
let bubble_height = (lines.len() as f64) * line_height + padding * 2.0; let bubble_height = (lines.len() as f64) * line_height + padding * 2.0;
// Get content center from layout // Center bubble horizontally on content (not grid center)
let content_center_x = layout.content_center_x(); let content_center_x = center_x + content_x_offset;
// Calculate initial bubble X (centered on content) // Calculate initial bubble X position (centered on content)
let initial_bubble_x = content_center_x - bubble_width / 2.0; let mut bubble_x = content_center_x - bubble_width / 2.0;
// Use layout's clamping method - this handles coordinate transformation correctly // Clamp bubble horizontally to stay within drawable area
let bubble_x = layout.clamp_bubble_x(initial_bubble_x, bubble_width); if let Some(bounds) = boundaries {
let bubble_left = bubble_x;
let bubble_right = bubble_x + bubble_width;
if bubble_left < bounds.min_x {
// Shift right to stay within left edge
bubble_x = bounds.min_x;
} else if bubble_right > bounds.max_x {
// Shift left to stay within right edge
bubble_x = bounds.max_x - bubble_width;
}
}
// Calculate tail center - point toward content center but stay within bubble bounds // Calculate tail center - point toward content center but stay within bubble bounds
let tail_center_x = content_center_x let tail_center_x = content_center_x
@ -873,20 +812,27 @@ fn draw_bubble_with_layout(
.min(bubble_x + bubble_width - tail_size - border_radius); .min(bubble_x + bubble_width - tail_size - border_radius);
// Calculate vertical position based on bubble position // Calculate vertical position based on bubble position
let bubble_y = match layout.bubble_position { let bubble_y = match position {
BubblePosition::Above => { BubblePosition::Above => {
// Position vertically closer to content when top rows are empty // Position vertically closer to content when top rows are empty
let adjusted_top_y = layout.avatar_top_y() + layout.content_top_adjustment(); let adjusted_top_y = top_y + content_top_adjustment;
adjusted_top_y - bubble_height - tail_size - gap adjusted_top_y - bubble_height - tail_size - gap
} }
BubblePosition::Below => { BubblePosition::Below => {
// Position below avatar with gap for tail // Position below avatar with gap for tail
layout.avatar_bottom_y() + tail_size + gap bottom_y + tail_size + gap
} }
}; };
// Draw bubble background // Draw bubble background
draw_rounded_rect(ctx, bubble_x, bubble_y, bubble_width, bubble_height, border_radius); draw_rounded_rect(
ctx,
bubble_x,
bubble_y,
bubble_width,
bubble_height,
border_radius,
);
ctx.set_fill_style_str(bg_color); ctx.set_fill_style_str(bg_color);
ctx.fill(); ctx.fill();
ctx.set_stroke_style_str(border_color); ctx.set_stroke_style_str(border_color);
@ -895,7 +841,7 @@ fn draw_bubble_with_layout(
// Draw tail pointing to content center // Draw tail pointing to content center
ctx.begin_path(); ctx.begin_path();
match layout.bubble_position { match position {
BubblePosition::Above => { BubblePosition::Above => {
// Tail points DOWN toward avatar // Tail points DOWN toward avatar
ctx.move_to(tail_center_x - tail_size, bubble_y + bubble_height); ctx.move_to(tail_center_x - tail_size, bubble_y + bubble_height);
@ -915,7 +861,7 @@ fn draw_bubble_with_layout(
ctx.set_stroke_style_str(border_color); ctx.set_stroke_style_str(border_color);
ctx.stroke(); ctx.stroke();
// Draw text // Draw text (re-set font in case canvas state changed)
ctx.set_font(&format!("{}{}px sans-serif", font_style, font_size)); ctx.set_font(&format!("{}{}px sans-serif", font_style, font_size));
ctx.set_fill_style_str(text_color); ctx.set_fill_style_str(text_color);
ctx.set_text_align("left"); ctx.set_text_align("left");

View file

@ -12,7 +12,6 @@ use chattyness_db::models::{AvatarWithPaths, InventoryItem};
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
use chattyness_db::ws_messages::ClientMessage; use chattyness_db::ws_messages::ClientMessage;
use super::modals::GuestLockedOverlay;
use super::ws_client::WsSenderStorage; use super::ws_client::WsSenderStorage;
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
use crate::utils::normalize_asset_path; use crate::utils::normalize_asset_path;
@ -217,7 +216,6 @@ fn RenderedPreview(#[prop(into)] avatar: Signal<Option<AvatarWithPaths>>) -> imp
/// - `realm_slug`: Current realm slug for API calls /// - `realm_slug`: Current realm slug for API calls
/// - `on_avatar_update`: Callback when avatar is updated /// - `on_avatar_update`: Callback when avatar is updated
/// - `ws_sender`: WebSocket sender for broadcasting avatar changes /// - `ws_sender`: WebSocket sender for broadcasting avatar changes
/// - `is_guest`: Whether the current user is a guest (shows locked overlay)
#[component] #[component]
pub fn AvatarEditorPopup( pub fn AvatarEditorPopup(
#[prop(into)] open: Signal<bool>, #[prop(into)] open: Signal<bool>,
@ -226,11 +224,7 @@ pub fn AvatarEditorPopup(
#[prop(into)] realm_slug: Signal<String>, #[prop(into)] realm_slug: Signal<String>,
on_avatar_update: Callback<AvatarWithPaths>, on_avatar_update: Callback<AvatarWithPaths>,
ws_sender: WsSenderStorage, ws_sender: WsSenderStorage,
/// Whether the current user is a guest. Guests see a locked overlay.
#[prop(optional, into)]
is_guest: Option<Signal<bool>>,
) -> impl IntoView { ) -> impl IntoView {
let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false));
// Tab state // Tab state
let (active_tab, set_active_tab) = signal(EditorTab::BaseLayers); let (active_tab, set_active_tab) = signal(EditorTab::BaseLayers);
@ -804,11 +798,6 @@ pub fn AvatarEditorPopup(
</div> </div>
</div> </div>
</div> </div>
// Guest locked overlay
<Show when=move || is_guest.get()>
<GuestLockedOverlay />
</Show>
</div> </div>
// Context menu // Context menu

View file

@ -1,13 +1,11 @@
//! Chat components for realm chat interface. //! Chat components for realm chat interface.
use leptos::prelude::*; use leptos::prelude::*;
use uuid::Uuid;
use chattyness_db::models::{EmotionAvailability, SceneSummary}; use chattyness_db::models::EmotionAvailability;
use chattyness_db::ws_messages::ClientMessage; use chattyness_db::ws_messages::ClientMessage;
use super::emotion_picker::{EMOTIONS, EmoteListPopup, LabelStyle}; use super::emotion_picker::{EMOTIONS, EmoteListPopup, LabelStyle};
use super::scene_list_popup::SceneListPopup;
use super::ws_client::WsSenderStorage; use super::ws_client::WsSenderStorage;
/// Command mode state for the chat input. /// Command mode state for the chat input.
@ -21,8 +19,6 @@ enum CommandMode {
ShowingSlashHint, ShowingSlashHint,
/// Showing emotion list popup. /// Showing emotion list popup.
ShowingList, ShowingList,
/// Showing scene list popup for teleport.
ShowingSceneList,
} }
/// Parse an emote command and return the emotion name if valid. /// Parse an emote command and return the emotion name if valid.
@ -48,28 +44,6 @@ fn parse_emote_command(cmd: &str) -> Option<String> {
}) })
} }
/// Parse a teleport command and return the scene slug if valid.
///
/// Supports `/t slug` and `/teleport slug`.
fn parse_teleport_command(cmd: &str) -> Option<String> {
let cmd = cmd.trim();
// Strip the leading slash if present
let cmd = cmd.strip_prefix('/').unwrap_or(cmd);
// Check for `t <slug>` or `teleport <slug>`
let slug = cmd
.strip_prefix("teleport ")
.or_else(|| cmd.strip_prefix("t "))
.map(str::trim)?;
if slug.is_empty() {
return None;
}
Some(slug.to_string())
}
/// Parse a whisper command and return (target_name, message) if valid. /// Parse a whisper command and return (target_name, message) if valid.
/// ///
/// Supports `/w name message` and `/whisper name message`. /// Supports `/w name message` and `/whisper name message`.
@ -110,9 +84,6 @@ fn parse_whisper_command(cmd: &str) -> Option<(String, String)> {
/// - `on_open_settings`: Callback to open settings popup /// - `on_open_settings`: Callback to open settings popup
/// - `on_open_inventory`: Callback to open inventory popup /// - `on_open_inventory`: Callback to open inventory popup
/// - `whisper_target`: Signal containing the display name to whisper to (triggers pre-fill) /// - `whisper_target`: Signal containing the display name to whisper to (triggers pre-fill)
/// - `scenes`: List of available scenes for teleport command
/// - `allow_user_teleport`: Whether teleporting is enabled for this realm
/// - `on_teleport`: Callback when a teleport is requested (receives scene ID)
#[component] #[component]
pub fn ChatInput( pub fn ChatInput(
ws_sender: WsSenderStorage, ws_sender: WsSenderStorage,
@ -126,23 +97,11 @@ pub fn ChatInput(
/// Signal containing the display name to whisper to. When set, pre-fills the input. /// Signal containing the display name to whisper to. When set, pre-fills the input.
#[prop(optional, into)] #[prop(optional, into)]
whisper_target: Option<Signal<Option<String>>>, whisper_target: Option<Signal<Option<String>>>,
/// List of available scenes for teleport command.
#[prop(optional, into)]
scenes: Option<Signal<Vec<SceneSummary>>>,
/// Whether teleporting is enabled for this realm.
#[prop(default = Signal::derive(|| false))]
allow_user_teleport: Signal<bool>,
/// Callback when a teleport is requested.
#[prop(optional)]
on_teleport: Option<Callback<Uuid>>,
) -> impl IntoView { ) -> impl IntoView {
let (message, set_message) = signal(String::new()); let (message, set_message) = signal(String::new());
let (command_mode, set_command_mode) = signal(CommandMode::None); let (command_mode, set_command_mode) = signal(CommandMode::None);
let (list_filter, set_list_filter) = signal(String::new()); let (list_filter, set_list_filter) = signal(String::new());
let (selected_index, set_selected_index) = signal(0usize); let (selected_index, set_selected_index) = signal(0usize);
// Separate filter/index for scene list
let (scene_filter, set_scene_filter) = signal(String::new());
let (scene_selected_index, set_scene_selected_index) = signal(0usize);
let input_ref = NodeRef::<leptos::html::Input>::new(); let input_ref = NodeRef::<leptos::html::Input>::new();
// Compute filtered emotions for keyboard navigation // Compute filtered emotions for keyboard navigation
@ -162,21 +121,6 @@ pub fn ChatInput(
.unwrap_or_default() .unwrap_or_default()
}; };
// Compute filtered scenes for teleport navigation
let filtered_scenes = move || {
let filter_text = scene_filter.get().to_lowercase();
scenes
.map(|s| s.get())
.unwrap_or_default()
.into_iter()
.filter(|s| {
filter_text.is_empty()
|| s.name.to_lowercase().contains(&filter_text)
|| s.slug.to_lowercase().contains(&filter_text)
})
.collect::<Vec<_>>()
};
// Handle focus trigger from parent (when space, ':' or '/' is pressed globally) // Handle focus trigger from parent (when space, ':' or '/' is pressed globally)
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
{ {
@ -218,24 +162,25 @@ pub fn ChatInput(
return; return;
}; };
// Pre-fill with /whisper command prefix only (no placeholder text) // Pre-fill with /whisper command
// User types their message after the space let placeholder = "your message here";
// parse_whisper_command already rejects empty messages let whisper_text = format!("/whisper {} {}", target_name, placeholder);
let whisper_prefix = format!("/whisper {} ", target_name);
if let Some(input) = input_ref.get() { if let Some(input) = input_ref.get() {
// Set the message // Set the message
set_message.set(whisper_prefix.clone()); set_message.set(whisper_text.clone());
// Don't show hint - user already knows they're whispering
set_command_mode.set(CommandMode::None); set_command_mode.set(CommandMode::None);
// Update input value // Update input value
input.set_value(&whisper_prefix); input.set_value(&whisper_text);
// Focus the input and position cursor at end // Focus the input
let _ = input.focus(); let _ = input.focus();
let len = whisper_prefix.len() as u32;
let _ = input.set_selection_range(len, len); // Select the placeholder text so it gets replaced when typing
let prefix_len = format!("/whisper {} ", target_name).len() as u32;
let total_len = whisper_text.len() as u32;
let _ = input.set_selection_range(prefix_len, total_len);
} }
}); });
} }
@ -260,20 +205,13 @@ pub fn ChatInput(
let value = event_target_value(&ev); let value = event_target_value(&ev);
set_message.set(value.clone()); set_message.set(value.clone());
// If emotion list is showing, update filter (input is the filter text) // If list is showing, update filter (input is the filter text)
if command_mode.get_untracked() == CommandMode::ShowingList { if command_mode.get_untracked() == CommandMode::ShowingList {
set_list_filter.set(value.clone()); set_list_filter.set(value.clone());
set_selected_index.set(0); // Reset selection when filter changes set_selected_index.set(0); // Reset selection when filter changes
return; return;
} }
// If scene list is showing, update filter (input is the filter text)
if command_mode.get_untracked() == CommandMode::ShowingSceneList {
set_scene_filter.set(value.clone());
set_scene_selected_index.set(0); // Reset selection when filter changes
return;
}
if value.starts_with(':') { if value.starts_with(':') {
let cmd = value[1..].to_lowercase(); let cmd = value[1..].to_lowercase();
@ -292,45 +230,16 @@ pub fn ChatInput(
let cmd = value[1..].to_lowercase(); let cmd = value[1..].to_lowercase();
// Show hint for slash commands (don't execute until Enter) // Show hint for slash commands (don't execute until Enter)
// Match: /s[etting], /i[nventory], /w[hisper], /t[eleport] // Match: /s[etting], /i[nventory], /w[hisper], or their full forms with args
// But NOT when whisper command is complete (has name + space for message) if cmd.is_empty()
let is_complete_whisper = {
// Check if it's "/w name " or "/whisper name " (name followed by space)
let rest = cmd.strip_prefix("whisper ").or_else(|| cmd.strip_prefix("w "));
if let Some(after_cmd) = rest {
// If there's content after the command and it contains a space,
// user has typed "name " and is now typing the message
after_cmd.contains(' ')
} else {
false
}
};
// Check if teleport command is complete (has slug)
let is_complete_teleport = {
let rest = cmd.strip_prefix("teleport ").or_else(|| cmd.strip_prefix("t "));
if let Some(after_cmd) = rest {
!after_cmd.is_empty()
} else {
false
}
};
if is_complete_whisper || is_complete_teleport {
// User is typing the argument part, no hint needed
set_command_mode.set(CommandMode::None);
} else if cmd.is_empty()
|| "setting".starts_with(&cmd) || "setting".starts_with(&cmd)
|| "inventory".starts_with(&cmd) || "inventory".starts_with(&cmd)
|| "whisper".starts_with(&cmd) || "whisper".starts_with(&cmd)
|| "teleport".starts_with(&cmd)
|| cmd == "setting" || cmd == "setting"
|| cmd == "settings" || cmd == "settings"
|| cmd == "inventory" || cmd == "inventory"
|| cmd.starts_with("w ") || cmd.starts_with("w ")
|| cmd.starts_with("whisper ") || cmd.starts_with("whisper ")
|| cmd.starts_with("t ")
|| cmd.starts_with("teleport ")
{ {
set_command_mode.set(CommandMode::ShowingSlashHint); set_command_mode.set(CommandMode::ShowingSlashHint);
} else { } else {
@ -356,8 +265,6 @@ pub fn ChatInput(
set_command_mode.set(CommandMode::None); set_command_mode.set(CommandMode::None);
set_list_filter.set(String::new()); set_list_filter.set(String::new());
set_selected_index.set(0); set_selected_index.set(0);
set_scene_filter.set(String::new());
set_scene_selected_index.set(0);
set_message.set(String::new()); set_message.set(String::new());
// Blur the input to unfocus chat // Blur the input to unfocus chat
if let Some(input) = input_ref.get() { if let Some(input) = input_ref.get() {
@ -407,51 +314,6 @@ pub fn ChatInput(
} }
} }
// Arrow key navigation when scene list is showing
if current_mode == CommandMode::ShowingSceneList {
let scene_list = filtered_scenes();
let count = scene_list.len();
if key == "ArrowDown" && count > 0 {
set_scene_selected_index.update(|idx| {
*idx = (*idx + 1) % count;
});
ev.prevent_default();
return;
}
if key == "ArrowUp" && count > 0 {
set_scene_selected_index.update(|idx| {
*idx = if *idx == 0 { count - 1 } else { *idx - 1 };
});
ev.prevent_default();
return;
}
if key == "Enter" && count > 0 {
// Select the currently highlighted scene - fill in command
let idx = scene_selected_index.get_untracked();
if let Some(scene) = scene_list.get(idx) {
let cmd = format!("/teleport {}", scene.slug);
set_scene_filter.set(String::new());
set_scene_selected_index.set(0);
set_command_mode.set(CommandMode::None);
set_message.set(cmd.clone());
if let Some(input) = input_ref.get() {
input.set_value(&cmd);
}
}
ev.prevent_default();
return;
}
// Any other key in scene list mode is handled by on_input
if key == "Enter" {
ev.prevent_default();
return;
}
}
// Tab for autocomplete // Tab for autocomplete
if key == "Tab" { if key == "Tab" {
let msg = message.get(); let msg = message.get();
@ -475,15 +337,6 @@ pub fn ChatInput(
ev.prevent_default(); ev.prevent_default();
return; return;
} }
// Autocomplete to /teleport if /t, /te, /tel, etc.
if !cmd.is_empty() && "teleport".starts_with(&cmd) && cmd != "teleport" {
set_message.set("/teleport".to_string());
if let Some(input) = input_ref.get() {
input.set_value("/teleport");
}
ev.prevent_default();
return;
}
} }
// Always prevent Tab from moving focus when in input // Always prevent Tab from moving focus when in input
ev.prevent_default(); ev.prevent_default();
@ -548,43 +401,6 @@ pub fn ChatInput(
return; return;
} }
// /t or /teleport (no slug yet) - show scene list if enabled
if allow_user_teleport.get_untracked()
&& !cmd.is_empty()
&& ("teleport".starts_with(&cmd) || cmd == "teleport")
{
set_command_mode.set(CommandMode::ShowingSceneList);
set_scene_filter.set(String::new());
set_scene_selected_index.set(0);
set_message.set(String::new());
if let Some(input) = input_ref.get() {
input.set_value("");
}
ev.prevent_default();
return;
}
// /teleport {slug} - execute teleport
if let Some(slug) = parse_teleport_command(&msg) {
if allow_user_teleport.get_untracked() {
// Find the scene by slug
let scene_list = scenes.map(|s| s.get()).unwrap_or_default();
if let Some(scene) = scene_list.iter().find(|s| s.slug == slug) {
if let Some(ref callback) = on_teleport {
callback.run(scene.id);
}
set_message.set(String::new());
set_command_mode.set(CommandMode::None);
if let Some(input) = input_ref.get() {
input.set_value("");
let _ = input.blur();
}
}
}
ev.prevent_default();
return;
}
// Invalid slash command - just ignore, don't send // Invalid slash command - just ignore, don't send
ev.prevent_default(); ev.prevent_default();
return; return;
@ -654,7 +470,7 @@ pub fn ChatInput(
} }
}; };
// Popup select handler for emotions // Popup select handler
let on_popup_select = Callback::new(move |emotion: String| { let on_popup_select = Callback::new(move |emotion: String| {
set_list_filter.set(String::new()); set_list_filter.set(String::new());
apply_emotion(emotion); apply_emotion(emotion);
@ -665,27 +481,7 @@ pub fn ChatInput(
set_command_mode.set(CommandMode::None); set_command_mode.set(CommandMode::None);
}); });
// Scene popup select handler - fills in the command
let on_scene_select = Callback::new(move |scene: SceneSummary| {
let cmd = format!("/teleport {}", scene.slug);
set_scene_filter.set(String::new());
set_scene_selected_index.set(0);
set_command_mode.set(CommandMode::None);
set_message.set(cmd.clone());
if let Some(input) = input_ref.get() {
input.set_value(&cmd);
}
});
let on_scene_popup_close = Callback::new(move |_: ()| {
set_scene_filter.set(String::new());
set_scene_selected_index.set(0);
set_command_mode.set(CommandMode::None);
});
let filter_signal = Signal::derive(move || list_filter.get()); let filter_signal = Signal::derive(move || list_filter.get());
let scene_filter_signal = Signal::derive(move || scene_filter.get());
let scenes_signal = Signal::derive(move || scenes.map(|s| s.get()).unwrap_or_default());
view! { view! {
<div class="chat-input-container w-full max-w-4xl mx-auto pointer-events-auto relative"> <div class="chat-input-container w-full max-w-4xl mx-auto pointer-events-auto relative">
@ -702,7 +498,7 @@ pub fn ChatInput(
</div> </div>
</Show> </Show>
// Slash command hint bar (/s[etting], /i[nventory], /w[hisper], /t[eleport]) // Slash command hint bar (/s[etting], /i[nventory], /w[hisper])
<Show when=move || command_mode.get() == CommandMode::ShowingSlashHint> <Show when=move || command_mode.get() == CommandMode::ShowingSlashHint>
<div class="absolute bottom-full left-0 mb-1 px-3 py-1 bg-gray-700/90 backdrop-blur-sm rounded text-sm"> <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-gray-400">"/"</span>
@ -716,12 +512,6 @@ pub fn ChatInput(
<span class="text-gray-400">"/"</span> <span class="text-gray-400">"/"</span>
<span class="text-blue-400">"w"</span> <span class="text-blue-400">"w"</span>
<span class="text-gray-500">"[hisper] name"</span> <span class="text-gray-500">"[hisper] name"</span>
<Show when=move || allow_user_teleport.get()>
<span class="text-gray-600 mx-2">"|"</span>
<span class="text-gray-400">"/"</span>
<span class="text-blue-400">"t"</span>
<span class="text-gray-500">"[eleport]"</span>
</Show>
</div> </div>
</Show> </Show>
@ -738,17 +528,6 @@ pub fn ChatInput(
/> />
</Show> </Show>
// Scene list popup for teleport
<Show when=move || command_mode.get() == CommandMode::ShowingSceneList>
<SceneListPopup
scenes=scenes_signal
on_select=on_scene_select
on_close=on_scene_popup_close
scene_filter=scene_filter_signal
selected_idx=Signal::derive(move || scene_selected_index.get())
/>
</Show>
<div class="flex items-center bg-gray-900/80 backdrop-blur-sm rounded-lg p-2 border border-gray-600"> <div class="flex items-center bg-gray-900/80 backdrop-blur-sm rounded-lg p-2 border border-gray-600">
<input <input
type="text" type="text"

View file

@ -24,7 +24,6 @@ pub struct ContextMenuItem {
/// Props: /// Props:
/// - `open`: Whether the menu is currently visible /// - `open`: Whether the menu is currently visible
/// - `position`: The (x, y) position in client coordinates where the menu should appear /// - `position`: The (x, y) position in client coordinates where the menu should appear
/// - `header`: Optional header text displayed at the top (e.g., username)
/// - `items`: The menu items to display /// - `items`: The menu items to display
/// - `on_select`: Callback when a menu item is selected, receives the action string /// - `on_select`: Callback when a menu item is selected, receives the action string
/// - `on_close`: Callback when the menu should close (click outside, escape, etc.) /// - `on_close`: Callback when the menu should close (click outside, escape, etc.)
@ -36,9 +35,6 @@ pub fn ContextMenu(
/// Position (x, y) in client coordinates. /// Position (x, y) in client coordinates.
#[prop(into)] #[prop(into)]
position: Signal<(f64, f64)>, position: Signal<(f64, f64)>,
/// Optional header text (e.g., username) displayed above the menu items.
#[prop(optional, into)]
header: Option<Signal<Option<String>>>,
/// Menu items to display. /// Menu items to display.
#[prop(into)] #[prop(into)]
items: Signal<Vec<ContextMenuItem>>, items: Signal<Vec<ContextMenuItem>>,
@ -86,40 +82,12 @@ pub fn ContextMenu(
) )
}; };
// Click outside handler - use Effect with cleanup to properly remove handlers // Click outside handler
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
{ {
use std::cell::RefCell;
use std::rc::Rc;
use wasm_bindgen::{JsCast, closure::Closure}; use wasm_bindgen::{JsCast, closure::Closure};
// Store closures so we can remove them on cleanup
let mousedown_closure: Rc<RefCell<Option<Closure<dyn Fn(web_sys::MouseEvent)>>>> =
Rc::new(RefCell::new(None));
let keydown_closure: Rc<RefCell<Option<Closure<dyn Fn(web_sys::KeyboardEvent)>>>> =
Rc::new(RefCell::new(None));
let mousedown_closure_clone = mousedown_closure.clone();
let keydown_closure_clone = keydown_closure.clone();
Effect::new(move |_| { Effect::new(move |_| {
let window = web_sys::window().unwrap();
// Clean up previous handlers first
if let Some(old_handler) = mousedown_closure_clone.borrow_mut().take() {
let _ = window.remove_event_listener_with_callback(
"mousedown",
old_handler.as_ref().unchecked_ref(),
);
}
if let Some(old_handler) = keydown_closure_clone.borrow_mut().take() {
let _ = window.remove_event_listener_with_callback(
"keydown",
old_handler.as_ref().unchecked_ref(),
);
}
// Only add handlers when menu is open
if !open.get() { if !open.get() {
return; return;
} }
@ -132,7 +100,6 @@ pub fn ContextMenu(
let menu_el: web_sys::HtmlElement = menu_el.into(); let menu_el: web_sys::HtmlElement = menu_el.into();
let menu_el_clone = menu_el.clone(); let menu_el_clone = menu_el.clone();
// Mousedown handler for click-outside detection
let handler = let handler =
Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |ev: web_sys::MouseEvent| { Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |ev: web_sys::MouseEvent| {
if let Some(target) = ev.target() { if let Some(target) = ev.target() {
@ -144,9 +111,9 @@ pub fn ContextMenu(
} }
}); });
let window = web_sys::window().unwrap();
let _ = window let _ = window
.add_event_listener_with_callback("mousedown", handler.as_ref().unchecked_ref()); .add_event_listener_with_callback("mousedown", handler.as_ref().unchecked_ref());
*mousedown_closure_clone.borrow_mut() = Some(handler);
// Escape key handler // Escape key handler
let on_close_esc = on_close.clone(); let on_close_esc = on_close.clone();
@ -162,7 +129,10 @@ pub fn ContextMenu(
"keydown", "keydown",
keydown_handler.as_ref().unchecked_ref(), keydown_handler.as_ref().unchecked_ref(),
); );
*keydown_closure_clone.borrow_mut() = Some(keydown_handler);
// Store handlers to clean up (they get cleaned up when Effect reruns)
handler.forget();
keydown_handler.forget();
}); });
} }
@ -175,17 +145,6 @@ pub fn ContextMenu(
role="menu" role="menu"
aria-label="Context menu" aria-label="Context menu"
> >
// Header with username and divider
{move || {
header.and_then(|h| h.get()).map(|header_text| {
view! {
<div class="px-4 py-2 text-center">
<div class="text-sm font-semibold text-white">{header_text}</div>
</div>
<hr class="border-gray-600 mx-2" />
}
})
}}
<For <For
each=move || items.get() each=move || items.get()
key=|item| item.action.clone() key=|item| item.action.clone()

View file

@ -8,7 +8,7 @@ use chattyness_db::models::{InventoryItem, PublicProp};
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
use chattyness_db::ws_messages::ClientMessage; use chattyness_db::ws_messages::ClientMessage;
use super::modals::{GuestLockedOverlay, Modal}; use super::modals::Modal;
use super::tabs::{Tab, TabBar}; use super::tabs::{Tab, TabBar};
use super::ws_client::WsSender; use super::ws_client::WsSender;
@ -24,18 +24,13 @@ use super::ws_client::WsSender;
/// - `on_close`: Callback when popup should close /// - `on_close`: Callback when popup should close
/// - `ws_sender`: WebSocket sender for dropping props /// - `ws_sender`: WebSocket sender for dropping props
/// - `realm_slug`: Current realm slug for fetching realm props /// - `realm_slug`: Current realm slug for fetching realm props
/// - `is_guest`: Whether the current user is a guest (shows locked overlay)
#[component] #[component]
pub fn InventoryPopup( pub fn InventoryPopup(
#[prop(into)] open: Signal<bool>, #[prop(into)] open: Signal<bool>,
on_close: Callback<()>, on_close: Callback<()>,
ws_sender: StoredValue<Option<WsSender>, LocalStorage>, ws_sender: StoredValue<Option<WsSender>, LocalStorage>,
#[prop(into)] realm_slug: Signal<String>, #[prop(into)] realm_slug: Signal<String>,
/// Whether the current user is a guest. Guests see a locked overlay.
#[prop(optional, into)]
is_guest: Option<Signal<bool>>,
) -> impl IntoView { ) -> impl IntoView {
let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false));
// Tab state // Tab state
let (active_tab, set_active_tab) = signal("my_inventory"); let (active_tab, set_active_tab) = signal("my_inventory");
@ -243,59 +238,52 @@ pub fn InventoryPopup(
max_width="max-w-2xl" max_width="max-w-2xl"
class="max-h-[80vh] flex flex-col" class="max-h-[80vh] flex flex-col"
> >
<div class="relative flex-1 flex flex-col"> // Tab bar
// Tab bar <TabBar
<TabBar tabs=vec![
tabs=vec![ Tab::new("my_inventory", "My Inventory"),
Tab::new("my_inventory", "My Inventory"), Tab::new("server", "Server"),
Tab::new("server", "Server"), Tab::new("realm", "Realm"),
Tab::new("realm", "Realm"), ]
] active=Signal::derive(move || active_tab.get())
active=Signal::derive(move || active_tab.get()) on_select=Callback::new(move |id| set_active_tab.set(id))
on_select=Callback::new(move |id| set_active_tab.set(id)) />
/>
// Tab content // Tab content
<div class="flex-1 overflow-y-auto min-h-[300px]"> <div class="flex-1 overflow-y-auto min-h-[300px]">
// My Inventory tab // My Inventory tab
<Show when=move || active_tab.get() == "my_inventory"> <Show when=move || active_tab.get() == "my_inventory">
<MyInventoryTab <MyInventoryTab
items=items items=items
loading=loading loading=loading
error=error error=error
selected_item=selected_item selected_item=selected_item
set_selected_item=set_selected_item set_selected_item=set_selected_item
dropping=dropping dropping=dropping
on_drop=Callback::new(handle_drop) on_drop=Callback::new(handle_drop)
/> />
</Show> </Show>
// Server tab // Server tab
<Show when=move || active_tab.get() == "server"> <Show when=move || active_tab.get() == "server">
<PublicPropsTab <PublicPropsTab
props=server_props props=server_props
loading=server_loading loading=server_loading
error=server_error error=server_error
tab_name="Server" tab_name="Server"
empty_message="No public server props available" empty_message="No public server props available"
/> />
</Show> </Show>
// Realm tab // Realm tab
<Show when=move || active_tab.get() == "realm"> <Show when=move || active_tab.get() == "realm">
<PublicPropsTab <PublicPropsTab
props=realm_props props=realm_props
loading=realm_loading loading=realm_loading
error=realm_error error=realm_error
tab_name="Realm" tab_name="Realm"
empty_message="No public realm props available" empty_message="No public realm props available"
/> />
</Show>
</div>
// Guest locked overlay
<Show when=move || is_guest.get()>
<GuestLockedOverlay />
</Show> </Show>
</div> </div>
</Modal> </Modal>

View file

@ -42,14 +42,14 @@ pub fn RealmHeader(
realm_description: Option<String>, realm_description: Option<String>,
scene_name: String, scene_name: String,
scene_description: Option<String>, scene_description: Option<String>,
online_count: Signal<i32>, online_count: i32,
total_members: i32, total_members: i32,
max_capacity: i32, max_capacity: i32,
can_admin: bool, can_admin: bool,
on_logout: Callback<()>, on_logout: Callback<()>,
) -> impl IntoView { ) -> impl IntoView {
let stats_tooltip = format!("Members: {} / Max: {}", total_members, max_capacity); let stats_tooltip = format!("Members: {} / Max: {}", total_members, max_capacity);
let online_text = move || format!("{} ONLINE", online_count.get()); let online_text = format!("{} ONLINE", online_count);
let admin_url = format!("/admin/realms/{}", realm_slug); let admin_url = format!("/admin/realms/{}", realm_slug);
view! { view! {

View file

@ -314,45 +314,3 @@ pub fn ConfirmModal(
</Show> </Show>
} }
} }
// ============================================================================
// Guest Locked Overlay
// ============================================================================
/// Overlay displayed when a feature is restricted to registered users.
///
/// Shows a semi-transparent backdrop with a padlock icon and diagonal
/// "Registered Users" text. Designed to be placed inside a modal container
/// with `position: relative`.
///
/// # Example
///
/// ```ignore
/// <div class="relative">
/// // Modal content here
/// <GuestLockedOverlay />
/// </div>
/// ```
#[component]
pub fn GuestLockedOverlay() -> impl IntoView {
view! {
<div
class="absolute inset-0 z-50 flex items-center justify-center bg-gray-900/80 backdrop-blur-sm rounded-lg"
role="alert"
aria-label="This feature is restricted to registered users"
>
<div class="flex flex-col items-center gap-4 transform -rotate-12">
<img
src="/icons/padlock.svg"
alt=""
class="w-16 h-16 text-gray-400"
style="filter: invert(60%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(90%) contrast(90%);"
aria-hidden="true"
/>
<span class="text-2xl font-bold text-gray-400 tracking-wider uppercase whitespace-nowrap">
"Registered Users"
</span>
</div>
</div>
}
}

View file

@ -1,399 +0,0 @@
//! Reconnection overlay component.
//!
//! Displays a full-screen overlay when WebSocket connection is lost,
//! with countdown timer and automatic retry logic.
use leptos::prelude::*;
use super::ws_client::WsState;
/// Reconnection attempt phase.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ReconnectionPhase {
/// Initial phase: 5 attempts with 5-second countdown each.
Initial { attempt: u8 },
/// Extended phase: 10 attempts with 10-second countdown each.
Extended { attempt: u8 },
/// All attempts exhausted.
Failed,
}
impl ReconnectionPhase {
/// Get the countdown duration in seconds for the current phase.
pub fn countdown_duration(&self) -> u32 {
match self {
Self::Initial { .. } => 5,
Self::Extended { .. } => 10,
Self::Failed => 0,
}
}
/// Get the maximum attempts for the current phase.
pub fn max_attempts(&self) -> u8 {
match self {
Self::Initial { .. } => 5,
Self::Extended { .. } => 10,
Self::Failed => 0,
}
}
/// Advance to the next attempt or phase.
pub fn next(self) -> Self {
match self {
Self::Initial { attempt } if attempt < 5 => Self::Initial { attempt: attempt + 1 },
Self::Initial { .. } => Self::Extended { attempt: 1 },
Self::Extended { attempt } if attempt < 10 => Self::Extended { attempt: attempt + 1 },
Self::Extended { .. } | Self::Failed => Self::Failed,
}
}
/// Get the current attempt number.
pub fn attempt(&self) -> u8 {
match self {
Self::Initial { attempt } | Self::Extended { attempt } => *attempt,
Self::Failed => 0,
}
}
/// Check if this is the initial phase.
pub fn is_initial(&self) -> bool {
matches!(self, Self::Initial { .. })
}
}
/// Internal state for the reconnection overlay.
#[derive(Clone, Copy, Debug)]
enum OverlayState {
/// Hidden (connected).
Hidden,
/// Showing countdown.
Countdown {
phase: ReconnectionPhase,
remaining: u32,
},
/// Currently attempting to reconnect.
Reconnecting { phase: ReconnectionPhase },
/// All attempts failed.
Failed,
}
/// Reconnection overlay component.
///
/// Shows a full-screen overlay when WebSocket connection is lost,
/// with countdown timer and automatic retry logic.
#[component]
pub fn ReconnectionOverlay(
/// WebSocket connection state to monitor.
ws_state: Signal<WsState>,
/// Callback to trigger a reconnection attempt.
on_reconnect: Callback<()>,
) -> impl IntoView {
// Internal overlay state
let (overlay_state, set_overlay_state) = signal(OverlayState::Hidden);
// Timer handle stored for cleanup
#[cfg(feature = "hydrate")]
let timer_handle: std::rc::Rc<std::cell::RefCell<Option<gloo_timers::callback::Interval>>> =
std::rc::Rc::new(std::cell::RefCell::new(None));
// Watch for WebSocket state changes
#[cfg(feature = "hydrate")]
{
let timer_handle = timer_handle.clone();
Effect::new(move |_| {
let state = ws_state.get();
match state {
WsState::Connected => {
// Connection restored - hide overlay and stop timer
if let Some(timer) = timer_handle.borrow_mut().take() {
drop(timer);
}
set_overlay_state.set(OverlayState::Hidden);
}
WsState::SilentReconnecting(_) => {
// Silent reconnection in progress - keep overlay hidden
// The ws_client handles the reconnection attempts internally
if let Some(timer) = timer_handle.borrow_mut().take() {
drop(timer);
}
set_overlay_state.set(OverlayState::Hidden);
}
WsState::Disconnected | WsState::Error => {
// Check current state - only start countdown if we're hidden
let current = overlay_state.get_untracked();
if matches!(current, OverlayState::Hidden) {
// Start initial countdown
let phase = ReconnectionPhase::Initial { attempt: 1 };
let duration = phase.countdown_duration();
set_overlay_state.set(OverlayState::Countdown {
phase,
remaining: duration,
});
// Start timer
start_countdown_timer(
timer_handle.clone(),
set_overlay_state,
on_reconnect.clone(),
);
} else if matches!(current, OverlayState::Reconnecting { .. }) {
// Reconnection attempt failed - advance to next attempt
if let OverlayState::Reconnecting { phase } = current {
let next_phase = phase.next();
if matches!(next_phase, ReconnectionPhase::Failed) {
set_overlay_state.set(OverlayState::Failed);
} else {
let duration = next_phase.countdown_duration();
set_overlay_state.set(OverlayState::Countdown {
phase: next_phase,
remaining: duration,
});
// Restart timer for next countdown
start_countdown_timer(
timer_handle.clone(),
set_overlay_state,
on_reconnect.clone(),
);
}
}
}
}
WsState::Connecting => {
// Currently attempting to connect - keep current state
// The reconnecting state should already be set
}
}
});
}
// Render based on state
move || {
let state = overlay_state.get();
match state {
OverlayState::Hidden => None,
OverlayState::Countdown { phase, remaining } => {
let (phase_text, attempt_text) = match phase {
ReconnectionPhase::Initial { attempt } => {
("Attempt", format!("{} of 5", attempt))
}
ReconnectionPhase::Extended { attempt } => {
("Extended attempt", format!("{} of 10", attempt))
}
ReconnectionPhase::Failed => ("", String::new()),
};
Some(
view! {
<div
class="fixed inset-0 z-[100] flex items-center justify-center"
role="alertdialog"
aria-modal="true"
aria-labelledby="reconnect-title"
>
// Darkened backdrop
<div
class="absolute inset-0 bg-black/80 backdrop-blur-md"
aria-hidden="true"
></div>
// Dialog box
<div class="relative bg-gray-800 rounded-lg shadow-2xl max-w-md w-full mx-4 p-8 border border-gray-700 text-center">
// Countdown circle
<div class="w-20 h-20 mx-auto rounded-full bg-yellow-900/30 flex items-center justify-center mb-6">
<span class="text-4xl font-bold text-yellow-300">
{remaining}
</span>
</div>
<h2
id="reconnect-title"
class="text-xl font-semibold text-white mb-2"
>
"Lost connection..."
</h2>
<p class="text-gray-300 mb-4">
{format!("attempting to reconnect in {} seconds", remaining)}
</p>
<p class="text-sm text-gray-400">
{format!("{} {}", phase_text, attempt_text)}
</p>
</div>
</div>
}
.into_any(),
)
}
OverlayState::Reconnecting { phase } => {
let (phase_text, attempt_text) = match phase {
ReconnectionPhase::Initial { attempt } => {
("Attempt", format!("{} of 5", attempt))
}
ReconnectionPhase::Extended { attempt } => {
("Extended attempt", format!("{} of 10", attempt))
}
ReconnectionPhase::Failed => ("", String::new()),
};
Some(
view! {
<div
class="fixed inset-0 z-[100] flex items-center justify-center"
role="alertdialog"
aria-modal="true"
aria-labelledby="reconnect-title"
>
// Darkened backdrop
<div
class="absolute inset-0 bg-black/80 backdrop-blur-md"
aria-hidden="true"
></div>
// Dialog box
<div class="relative bg-gray-800 rounded-lg shadow-2xl max-w-md w-full mx-4 p-8 border border-gray-700 text-center">
// Spinner
<div class="w-20 h-20 mx-auto rounded-full bg-blue-900/30 flex items-center justify-center mb-6 reconnect-spinner">
<svg
class="w-10 h-10 text-blue-400 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</div>
<h2
id="reconnect-title"
class="text-xl font-semibold text-white mb-2"
>
"Reconnecting..."
</h2>
<p class="text-gray-300 mb-4">"Attempting to restore connection"</p>
<p class="text-sm text-gray-400">
{format!("{} {}", phase_text, attempt_text)}
</p>
</div>
</div>
}
.into_any(),
)
}
OverlayState::Failed => Some(
view! {
<div
class="fixed inset-0 z-[100] flex items-center justify-center"
role="alertdialog"
aria-modal="true"
aria-labelledby="reconnect-title"
>
// Darkened backdrop
<div
class="absolute inset-0 bg-black/80 backdrop-blur-md"
aria-hidden="true"
></div>
// Dialog box
<div class="relative bg-gray-800 rounded-lg shadow-2xl max-w-md w-full mx-4 p-8 border border-gray-700 text-center">
// Error icon
<div class="w-20 h-20 mx-auto rounded-full bg-red-900/30 flex items-center justify-center mb-6">
<svg
class="w-10 h-10 text-red-400"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</div>
<h2
id="reconnect-title"
class="text-xl font-semibold text-white mb-2"
>
"Connection Failed"
</h2>
<p class="text-gray-300 mb-6">
"Unable to reconnect after multiple attempts. Please check your network connection and try again."
</p>
<button
type="button"
class="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-800 transition-colors duration-200"
on:click=move |_| {
#[cfg(feature = "hydrate")]
{
if let Some(window) = web_sys::window() {
let _ = window.location().reload();
}
}
}
>
"Refresh Page"
</button>
</div>
</div>
}
.into_any(),
),
}
}
}
/// Start the countdown timer.
#[cfg(feature = "hydrate")]
fn start_countdown_timer(
timer_handle: std::rc::Rc<std::cell::RefCell<Option<gloo_timers::callback::Interval>>>,
set_overlay_state: WriteSignal<OverlayState>,
on_reconnect: Callback<()>,
) {
use gloo_timers::callback::Interval;
// Stop any existing timer
if let Some(old_timer) = timer_handle.borrow_mut().take() {
drop(old_timer);
}
// Create new timer that ticks every second
let timer = Interval::new(1000, move || {
set_overlay_state.update(|state| {
if let OverlayState::Countdown { phase, remaining } = state {
if *remaining > 1 {
// Decrement countdown
*remaining -= 1;
} else {
// Countdown reached zero - trigger reconnection
*state = OverlayState::Reconnecting { phase: *phase };
on_reconnect.run(());
}
}
});
});
*timer_handle.borrow_mut() = Some(timer);
}

View file

@ -1,127 +0,0 @@
//! Scene list popup component for teleport command.
//!
//! Shows available scenes in a realm for teleportation.
use leptos::prelude::*;
use chattyness_db::models::SceneSummary;
/// Scene list popup component.
///
/// Shows scenes in a list with keyboard navigation for teleport selection.
///
/// Props:
/// - `scenes`: List of available scenes
/// - `on_select`: Callback when a scene is selected (receives SceneSummary)
/// - `on_close`: Callback when popup should close
/// - `scene_filter`: Signal containing the current filter text
/// - `selected_idx`: Signal containing the currently selected index
#[component]
pub fn SceneListPopup(
scenes: Signal<Vec<SceneSummary>>,
on_select: Callback<SceneSummary>,
#[prop(into)] on_close: Callback<()>,
#[prop(into)] scene_filter: Signal<String>,
#[prop(into)] selected_idx: Signal<usize>,
) -> impl IntoView {
let _ = on_close; // Suppress unused warning
// Get list of scenes, filtered by search text
let filtered_scenes = move || {
let filter_text = scene_filter.get().to_lowercase();
scenes
.get()
.into_iter()
.filter(|s| {
filter_text.is_empty()
|| s.name.to_lowercase().contains(&filter_text)
|| s.slug.to_lowercase().contains(&filter_text)
})
.collect::<Vec<_>>()
};
let filter_display = move || {
let f = scene_filter.get();
if f.is_empty() {
"Type to filter...".to_string()
} else {
format!("Filter: {}", f)
}
};
// Indexed scenes for selection tracking
let indexed_scenes = move || {
filtered_scenes()
.into_iter()
.enumerate()
.collect::<Vec<_>>()
};
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 scenes"
>
<div class="flex justify-between items-center text-xs mb-2 px-1">
<span class="text-gray-400">"Select a scene to teleport to:"</span>
<span class="text-blue-400 italic">{filter_display}</span>
</div>
<div class="flex flex-col gap-1 max-h-64 overflow-y-auto">
{move || {
indexed_scenes()
.into_iter()
.map(|(idx, scene)| {
let on_select = on_select.clone();
let scene_for_click = scene.clone();
let scene_name = scene.name.clone();
let scene_slug = scene.slug.clone();
let is_selected = move || selected_idx.get() == idx;
view! {
<button
type="button"
class=move || {
if is_selected() {
"flex flex-col gap-0.5 p-2 rounded bg-blue-600 text-left w-full"
} else {
"flex flex-col gap-0.5 p-2 rounded hover:bg-gray-700 transition-colors text-left w-full"
}
}
on:click=move |_| on_select.run(scene_for_click.clone())
role="option"
aria-selected=is_selected
>
<span class="text-white text-sm font-medium">
{scene_name}
</span>
<span class="text-gray-400 text-xs">
"/teleport "{scene_slug}
</span>
</button>
}
})
.collect_view()
}}
</div>
<Show when=move || filtered_scenes().is_empty()>
<div class="text-gray-500 text-sm text-center py-4">
{move || {
if scene_filter.get().is_empty() {
"No scenes available"
} else {
"No matching scenes"
}
}}
</div>
</Show>
<div class="mt-2 pt-2 border-t border-gray-700 text-xs text-gray-500">
<kbd class="px-1 py-0.5 bg-gray-700 rounded font-mono">"^_"</kbd>
" navigate "
<kbd class="px-1 py-0.5 bg-gray-700 rounded font-mono">"Enter"</kbd>
" select "
<kbd class="px-1 py-0.5 bg-gray-700 rounded font-mono">"Esc"</kbd>
" cancel"
</div>
</div>
}
}

View file

@ -56,14 +56,10 @@ pub fn RealmSceneViewer(
/// Current user's guest_session_id (for context menu filtering). /// Current user's guest_session_id (for context menu filtering).
#[prop(optional, into)] #[prop(optional, into)]
current_guest_session_id: Option<Signal<Option<Uuid>>>, current_guest_session_id: Option<Signal<Option<Uuid>>>,
/// Whether the current user is a guest (guests cannot use context menu).
#[prop(optional, into)]
is_guest: Option<Signal<bool>>,
/// Callback when whisper is requested on a member. /// Callback when whisper is requested on a member.
#[prop(optional, into)] #[prop(optional, into)]
on_whisper_request: Option<Callback<String>>, on_whisper_request: Option<Callback<String>>,
) -> impl IntoView { ) -> impl IntoView {
let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false));
// Use default settings if none provided // Use default settings if none provided
let settings = settings.unwrap_or_else(|| Signal::derive(ViewerSettings::default)); let settings = settings.unwrap_or_else(|| Signal::derive(ViewerSettings::default));
let dimensions = parse_bounds_dimensions(&scene.bounds_wkt); let dimensions = parse_bounds_dimensions(&scene.bounds_wkt);
@ -187,19 +183,14 @@ pub fn RealmSceneViewer(
move |ev: web_sys::MouseEvent| { move |ev: web_sys::MouseEvent| {
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
// Guests cannot message other users - don't show context menu // Get click position
if is_guest.get() { let client_x = ev.client_x() as f64;
return; let client_y = ev.client_y() as f64;
}
// Get current user identity for filtering // Get current user identity for filtering
let my_user_id = current_user_id.map(|s| s.get()).flatten(); let my_user_id = current_user_id.map(|s| s.get()).flatten();
let my_guest_session_id = current_guest_session_id.map(|s| s.get()).flatten(); let my_guest_session_id = current_guest_session_id.map(|s| s.get()).flatten();
// Get click position
let client_x = ev.client_x() as f64;
let client_y = ev.client_y() as f64;
// Query all avatar canvases and check for hit // Query all avatar canvases and check for hit
let document = web_sys::window().unwrap().document().unwrap(); let document = web_sys::window().unwrap().document().unwrap();
@ -1042,7 +1033,6 @@ pub fn RealmSceneViewer(
<ContextMenu <ContextMenu
open=Signal::derive(move || context_menu_open.get()) open=Signal::derive(move || context_menu_open.get())
position=Signal::derive(move || context_menu_position.get()) position=Signal::derive(move || context_menu_position.get())
header=Signal::derive(move || context_menu_target.get())
items=Signal::derive(move || { items=Signal::derive(move || {
vec![ vec![
ContextMenuItem { ContextMenuItem {

View file

@ -9,21 +9,18 @@ use leptos::reactive::owner::LocalStorage;
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
use chattyness_db::models::EmotionState; use chattyness_db::models::EmotionState;
use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp}; use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp};
use chattyness_db::ws_messages::{close_codes, ClientMessage}; use chattyness_db::ws_messages::ClientMessage;
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
use chattyness_db::ws_messages::{DisconnectReason, ServerMessage}; use chattyness_db::ws_messages::{DisconnectReason, ServerMessage};
use super::chat_types::ChatMessage; use super::chat_types::ChatMessage;
/// Close code for scene change (must match server constant).
pub const SCENE_CHANGE_CLOSE_CODE: u16 = 4000;
/// Duration for fade-out animation in milliseconds. /// Duration for fade-out animation in milliseconds.
pub const FADE_DURATION_MS: i64 = 5000; pub const FADE_DURATION_MS: i64 = 5000;
/// Maximum number of silent reconnection attempts before showing overlay.
pub const MAX_SILENT_RECONNECT_ATTEMPTS: u8 = 3;
/// Delay between silent reconnection attempts in milliseconds.
pub const SILENT_RECONNECT_DELAY_MS: u32 = 1000;
/// A member that is currently fading out after a timeout disconnect. /// A member that is currently fading out after a timeout disconnect.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct FadingMember { pub struct FadingMember {
@ -46,9 +43,6 @@ pub enum WsState {
Disconnected, Disconnected,
/// Connection error occurred. /// Connection error occurred.
Error, Error,
/// Silently attempting to reconnect after server timeout.
/// The u8 is the current attempt number (1-based).
SilentReconnecting(u8),
} }
/// Sender function type for WebSocket messages. /// Sender function type for WebSocket messages.
@ -66,8 +60,6 @@ pub struct ChannelMemberInfo {
pub guest_session_id: Option<uuid::Uuid>, pub guest_session_id: Option<uuid::Uuid>,
/// The user's display name. /// The user's display name.
pub display_name: String, pub display_name: String,
/// Whether this user is a guest (has the 'guest' tag).
pub is_guest: bool,
} }
/// WebSocket error info for UI display. /// WebSocket error info for UI display.
@ -79,15 +71,6 @@ pub struct WsError {
pub message: String, pub message: String,
} }
/// Teleport information received from server.
#[derive(Clone, Debug)]
pub struct TeleportInfo {
/// Scene ID to teleport to.
pub scene_id: uuid::Uuid,
/// Scene slug for URL.
pub scene_slug: String,
}
/// Hook to manage WebSocket connection for a channel. /// Hook to manage WebSocket connection for a channel.
/// ///
/// Returns a tuple of: /// Returns a tuple of:
@ -97,7 +80,6 @@ pub struct TeleportInfo {
pub fn use_channel_websocket( 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>>,
reconnect_trigger: RwSignal<u32>,
on_members_update: Callback<Vec<ChannelMemberWithAvatar>>, on_members_update: Callback<Vec<ChannelMemberWithAvatar>>,
on_chat_message: Callback<ChatMessage>, on_chat_message: Callback<ChatMessage>,
on_loose_props_sync: Callback<Vec<LooseProp>>, on_loose_props_sync: Callback<Vec<LooseProp>>,
@ -106,7 +88,6 @@ pub fn use_channel_websocket(
on_member_fading: Callback<FadingMember>, on_member_fading: Callback<FadingMember>,
on_welcome: Option<Callback<ChannelMemberInfo>>, on_welcome: Option<Callback<ChannelMemberInfo>>,
on_error: Option<Callback<WsError>>, on_error: Option<Callback<WsError>>,
on_teleport_approved: Option<Callback<TeleportInfo>>,
) -> (Signal<WsState>, WsSenderStorage) { ) -> (Signal<WsState>, WsSenderStorage) {
use std::cell::RefCell; use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
@ -116,11 +97,6 @@ pub fn use_channel_websocket(
let (ws_state, set_ws_state) = signal(WsState::Disconnected); let (ws_state, set_ws_state) = signal(WsState::Disconnected);
let ws_ref: Rc<RefCell<Option<WebSocket>>> = Rc::new(RefCell::new(None)); let ws_ref: Rc<RefCell<Option<WebSocket>>> = Rc::new(RefCell::new(None));
let members: Rc<RefCell<Vec<ChannelMemberWithAvatar>>> = Rc::new(RefCell::new(Vec::new())); let members: Rc<RefCell<Vec<ChannelMemberWithAvatar>>> = Rc::new(RefCell::new(Vec::new()));
// Track current user's ID to ignore self MemberLeft during reconnection
let current_user_id: Rc<RefCell<Option<uuid::Uuid>>> = Rc::new(RefCell::new(None));
// Flag to track intentional closes (teleport, scene change) - guarantees local state
// even if close code doesn't arrive correctly due to browser/server quirks
let is_intentional_close: Rc<RefCell<bool>> = Rc::new(RefCell::new(false));
// Create a stored sender function (using new_local for WASM single-threaded environment) // Create a stored sender function (using new_local for WASM single-threaded environment)
let ws_ref_for_send = ws_ref.clone(); let ws_ref_for_send = ws_ref.clone();
@ -140,24 +116,14 @@ pub fn use_channel_websocket(
// Effect to manage WebSocket lifecycle // Effect to manage WebSocket lifecycle
let ws_ref_clone = ws_ref.clone(); let ws_ref_clone = ws_ref.clone();
let members_clone = members.clone(); let members_clone = members.clone();
let is_intentional_close_for_cleanup = is_intentional_close.clone();
Effect::new(move |_| { Effect::new(move |_| {
let slug = realm_slug.get(); let slug = realm_slug.get();
let ch_id = channel_id.get(); let ch_id = channel_id.get();
// Track reconnect_trigger to force reconnection when it changes
let _trigger = reconnect_trigger.get();
// Cleanup previous connection // Cleanup previous connection
if let Some(old_ws) = ws_ref_clone.borrow_mut().take() { if let Some(old_ws) = ws_ref_clone.borrow_mut().take() {
#[cfg(debug_assertions)] let _ = old_ws.close();
web_sys::console::log_1(
&format!("[WS] Closing old connection, readyState={}", old_ws.ready_state()).into(),
);
// Set flag BEFORE closing - guarantees local state even if close code doesn't arrive
*is_intentional_close_for_cleanup.borrow_mut() = true;
// Close with SCENE_CHANGE code so onclose handler knows this was intentional
let _ = old_ws.close_with_code_and_reason(close_codes::SCENE_CHANGE, "scene change");
} }
let Some(ch_id) = ch_id else { let Some(ch_id) = ch_id else {
@ -219,13 +185,10 @@ pub fn use_channel_websocket(
let on_member_fading_clone = on_member_fading.clone(); let on_member_fading_clone = on_member_fading.clone();
let on_welcome_clone = on_welcome.clone(); let on_welcome_clone = on_welcome.clone();
let on_error_clone = on_error.clone(); let on_error_clone = on_error.clone();
let on_teleport_approved_clone = on_teleport_approved.clone();
// For starting heartbeat on Welcome // For starting heartbeat on Welcome
let ws_ref_for_heartbeat = ws_ref.clone(); let ws_ref_for_heartbeat = ws_ref.clone();
let heartbeat_started: Rc<RefCell<bool>> = Rc::new(RefCell::new(false)); let heartbeat_started: Rc<RefCell<bool>> = Rc::new(RefCell::new(false));
let heartbeat_started_clone = heartbeat_started.clone(); let heartbeat_started_clone = heartbeat_started.clone();
// For tracking current user ID to ignore self MemberLeft during reconnection
let current_user_id_for_msg = current_user_id.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();
@ -240,9 +203,6 @@ pub fn use_channel_websocket(
.. ..
} = msg } = msg
{ {
// Track current user ID for MemberLeft filtering
*current_user_id_for_msg.borrow_mut() = member.user_id;
if !*heartbeat_started_clone.borrow() { if !*heartbeat_started_clone.borrow() {
*heartbeat_started_clone.borrow_mut() = true; *heartbeat_started_clone.borrow_mut() = true;
let ping_interval_ms = config.ping_interval_secs * 1000; let ping_interval_ms = config.ping_interval_secs * 1000;
@ -277,7 +237,6 @@ pub fn use_channel_websocket(
user_id: member.user_id, user_id: member.user_id,
guest_session_id: member.guest_session_id, guest_session_id: member.guest_session_id,
display_name: member.display_name.clone(), display_name: member.display_name.clone(),
is_guest: member.is_guest,
}; };
callback.run(info); callback.run(info);
} }
@ -292,8 +251,6 @@ pub fn use_channel_websocket(
&on_prop_picked_up_clone, &on_prop_picked_up_clone,
&on_member_fading_clone, &on_member_fading_clone,
&on_error_clone, &on_error_clone,
&on_teleport_approved_clone,
&current_user_id_for_msg,
); );
} }
} }
@ -303,82 +260,22 @@ pub fn use_channel_websocket(
// onerror // onerror
let set_ws_state_err = set_ws_state; let set_ws_state_err = set_ws_state;
let ws_state_for_err = ws_state;
let reconnect_trigger_for_error = reconnect_trigger;
let onerror = Closure::wrap(Box::new(move |e: ErrorEvent| { let onerror = Closure::wrap(Box::new(move |e: ErrorEvent| {
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
web_sys::console::error_1(&format!("[WS] Error: {:?}", e.message()).into()); web_sys::console::error_1(&format!("[WS] Error: {:?}", e.message()).into());
set_ws_state_err.set(WsState::Error);
// Check if we're in silent reconnection mode
let current_state = ws_state_for_err.get_untracked();
if let WsState::SilentReconnecting(attempt) = current_state {
if attempt < MAX_SILENT_RECONNECT_ATTEMPTS {
// Try another silent reconnection
let next_attempt = attempt + 1;
#[cfg(debug_assertions)]
web_sys::console::log_1(
&format!(
"[WS] Silent reconnection attempt {} failed, trying attempt {}",
attempt, next_attempt
)
.into(),
);
set_ws_state_err.set(WsState::SilentReconnecting(next_attempt));
// Schedule next reconnection attempt
let reconnect_trigger = reconnect_trigger_for_error;
gloo_timers::callback::Timeout::new(SILENT_RECONNECT_DELAY_MS, move || {
reconnect_trigger.update(|v| *v = v.wrapping_add(1));
})
.forget();
} else {
// Max attempts reached, fall back to showing overlay
#[cfg(debug_assertions)]
web_sys::console::log_1(
&"[WS] Silent reconnection failed, showing reconnection overlay".into(),
);
set_ws_state_err.set(WsState::Error);
}
} else {
set_ws_state_err.set(WsState::Error);
}
}) as Box<dyn FnMut(ErrorEvent)>); }) as Box<dyn FnMut(ErrorEvent)>);
ws.set_onerror(Some(onerror.as_ref().unchecked_ref())); ws.set_onerror(Some(onerror.as_ref().unchecked_ref()));
onerror.forget(); onerror.forget();
// onclose // onclose
let set_ws_state_close = set_ws_state; let set_ws_state_close = set_ws_state;
let reconnect_trigger_for_close = reconnect_trigger;
let is_intentional_close_for_onclose = is_intentional_close.clone();
let onclose = Closure::wrap(Box::new(move |e: CloseEvent| { let onclose = Closure::wrap(Box::new(move |e: CloseEvent| {
let code = e.code();
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
web_sys::console::log_1( web_sys::console::log_1(
&format!("[WS] Closed: code={}, reason={}", code, e.reason()).into(), &format!("[WS] Closed: code={}, reason={}", e.code(), e.reason()).into(),
); );
set_ws_state_close.set(WsState::Disconnected);
// Handle based on close code with defense-in-depth using flag
if code == close_codes::SERVER_TIMEOUT {
// Server timeout - attempt silent reconnection (highest priority)
#[cfg(debug_assertions)]
web_sys::console::log_1(&"[WS] Server timeout, attempting silent reconnection".into());
set_ws_state_close.set(WsState::SilentReconnecting(1));
// Schedule reconnection after delay
let reconnect_trigger = reconnect_trigger_for_close;
gloo_timers::callback::Timeout::new(SILENT_RECONNECT_DELAY_MS, move || {
reconnect_trigger.update(|v| *v = v.wrapping_add(1));
})
.forget();
} else if code == close_codes::SCENE_CHANGE || *is_intentional_close_for_onclose.borrow() {
// Intentional close (scene change/teleport) - don't show disconnection
// Check both code AND flag for defense-in-depth (flag is guaranteed local state)
#[cfg(debug_assertions)]
web_sys::console::log_1(&"[WS] Intentional close, not setting Disconnected".into());
// Reset the flag for future connections
*is_intentional_close_for_onclose.borrow_mut() = false;
} else {
// Other close codes - treat as disconnection
set_ws_state_close.set(WsState::Disconnected);
}
}) as Box<dyn FnMut(CloseEvent)>); }) as Box<dyn FnMut(CloseEvent)>);
ws.set_onclose(Some(onclose.as_ref().unchecked_ref())); ws.set_onclose(Some(onclose.as_ref().unchecked_ref()));
onclose.forget(); onclose.forget();
@ -401,8 +298,6 @@ fn handle_server_message(
on_prop_picked_up: &Callback<uuid::Uuid>, on_prop_picked_up: &Callback<uuid::Uuid>,
on_member_fading: &Callback<FadingMember>, on_member_fading: &Callback<FadingMember>,
on_error: &Option<Callback<WsError>>, on_error: &Option<Callback<WsError>>,
on_teleport_approved: &Option<Callback<TeleportInfo>>,
current_user_id: &std::rc::Rc<std::cell::RefCell<Option<uuid::Uuid>>>,
) { ) {
let mut members_vec = members.borrow_mut(); let mut members_vec = members.borrow_mut();
@ -429,18 +324,6 @@ fn handle_server_message(
guest_session_id, guest_session_id,
reason, reason,
} => { } => {
// Check if this is our own MemberLeft due to timeout - ignore it during reconnection
// so we don't see our own avatar fade out
let own_user_id = *current_user_id.borrow();
let is_self = own_user_id.is_some() && user_id == own_user_id;
if is_self && reason == DisconnectReason::Timeout {
#[cfg(debug_assertions)]
web_sys::console::log_1(
&"[WS] Ignoring self MemberLeft during reconnection".into(),
);
return;
}
// Find the member before removing // Find the member before removing
let leaving_member = members_vec let leaving_member = members_vec
.iter() .iter()
@ -567,17 +450,6 @@ fn handle_server_message(
} }
on_update.run(members_vec.clone()); on_update.run(members_vec.clone());
} }
ServerMessage::TeleportApproved {
scene_id,
scene_slug,
} => {
if let Some(callback) = on_teleport_approved {
callback.run(TeleportInfo {
scene_id,
scene_slug,
});
}
}
} }
} }
@ -586,7 +458,6 @@ fn handle_server_message(
pub fn use_channel_websocket( 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>>,
_reconnect_trigger: RwSignal<u32>,
_on_members_update: Callback<Vec<ChannelMemberWithAvatar>>, _on_members_update: Callback<Vec<ChannelMemberWithAvatar>>,
_on_chat_message: Callback<ChatMessage>, _on_chat_message: Callback<ChatMessage>,
_on_loose_props_sync: Callback<Vec<LooseProp>>, _on_loose_props_sync: Callback<Vec<LooseProp>>,
@ -595,7 +466,6 @@ pub fn use_channel_websocket(
_on_member_fading: Callback<FadingMember>, _on_member_fading: Callback<FadingMember>,
_on_welcome: Option<Callback<ChannelMemberInfo>>, _on_welcome: Option<Callback<ChannelMemberInfo>>,
_on_error: Option<Callback<WsError>>, _on_error: Option<Callback<WsError>>,
_on_teleport_approved: Option<Callback<TeleportInfo>>,
) -> (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);

View file

@ -14,20 +14,20 @@ use uuid::Uuid;
use crate::components::{ use crate::components::{
ActiveBubble, AvatarEditorPopup, Card, ChatInput, ConversationModal, EmotionKeybindings, ActiveBubble, AvatarEditorPopup, Card, ChatInput, ConversationModal, EmotionKeybindings,
FadingMember, InventoryPopup, KeybindingsPopup, MessageLog, NotificationHistoryModal, FadingMember, InventoryPopup, KeybindingsPopup, MessageLog, NotificationHistoryModal,
NotificationMessage, NotificationToast, RealmHeader, RealmSceneViewer, ReconnectionOverlay, NotificationMessage, NotificationToast, RealmHeader, RealmSceneViewer, SettingsPopup,
SettingsPopup, ViewerSettings, ViewerSettings,
}; };
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
use crate::components::{ use crate::components::{
ChannelMemberInfo, ChatMessage, DEFAULT_BUBBLE_TIMEOUT_MS, FADE_DURATION_MS, HistoryEntry, ChannelMemberInfo, ChatMessage, DEFAULT_BUBBLE_TIMEOUT_MS, FADE_DURATION_MS, HistoryEntry,
TeleportInfo, WsError, add_to_history, use_channel_websocket, WsError, add_to_history, use_channel_websocket,
}; };
use crate::utils::LocalStoragePersist; use crate::utils::LocalStoragePersist;
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
use crate::utils::parse_bounds_dimensions; use crate::utils::parse_bounds_dimensions;
use chattyness_db::models::{ use chattyness_db::models::{
AvatarWithPaths, ChannelMemberWithAvatar, EmotionAvailability, LooseProp, RealmRole, AvatarWithPaths, ChannelMemberWithAvatar, EmotionAvailability, LooseProp, RealmRole,
RealmWithUserRole, Scene, SceneSummary, RealmWithUserRole, Scene,
}; };
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
use chattyness_db::ws_messages::ClientMessage; use chattyness_db::ws_messages::ClientMessage;
@ -44,9 +44,6 @@ pub fn RealmPage() -> impl IntoView {
let slug = Signal::derive(move || params.read().get("slug").unwrap_or_default()); let slug = Signal::derive(move || params.read().get("slug").unwrap_or_default());
// Scene slug from URL (for direct scene navigation)
let scene_slug_param = Signal::derive(move || params.read().get("scene_slug"));
// Channel member state // Channel member state
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);
@ -101,8 +98,6 @@ pub fn RealmPage() -> impl IntoView {
// Current user identity (received from WebSocket Welcome message) // Current user identity (received from WebSocket Welcome message)
let (current_user_id, set_current_user_id) = signal(Option::<Uuid>::None); let (current_user_id, set_current_user_id) = signal(Option::<Uuid>::None);
let (current_guest_session_id, set_current_guest_session_id) = signal(Option::<Uuid>::None); let (current_guest_session_id, set_current_guest_session_id) = signal(Option::<Uuid>::None);
// Whether the current user is a guest (has the 'guest' tag)
let (is_guest, set_is_guest) = signal(false);
// Whisper target - when set, triggers pre-fill in ChatInput // Whisper target - when set, triggers pre-fill in ChatInput
let (whisper_target, set_whisper_target) = signal(Option::<String>::None); let (whisper_target, set_whisper_target) = signal(Option::<String>::None);
@ -123,18 +118,6 @@ pub fn RealmPage() -> impl IntoView {
// Error notification state (for whisper failures, etc.) // Error notification state (for whisper failures, etc.)
let (error_message, set_error_message) = signal(Option::<String>::None); let (error_message, set_error_message) = signal(Option::<String>::None);
// Reconnection trigger - increment to force WebSocket reconnection
let reconnect_trigger = RwSignal::new(0u32);
// Current scene (changes when teleporting)
let (current_scene, set_current_scene) = signal(Option::<Scene>::None);
// Available scenes for teleportation (cached on load)
let (available_scenes, set_available_scenes) = signal(Vec::<SceneSummary>::new());
// Whether teleportation is allowed in this realm
let (allow_user_teleport, set_allow_user_teleport) = signal(false);
let realm_data = LocalResource::new(move || { let realm_data = LocalResource::new(move || {
let slug = slug.get(); let slug = slug.get();
async move { async move {
@ -327,7 +310,6 @@ pub fn RealmPage() -> impl IntoView {
set_current_user_id.set(info.user_id); set_current_user_id.set(info.user_id);
set_current_guest_session_id.set(info.guest_session_id); set_current_guest_session_id.set(info.guest_session_id);
set_current_display_name.set(info.display_name.clone()); set_current_display_name.set(info.display_name.clone());
set_is_guest.set(info.is_guest);
}); });
// Callback for WebSocket errors (whisper failures, etc.) // Callback for WebSocket errors (whisper failures, etc.)
@ -336,8 +318,6 @@ pub fn RealmPage() -> impl IntoView {
// Display user-friendly error message // Display user-friendly error message
let msg = match error.code.as_str() { let msg = match error.code.as_str() {
"WHISPER_TARGET_NOT_FOUND" => error.message, "WHISPER_TARGET_NOT_FOUND" => error.message,
"TELEPORT_DISABLED" => error.message,
"SCENE_NOT_FOUND" => error.message,
_ => format!("Error: {}", error.message), _ => format!("Error: {}", error.message),
}; };
set_error_message.set(Some(msg)); set_error_message.set(Some(msg));
@ -349,74 +329,10 @@ pub fn RealmPage() -> impl IntoView {
.forget(); .forget();
}); });
// Callback for teleport approval - navigate to new scene
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
let on_teleport_approved = Callback::new(move |info: TeleportInfo| { let (_ws_state, ws_sender) = use_channel_websocket(
let scene_id = info.scene_id;
let scene_slug = info.scene_slug.clone();
let realm_slug = slug.get_untracked();
// Fetch the new scene data to update the canvas background
let scene_slug_for_url = scene_slug.clone();
let realm_slug_for_url = realm_slug.clone();
spawn_local(async move {
use gloo_net::http::Request;
let response = Request::get(&format!(
"/api/realms/{}/scenes/{}",
realm_slug, scene_slug
))
.send()
.await;
if let Ok(resp) = response {
if resp.ok() {
if let Ok(scene) = resp.json::<Scene>().await {
// Update scene dimensions from the new scene
if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) {
set_scene_dimensions.set((w as f64, h as f64));
}
// Update URL to reflect new scene
if let Some(window) = web_sys::window() {
if let Ok(history) = window.history() {
let new_url = if scene.is_entry_point {
format!("/realms/{}", realm_slug_for_url)
} else {
format!(
"/realms/{}/scenes/{}",
realm_slug_for_url, scene_slug_for_url
)
};
let _ = history.replace_state_with_url(
&wasm_bindgen::JsValue::NULL,
"",
Some(&new_url),
);
}
}
// Update the current scene for the viewer
set_current_scene.set(Some(scene));
}
}
}
// Update channel_id to trigger WebSocket reconnection
set_channel_id.set(Some(scene_id));
// Clear members since we're switching scenes
set_members.set(Vec::new());
// Trigger a reconnect to ensure fresh connection
reconnect_trigger.update(|t| *t += 1);
});
});
#[cfg(feature = "hydrate")]
let (ws_state, ws_sender) = use_channel_websocket(
slug, slug,
Signal::derive(move || channel_id.get()), Signal::derive(move || channel_id.get()),
reconnect_trigger,
on_members_update, on_members_update,
on_chat_message, on_chat_message,
on_loose_props_sync, on_loose_props_sync,
@ -425,172 +341,23 @@ pub fn RealmPage() -> impl IntoView {
on_member_fading, on_member_fading,
Some(on_welcome), Some(on_welcome),
Some(on_ws_error), Some(on_ws_error),
Some(on_teleport_approved),
); );
// Set channel ID, current scene, and scene dimensions when entry scene loads // Set channel ID and scene dimensions when scene loads
// Note: Currently using scene.id as the channel_id since channel_members // Note: Currently using scene.id as the channel_id since channel_members
// uses scenes directly. Proper channel infrastructure can be added later. // uses scenes directly. Proper channel infrastructure can be added later.
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
{ {
// Track whether we've handled initial scene load to prevent double-loading
let initial_scene_handled = StoredValue::new_local(false);
Effect::new(move |_| { Effect::new(move |_| {
// Skip if already handled let Some(scene) = entry_scene.get().flatten() else {
if initial_scene_handled.get_value() {
return;
}
let url_scene_slug = scene_slug_param.get();
let has_url_scene = url_scene_slug
.as_ref()
.is_some_and(|s| !s.is_empty());
if has_url_scene {
// URL has a scene slug - wait for realm data to check if teleport is allowed
let Some(realm_with_role) = realm_data.get().flatten() else {
return;
};
let realm_slug_val = slug.get();
let scene_slug_val = url_scene_slug.unwrap();
if !realm_with_role.realm.allow_user_teleport {
// Teleport disabled - redirect to base realm URL and show error
initial_scene_handled.set_value(true);
set_error_message.set(Some(
"Direct scene access is disabled for this realm".to_string(),
));
// Redirect to base realm URL
let navigate = use_navigate();
navigate(
&format!("/realms/{}", realm_slug_val),
leptos_router::NavigateOptions {
replace: true,
..Default::default()
},
);
// Auto-dismiss error after 5 seconds
use gloo_timers::callback::Timeout;
Timeout::new(5000, move || {
set_error_message.set(None);
})
.forget();
// Fall back to entry scene
if let Some(scene) = entry_scene.get().flatten() {
set_channel_id.set(Some(scene.id));
set_current_scene.set(Some(scene.clone()));
if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) {
set_scene_dimensions.set((w as f64, h as f64));
}
}
return;
}
// Teleport allowed - fetch the specific scene
initial_scene_handled.set_value(true);
spawn_local(async move {
use gloo_net::http::Request;
let response = Request::get(&format!(
"/api/realms/{}/scenes/{}",
realm_slug_val, scene_slug_val
))
.send()
.await;
if let Ok(resp) = response {
if resp.ok() {
if let Ok(scene) = resp.json::<Scene>().await {
set_channel_id.set(Some(scene.id));
if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) {
set_scene_dimensions.set((w as f64, h as f64));
}
set_current_scene.set(Some(scene));
return;
}
}
}
// Scene not found - show error and fall back to entry scene
set_error_message.set(Some(format!(
"Scene '{}' not found",
scene_slug_val
)));
// Update URL to base realm URL
if let Some(window) = web_sys::window() {
if let Ok(history) = window.history() {
let _ = history.replace_state_with_url(
&wasm_bindgen::JsValue::NULL,
"",
Some(&format!("/realms/{}", realm_slug_val)),
);
}
}
// Auto-dismiss error after 5 seconds
use gloo_timers::callback::Timeout;
Timeout::new(5000, move || {
set_error_message.set(None);
})
.forget();
});
} else {
// No URL scene slug - use entry scene
let Some(scene) = entry_scene.get().flatten() else {
return;
};
initial_scene_handled.set_value(true);
set_channel_id.set(Some(scene.id));
set_current_scene.set(Some(scene.clone()));
// Extract scene dimensions from bounds_wkt
if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) {
set_scene_dimensions.set((w as f64, h as f64));
}
}
});
}
// Fetch available scenes and realm settings when realm loads
#[cfg(feature = "hydrate")]
{
Effect::new(move |_| {
let Some(realm_with_role) = realm_data.get().flatten() else {
return; return;
}; };
set_channel_id.set(Some(scene.id));
// Set allow_user_teleport from realm settings // Extract scene dimensions from bounds_wkt
set_allow_user_teleport.set(realm_with_role.realm.allow_user_teleport); if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) {
set_scene_dimensions.set((w as f64, h as f64));
// Fetch scenes list for teleport command
let current_slug = slug.get();
if current_slug.is_empty() {
return;
} }
spawn_local(async move {
use gloo_net::http::Request;
let response = Request::get(&format!("/api/realms/{}/scenes", current_slug))
.send()
.await;
if let Ok(resp) = response {
if resp.ok() {
if let Ok(scenes) = resp.json::<Vec<SceneSummary>>().await {
// Filter out hidden scenes
let visible_scenes: Vec<SceneSummary> = scenes
.into_iter()
.filter(|s| !s.is_hidden)
.collect();
set_available_scenes.set(visible_scenes);
}
}
}
});
}); });
} }
@ -919,8 +686,7 @@ pub fn RealmPage() -> impl IntoView {
let realm_name = realm.name.clone(); let realm_name = realm.name.clone();
let realm_slug_val = realm.slug.clone(); let realm_slug_val = realm.slug.clone();
let realm_description = realm.tagline.clone(); let realm_description = realm.tagline.clone();
// Derive online count reactively from members signal let online_count = realm.current_user_count;
let online_count = Signal::derive(move || members.get().len() as i32);
let total_members = realm.member_count; let total_members = realm.member_count;
let max_capacity = realm.max_users; let max_capacity = realm.max_users;
let scene_name = scene_info.0; let scene_name = scene_info.0;
@ -955,16 +721,11 @@ pub fn RealmPage() -> impl IntoView {
let realm_slug_for_viewer = realm_slug_val.clone(); let realm_slug_for_viewer = realm_slug_val.clone();
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
let ws_sender_clone = ws_sender.clone(); let ws_sender_clone = ws_sender.clone();
// Read current_scene in reactive context (before .map())
// so changes trigger re-render
let current_scene_val = current_scene.get();
entry_scene entry_scene
.get() .get()
.map(|maybe_scene| { .map(|maybe_scene| {
match maybe_scene { match maybe_scene {
Some(entry_scene_data) => { Some(scene) => {
// Use current_scene if set (after teleport), otherwise use entry scene
let display_scene = current_scene_val.clone().unwrap_or_else(|| entry_scene_data.clone());
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 emotion_avail_signal = Signal::derive(move || emotion_availability.get());
let skin_path_signal = Signal::derive(move || skin_preview_path.get()); let skin_path_signal = Signal::derive(move || skin_preview_path.get());
@ -986,22 +747,10 @@ pub fn RealmPage() -> impl IntoView {
let on_whisper_request_cb = Callback::new(move |target: String| { let on_whisper_request_cb = Callback::new(move |target: String| {
set_whisper_target.set(Some(target)); set_whisper_target.set(Some(target));
}); });
let scenes_signal = Signal::derive(move || available_scenes.get());
let teleport_enabled_signal = Signal::derive(move || allow_user_teleport.get());
#[cfg(feature = "hydrate")]
let ws_for_teleport = ws_sender_clone.clone();
let on_teleport_cb = Callback::new(move |scene_id: Uuid| {
#[cfg(feature = "hydrate")]
ws_for_teleport.with_value(|sender| {
if let Some(send_fn) = sender {
send_fn(ClientMessage::Teleport { scene_id });
}
});
});
view! { view! {
<div class="relative w-full"> <div class="relative w-full">
<RealmSceneViewer <RealmSceneViewer
scene=display_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 active_bubbles=active_bubbles_signal
@ -1018,7 +767,6 @@ pub fn RealmPage() -> impl IntoView {
fading_members=Signal::derive(move || fading_members.get()) fading_members=Signal::derive(move || fading_members.get())
current_user_id=Signal::derive(move || current_user_id.get()) current_user_id=Signal::derive(move || current_user_id.get())
current_guest_session_id=Signal::derive(move || current_guest_session_id.get()) current_guest_session_id=Signal::derive(move || current_guest_session_id.get())
is_guest=Signal::derive(move || is_guest.get())
on_whisper_request=on_whisper_request_cb on_whisper_request=on_whisper_request_cb
/> />
<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">
@ -1032,9 +780,6 @@ pub fn RealmPage() -> impl IntoView {
on_open_settings=on_open_settings_cb on_open_settings=on_open_settings_cb
on_open_inventory=on_open_inventory_cb on_open_inventory=on_open_inventory_cb
whisper_target=whisper_target_signal whisper_target=whisper_target_signal
scenes=scenes_signal
allow_user_teleport=teleport_enabled_signal
on_teleport=on_teleport_cb
/> />
</div> </div>
</div> </div>
@ -1073,7 +818,6 @@ pub fn RealmPage() -> impl IntoView {
}) })
ws_sender=ws_sender_for_inv ws_sender=ws_sender_for_inv
realm_slug=Signal::derive(move || slug.get()) realm_slug=Signal::derive(move || slug.get())
is_guest=Signal::derive(move || is_guest.get())
/> />
} }
} }
@ -1118,7 +862,6 @@ pub fn RealmPage() -> impl IntoView {
set_skin_preview_path.set(updated.skin_layer[4].clone()); set_skin_preview_path.set(updated.skin_layer[4].clone());
}) })
ws_sender=ws_sender_for_avatar ws_sender=ws_sender_for_avatar
is_guest=Signal::derive(move || is_guest.get())
/> />
} }
} }
@ -1210,22 +953,6 @@ pub fn RealmPage() -> impl IntoView {
/> />
} }
} }
// Reconnection overlay - shown when WebSocket disconnects
{
#[cfg(feature = "hydrate")]
let ws_state_for_overlay = ws_state;
#[cfg(not(feature = "hydrate"))]
let ws_state_for_overlay = Signal::derive(|| crate::components::ws_client::WsState::Disconnected);
view! {
<ReconnectionOverlay
ws_state=ws_state_for_overlay
on_reconnect=Callback::new(move |_: ()| {
reconnect_trigger.update(|t| *t += 1);
})
/>
}
}
} }
.into_any() .into_any()
} }

View file

@ -31,15 +31,6 @@ pub fn UserRoutes() -> impl IntoView {
<Route path=StaticSegment("home") view=HomePage /> <Route path=StaticSegment("home") view=HomePage />
<Route path=StaticSegment("password-reset") view=PasswordResetPage /> <Route path=StaticSegment("password-reset") view=PasswordResetPage />
<Route path=(StaticSegment("realms"), ParamSegment("slug")) view=RealmPage /> <Route path=(StaticSegment("realms"), ParamSegment("slug")) view=RealmPage />
<Route
path=(
StaticSegment("realms"),
ParamSegment("slug"),
StaticSegment("scenes"),
ParamSegment("scene_slug"),
)
view=RealmPage
/>
</Routes> </Routes>
} }
} }

View file

@ -31,7 +31,6 @@ CREATE TABLE realm.realms (
max_users INTEGER NOT NULL DEFAULT 100 CHECK (max_users > 0 AND max_users <= 10000), max_users INTEGER NOT NULL DEFAULT 100 CHECK (max_users > 0 AND max_users <= 10000),
allow_guest_access BOOLEAN NOT NULL DEFAULT true, allow_guest_access BOOLEAN NOT NULL DEFAULT true,
allow_user_teleport BOOLEAN NOT NULL DEFAULT false,
default_scene_id UUID, default_scene_id UUID,