Compare commits
10 commits
af1c767f5f
...
29f29358fd
| Author | SHA256 | Date | |
|---|---|---|---|
| 29f29358fd | |||
| bf3bd3dff5 | |||
| 32e5e42462 | |||
| 226c2e02b5 | |||
| 39750c1d82 | |||
| 1f922f8221 | |||
| 27b3658e1d | |||
| 84cb4e5e78 | |||
| 15cc1f708f | |||
| 44b322371c |
25 changed files with 2026 additions and 444 deletions
|
|
@ -259,6 +259,15 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -75,4 +75,14 @@
|
||||||
.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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -451,6 +451,13 @@ 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 {
|
||||||
|
|
@ -482,6 +489,7 @@ 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,
|
||||||
|
|
@ -509,6 +517,7 @@ 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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1354,6 +1363,7 @@ 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>,
|
||||||
|
|
@ -1370,6 +1380,7 @@ 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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1829,6 +1840,9 @@ 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.
|
||||||
|
|
|
||||||
|
|
@ -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, 0
|
SELECT $1, $2, id, 1
|
||||||
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,7 +176,8 @@ 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
|
||||||
|
|
@ -214,7 +215,8 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,12 @@ 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,
|
||||||
r.current_user_count,
|
COALESCE((
|
||||||
|
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
|
||||||
|
|
@ -65,7 +70,12 @@ 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,
|
||||||
r.current_user_count,
|
COALESCE((
|
||||||
|
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
|
||||||
|
|
@ -244,8 +254,14 @@ 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,
|
||||||
r.current_user_count,
|
COALESCE((
|
||||||
|
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
|
||||||
|
|
@ -279,9 +295,10 @@ 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,
|
||||||
theme_color = $8,
|
allow_user_teleport = $8,
|
||||||
|
theme_color = $9,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $9
|
WHERE id = $10
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(&req.name)
|
.bind(&req.name)
|
||||||
|
|
@ -291,6 +308,7 @@ 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)
|
||||||
|
|
@ -316,8 +334,14 @@ 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,
|
||||||
r.current_user_count,
|
COALESCE((
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -262,17 +262,22 @@ 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
|
||||||
id,
|
r.id,
|
||||||
name,
|
r.name,
|
||||||
slug,
|
r.slug,
|
||||||
tagline,
|
r.tagline,
|
||||||
privacy,
|
r.privacy,
|
||||||
is_nsfw,
|
r.is_nsfw,
|
||||||
thumbnail_path,
|
r.thumbnail_path,
|
||||||
member_count,
|
r.member_count,
|
||||||
current_user_count
|
COALESCE((
|
||||||
FROM realm.realms
|
SELECT COUNT(*)::INTEGER
|
||||||
ORDER BY name
|
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
|
||||||
|
FROM realm.realms r
|
||||||
|
ORDER BY r.name
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
|
|
|
||||||
|
|
@ -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, theme_color
|
privacy, is_nsfw, max_users, allow_guest_access, allow_user_teleport, theme_color
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
RETURNING
|
RETURNING
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
|
|
@ -35,6 +35,7 @@ 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,
|
||||||
|
|
@ -51,6 +52,7 @@ 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?;
|
||||||
|
|
@ -77,27 +79,33 @@ pub async fn get_realm_by_slug<'e>(
|
||||||
let realm = sqlx::query_as::<_, Realm>(
|
let realm = sqlx::query_as::<_, Realm>(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
r.id,
|
||||||
name,
|
r.name,
|
||||||
slug,
|
r.slug,
|
||||||
description,
|
r.description,
|
||||||
tagline,
|
r.tagline,
|
||||||
owner_id,
|
r.owner_id,
|
||||||
privacy,
|
r.privacy,
|
||||||
is_nsfw,
|
r.is_nsfw,
|
||||||
min_reputation_tier,
|
r.min_reputation_tier,
|
||||||
theme_color,
|
r.theme_color,
|
||||||
banner_image_path,
|
r.banner_image_path,
|
||||||
thumbnail_path,
|
r.thumbnail_path,
|
||||||
max_users,
|
r.max_users,
|
||||||
allow_guest_access,
|
r.allow_guest_access,
|
||||||
default_scene_id,
|
r.allow_user_teleport,
|
||||||
member_count,
|
r.default_scene_id,
|
||||||
current_user_count,
|
r.member_count,
|
||||||
created_at,
|
COALESCE((
|
||||||
updated_at
|
SELECT COUNT(*)::INTEGER
|
||||||
FROM realm.realms
|
FROM scene.instance_members im
|
||||||
WHERE slug = $1
|
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.updated_at
|
||||||
|
FROM realm.realms r
|
||||||
|
WHERE r.slug = $1
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(slug)
|
.bind(slug)
|
||||||
|
|
@ -112,27 +120,33 @@ 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
|
||||||
id,
|
r.id,
|
||||||
name,
|
r.name,
|
||||||
slug,
|
r.slug,
|
||||||
description,
|
r.description,
|
||||||
tagline,
|
r.tagline,
|
||||||
owner_id,
|
r.owner_id,
|
||||||
privacy,
|
r.privacy,
|
||||||
is_nsfw,
|
r.is_nsfw,
|
||||||
min_reputation_tier,
|
r.min_reputation_tier,
|
||||||
theme_color,
|
r.theme_color,
|
||||||
banner_image_path,
|
r.banner_image_path,
|
||||||
thumbnail_path,
|
r.thumbnail_path,
|
||||||
max_users,
|
r.max_users,
|
||||||
allow_guest_access,
|
r.allow_guest_access,
|
||||||
default_scene_id,
|
r.allow_user_teleport,
|
||||||
member_count,
|
r.default_scene_id,
|
||||||
current_user_count,
|
r.member_count,
|
||||||
created_at,
|
COALESCE((
|
||||||
updated_at
|
SELECT COUNT(*)::INTEGER
|
||||||
FROM realm.realms
|
FROM scene.instance_members im
|
||||||
WHERE id = $1
|
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.updated_at
|
||||||
|
FROM realm.realms r
|
||||||
|
WHERE r.id = $1
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
|
|
@ -153,18 +167,23 @@ pub async fn list_public_realms(
|
||||||
sqlx::query_as::<_, RealmSummary>(
|
sqlx::query_as::<_, RealmSummary>(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
r.id,
|
||||||
name,
|
r.name,
|
||||||
slug,
|
r.slug,
|
||||||
tagline,
|
r.tagline,
|
||||||
privacy,
|
r.privacy,
|
||||||
is_nsfw,
|
r.is_nsfw,
|
||||||
thumbnail_path,
|
r.thumbnail_path,
|
||||||
member_count,
|
r.member_count,
|
||||||
current_user_count
|
COALESCE((
|
||||||
FROM realm.realms
|
SELECT COUNT(*)::INTEGER
|
||||||
WHERE privacy = 'public'
|
FROM scene.instance_members im
|
||||||
ORDER BY current_user_count DESC, member_count DESC
|
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
|
||||||
|
WHERE r.privacy = 'public'
|
||||||
|
ORDER BY current_user_count DESC, r.member_count DESC
|
||||||
LIMIT $1 OFFSET $2
|
LIMIT $1 OFFSET $2
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|
@ -176,18 +195,23 @@ pub async fn list_public_realms(
|
||||||
sqlx::query_as::<_, RealmSummary>(
|
sqlx::query_as::<_, RealmSummary>(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
r.id,
|
||||||
name,
|
r.name,
|
||||||
slug,
|
r.slug,
|
||||||
tagline,
|
r.tagline,
|
||||||
privacy,
|
r.privacy,
|
||||||
is_nsfw,
|
r.is_nsfw,
|
||||||
thumbnail_path,
|
r.thumbnail_path,
|
||||||
member_count,
|
r.member_count,
|
||||||
current_user_count
|
COALESCE((
|
||||||
FROM realm.realms
|
SELECT COUNT(*)::INTEGER
|
||||||
WHERE privacy = 'public' AND is_nsfw = false
|
FROM scene.instance_members im
|
||||||
ORDER BY current_user_count DESC, member_count DESC
|
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
|
||||||
|
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
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|
@ -205,18 +229,23 @@ 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
|
||||||
id,
|
r.id,
|
||||||
name,
|
r.name,
|
||||||
slug,
|
r.slug,
|
||||||
tagline,
|
r.tagline,
|
||||||
privacy,
|
r.privacy,
|
||||||
is_nsfw,
|
r.is_nsfw,
|
||||||
thumbnail_path,
|
r.thumbnail_path,
|
||||||
member_count,
|
r.member_count,
|
||||||
current_user_count
|
COALESCE((
|
||||||
FROM realm.realms
|
SELECT COUNT(*)::INTEGER
|
||||||
WHERE owner_id = $1
|
FROM scene.instance_members im
|
||||||
ORDER BY created_at DESC
|
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
|
||||||
|
WHERE r.owner_id = $1
|
||||||
|
ORDER BY r.created_at DESC
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,14 @@ 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")]
|
||||||
|
|
@ -76,6 +84,12 @@ 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.
|
||||||
|
|
@ -212,4 +226,12 @@ 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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,13 @@ 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;
|
||||||
|
|
@ -93,6 +100,13 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -20,14 +20,11 @@ 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::{ClientMessage, DisconnectReason, ServerMessage, WsConfig},
|
ws_messages::{close_codes, 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.
|
||||||
|
|
@ -353,6 +350,7 @@ 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
|
||||||
|
|
@ -390,11 +388,20 @@ 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;
|
||||||
|
|
||||||
|
|
@ -509,6 +516,17 @@ 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)
|
||||||
|
|
@ -712,12 +730,96 @@ 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 == SCENE_CHANGE_CLOSE_CODE {
|
if code == close_codes::SCENE_CHANGE {
|
||||||
disconnect_reason = DisconnectReason::SceneChange;
|
disconnect_reason = DisconnectReason::SceneChange;
|
||||||
} else {
|
} else {
|
||||||
disconnect_reason = DisconnectReason::Graceful;
|
disconnect_reason = DisconnectReason::Graceful;
|
||||||
|
|
@ -743,6 +845,12 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
@ -752,10 +860,21 @@ async fn handle_socket(
|
||||||
(recv_conn, disconnect_reason)
|
(recv_conn, disconnect_reason)
|
||||||
});
|
});
|
||||||
|
|
||||||
// Spawn task to forward broadcasts and direct messages to this client
|
// Spawn task to forward broadcasts, direct messages, and close frames 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) {
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,12 @@ 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::*;
|
||||||
|
|
@ -38,6 +40,8 @@ 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::*;
|
||||||
|
|
|
||||||
|
|
@ -226,6 +226,235 @@ 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)
|
||||||
|
|
@ -293,102 +522,29 @@ 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 for avatar clamping
|
// Compute screen boundaries and avatar screen position
|
||||||
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;
|
||||||
|
|
||||||
// Clamp avatar center so visual bounds stay within screen boundaries
|
// Create unified layout - all calculations happen in one place
|
||||||
// Use actual content extent rather than full 3x3 grid
|
let layout = CanvasLayout::new(
|
||||||
let content_half_width = content_bounds.content_width(ps) / 2.0;
|
&content_bounds,
|
||||||
let content_half_height = content_bounds.content_height(ps) / 2.0;
|
ps,
|
||||||
let (clamped_x, clamped_y) = boundaries.clamp_avatar_center(
|
te,
|
||||||
avatar_screen_x,
|
avatar_screen_x,
|
||||||
avatar_screen_y,
|
avatar_screen_y,
|
||||||
content_half_width,
|
boundaries,
|
||||||
content_half_height,
|
bubble.is_some(),
|
||||||
|
bubble.as_ref().map(|b| b.message.content.as_str()),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Calculate canvas position from clamped screen coordinates, adjusted for content bounds
|
// Generate CSS style from layout
|
||||||
let canvas_x = clamped_x - avatar_size / 2.0 - x_content_offset;
|
layout.css_style(z_index, pointer_events, opacity)
|
||||||
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
|
||||||
|
|
@ -422,7 +578,7 @@ pub fn AvatarCanvas(
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate dimensions (same as in style closure)
|
// Calculate content bounds for the avatar
|
||||||
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,
|
||||||
|
|
@ -430,61 +586,35 @@ pub fn AvatarCanvas(
|
||||||
&m.avatar.emotion_layer,
|
&m.avatar.emotion_layer,
|
||||||
);
|
);
|
||||||
|
|
||||||
let avatar_size = ps * 3.0;
|
// Get scene dimensions and transform parameters
|
||||||
let text_scale = te * BASE_TEXT_SCALE;
|
let sx = scale_x.get();
|
||||||
let fixed_bubble_height = if bubble.is_some() {
|
let sy = scale_y.get();
|
||||||
(4.0 * 16.0 + 16.0 + 8.0 + 5.0) * text_scale
|
let ox = offset_x.get();
|
||||||
} else {
|
let oy = offset_y.get();
|
||||||
0.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 fixed_name_height = 20.0 * text_scale;
|
|
||||||
let fixed_text_width = 200.0 * text_scale;
|
|
||||||
|
|
||||||
// Determine bubble position early so we can position the avatar correctly
|
// Create unified layout - same calculation as style closure
|
||||||
let y_content_offset = content_bounds.y_offset(ps);
|
let boundaries = ScreenBoundaries::from_transform(sw, sh, sx, sy, ox, oy);
|
||||||
let bubble_position = if bubble.is_some() {
|
let avatar_screen_x = m.member.position_x * sx + ox;
|
||||||
let sx = scale_x.get();
|
let avatar_screen_y = m.member.position_y * sy + oy;
|
||||||
let sy = scale_y.get();
|
|
||||||
let ox = offset_x.get();
|
|
||||||
let oy = offset_y.get();
|
|
||||||
|
|
||||||
// Get scene dimensions (use large defaults if not provided)
|
let layout = CanvasLayout::new(
|
||||||
let sw = scene_width.map(|s| s.get()).unwrap_or(10000.0);
|
&content_bounds,
|
||||||
let sh = scene_height.map(|s| s.get()).unwrap_or(10000.0);
|
ps,
|
||||||
|
te,
|
||||||
// Compute screen boundaries
|
avatar_screen_x,
|
||||||
let boundaries = ScreenBoundaries::from_transform(sw, sh, sx, sy, ox, oy);
|
avatar_screen_y,
|
||||||
|
boundaries,
|
||||||
// Calculate avatar's screen position
|
bubble.is_some(),
|
||||||
let avatar_screen_y = m.member.position_y * sy + oy;
|
bubble.as_ref().map(|b| b.message.content.as_str()),
|
||||||
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
|
// Set canvas resolution from layout
|
||||||
canvas_el.set_width(canvas_width as u32);
|
canvas_el.set_width(layout.canvas_width as u32);
|
||||||
canvas_el.set_height(canvas_height as u32);
|
canvas_el.set_height(layout.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;
|
||||||
|
|
@ -492,16 +622,7 @@ 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, canvas_width, canvas_height);
|
ctx.clear_rect(0.0, 0.0, layout.canvas_width, layout.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
|
||||||
|
|
@ -544,9 +665,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 = ps;
|
let cell_size = layout.prop_size;
|
||||||
let grid_origin_x = avatar_cx - avatar_size / 2.0;
|
let grid_origin_x = layout.avatar_cx - layout.avatar_size / 2.0;
|
||||||
let grid_origin_y = avatar_cy - avatar_size / 2.0;
|
let grid_origin_y = layout.avatar_cy - layout.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 {
|
||||||
|
|
@ -616,9 +737,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 * text_scale;
|
let badge_size = 16.0 * layout.text_scale;
|
||||||
let badge_x = avatar_cx + avatar_size / 2.0 - badge_size / 2.0;
|
let badge_x = layout.avatar_cx + layout.avatar_size / 2.0 - badge_size / 2.0;
|
||||||
let badge_y = avatar_cy - avatar_size / 2.0 - badge_size / 2.0;
|
let badge_y = layout.avatar_cy - layout.avatar_size / 2.0 - badge_size / 2.0;
|
||||||
|
|
||||||
ctx.begin_path();
|
ctx.begin_path();
|
||||||
let _ = ctx.arc(
|
let _ = ctx.arc(
|
||||||
|
|
@ -632,23 +753,21 @@ 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 * text_scale));
|
ctx.set_font(&format!("bold {}px sans-serif", 10.0 * layout.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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate content bounds for name positioning
|
|
||||||
let name_x = avatar_cx + content_bounds.x_offset(cell_size);
|
|
||||||
let empty_bottom_rows = content_bounds.empty_bottom_rows();
|
|
||||||
|
|
||||||
// Draw display name below avatar (with black outline for readability)
|
// Draw display name below avatar (with black outline for readability)
|
||||||
|
let name_x = layout.content_center_x();
|
||||||
|
let name_y = layout.avatar_bottom_y() - layout.content_bottom_adjustment()
|
||||||
|
+ 15.0 * layout.text_scale;
|
||||||
|
|
||||||
let display_name = &m.member.display_name;
|
let display_name = &m.member.display_name;
|
||||||
ctx.set_font(&format!("{}px sans-serif", 12.0 * text_scale));
|
ctx.set_font(&format!("{}px sans-serif", 12.0 * layout.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);
|
||||||
|
|
@ -661,35 +780,7 @@ 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 {
|
||||||
let content_x_offset = content_bounds.x_offset(cell_size);
|
draw_bubble_with_layout(&ctx, b, &layout, te);
|
||||||
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),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -724,33 +815,18 @@ fn normalize_asset_path(path: &str) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draw a speech bubble relative to the avatar with boundary awareness.
|
/// Draw a speech bubble using the unified CanvasLayout.
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// This is the preferred method for drawing bubbles - it uses the layout's
|
||||||
/// * `ctx` - Canvas rendering context
|
/// coordinate transformation and clamping methods, ensuring consistency
|
||||||
/// * `bubble` - The active bubble data
|
/// with the canvas positioning.
|
||||||
/// * `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(
|
fn draw_bubble_with_layout(
|
||||||
ctx: &web_sys::CanvasRenderingContext2d,
|
ctx: &web_sys::CanvasRenderingContext2d,
|
||||||
bubble: &ActiveBubble,
|
bubble: &ActiveBubble,
|
||||||
center_x: f64,
|
layout: &CanvasLayout,
|
||||||
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;
|
||||||
|
|
@ -771,11 +847,7 @@ fn draw_bubble(
|
||||||
|
|
||||||
// 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(
|
let lines = wrap_text(ctx, &bubble.message.content, max_bubble_width - padding * 2.0);
|
||||||
ctx,
|
|
||||||
&bubble.message.content,
|
|
||||||
max_bubble_width - padding * 2.0,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Calculate bubble dimensions
|
// Calculate bubble dimensions
|
||||||
let bubble_width = lines
|
let bubble_width = lines
|
||||||
|
|
@ -786,25 +858,14 @@ fn draw_bubble(
|
||||||
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;
|
||||||
|
|
||||||
// Center bubble horizontally on content (not grid center)
|
// Get content center from layout
|
||||||
let content_center_x = center_x + content_x_offset;
|
let content_center_x = layout.content_center_x();
|
||||||
|
|
||||||
// Calculate initial bubble X position (centered on content)
|
// Calculate initial bubble X (centered on content)
|
||||||
let mut bubble_x = content_center_x - bubble_width / 2.0;
|
let initial_bubble_x = content_center_x - bubble_width / 2.0;
|
||||||
|
|
||||||
// Clamp bubble horizontally to stay within drawable area
|
// Use layout's clamping method - this handles coordinate transformation correctly
|
||||||
if let Some(bounds) = boundaries {
|
let bubble_x = layout.clamp_bubble_x(initial_bubble_x, bubble_width);
|
||||||
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
|
||||||
|
|
@ -812,27 +873,20 @@ fn draw_bubble(
|
||||||
.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 position {
|
let bubble_y = match layout.bubble_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 = top_y + content_top_adjustment;
|
let adjusted_top_y = layout.avatar_top_y() + layout.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
|
||||||
bottom_y + tail_size + gap
|
layout.avatar_bottom_y() + tail_size + gap
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Draw bubble background
|
// Draw bubble background
|
||||||
draw_rounded_rect(
|
draw_rounded_rect(ctx, bubble_x, bubble_y, bubble_width, bubble_height, border_radius);
|
||||||
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);
|
||||||
|
|
@ -841,7 +895,7 @@ fn draw_bubble(
|
||||||
|
|
||||||
// Draw tail pointing to content center
|
// Draw tail pointing to content center
|
||||||
ctx.begin_path();
|
ctx.begin_path();
|
||||||
match position {
|
match layout.bubble_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);
|
||||||
|
|
@ -861,7 +915,7 @@ fn draw_bubble(
|
||||||
ctx.set_stroke_style_str(border_color);
|
ctx.set_stroke_style_str(border_color);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Draw text (re-set font in case canvas state changed)
|
// Draw text
|
||||||
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");
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ 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;
|
||||||
|
|
@ -216,6 +217,7 @@ 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>,
|
||||||
|
|
@ -224,7 +226,11 @@ 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);
|
||||||
|
|
||||||
|
|
@ -798,6 +804,11 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
//! 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;
|
use chattyness_db::models::{EmotionAvailability, SceneSummary};
|
||||||
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.
|
||||||
|
|
@ -19,6 +21,8 @@ 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.
|
||||||
|
|
@ -44,6 +48,28 @@ 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`.
|
||||||
|
|
@ -84,6 +110,9 @@ 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,
|
||||||
|
|
@ -97,11 +126,23 @@ 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
|
||||||
|
|
@ -121,6 +162,21 @@ 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")]
|
||||||
{
|
{
|
||||||
|
|
@ -162,25 +218,24 @@ pub fn ChatInput(
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Pre-fill with /whisper command
|
// Pre-fill with /whisper command prefix only (no placeholder text)
|
||||||
let placeholder = "your message here";
|
// User types their message after the space
|
||||||
let whisper_text = format!("/whisper {} {}", target_name, placeholder);
|
// parse_whisper_command already rejects empty messages
|
||||||
|
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_text.clone());
|
set_message.set(whisper_prefix.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_text);
|
input.set_value(&whisper_prefix);
|
||||||
|
|
||||||
// Focus the input
|
// Focus the input and position cursor at end
|
||||||
let _ = input.focus();
|
let _ = input.focus();
|
||||||
|
let len = whisper_prefix.len() as u32;
|
||||||
// Select the placeholder text so it gets replaced when typing
|
let _ = input.set_selection_range(len, len);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -205,13 +260,20 @@ 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 list is showing, update filter (input is the filter text)
|
// If emotion 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();
|
||||||
|
|
||||||
|
|
@ -230,16 +292,45 @@ 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], or their full forms with args
|
// Match: /s[etting], /i[nventory], /w[hisper], /t[eleport]
|
||||||
if cmd.is_empty()
|
// But NOT when whisper command is complete (has name + space for message)
|
||||||
|
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 {
|
||||||
|
|
@ -265,6 +356,8 @@ 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() {
|
||||||
|
|
@ -314,6 +407,51 @@ 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();
|
||||||
|
|
@ -337,6 +475,15 @@ 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();
|
||||||
|
|
@ -401,6 +548,43 @@ 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;
|
||||||
|
|
@ -470,7 +654,7 @@ pub fn ChatInput(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Popup select handler
|
// Popup select handler for emotions
|
||||||
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);
|
||||||
|
|
@ -481,7 +665,27 @@ 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">
|
||||||
|
|
@ -498,7 +702,7 @@ pub fn ChatInput(
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
// Slash command hint bar (/s[etting], /i[nventory], /w[hisper])
|
// Slash command hint bar (/s[etting], /i[nventory], /w[hisper], /t[eleport])
|
||||||
<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>
|
||||||
|
|
@ -512,6 +716,12 @@ 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>
|
||||||
|
|
||||||
|
|
@ -528,6 +738,17 @@ 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"
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ 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.)
|
||||||
|
|
@ -35,6 +36,9 @@ 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>>,
|
||||||
|
|
@ -82,12 +86,40 @@ pub fn ContextMenu(
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Click outside handler
|
// Click outside handler - use Effect with cleanup to properly remove handlers
|
||||||
#[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;
|
||||||
}
|
}
|
||||||
|
|
@ -100,6 +132,7 @@ 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() {
|
||||||
|
|
@ -111,9 +144,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();
|
||||||
|
|
@ -129,10 +162,7 @@ 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();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,6 +175,17 @@ 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()
|
||||||
|
|
|
||||||
|
|
@ -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::Modal;
|
use super::modals::{GuestLockedOverlay, Modal};
|
||||||
use super::tabs::{Tab, TabBar};
|
use super::tabs::{Tab, TabBar};
|
||||||
use super::ws_client::WsSender;
|
use super::ws_client::WsSender;
|
||||||
|
|
||||||
|
|
@ -24,13 +24,18 @@ 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");
|
||||||
|
|
||||||
|
|
@ -238,52 +243,59 @@ 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"
|
||||||
>
|
>
|
||||||
// Tab bar
|
<div class="relative flex-1 flex flex-col">
|
||||||
<TabBar
|
// Tab bar
|
||||||
tabs=vec![
|
<TabBar
|
||||||
Tab::new("my_inventory", "My Inventory"),
|
tabs=vec![
|
||||||
Tab::new("server", "Server"),
|
Tab::new("my_inventory", "My Inventory"),
|
||||||
Tab::new("realm", "Realm"),
|
Tab::new("server", "Server"),
|
||||||
]
|
Tab::new("realm", "Realm"),
|
||||||
active=Signal::derive(move || active_tab.get())
|
]
|
||||||
on_select=Callback::new(move |id| set_active_tab.set(id))
|
active=Signal::derive(move || active_tab.get())
|
||||||
/>
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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: i32,
|
online_count: Signal<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 = format!("{} ONLINE", online_count);
|
let online_text = move || format!("{} ONLINE", online_count.get());
|
||||||
let admin_url = format!("/admin/realms/{}", realm_slug);
|
let admin_url = format!("/admin/realms/{}", realm_slug);
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
|
|
|
||||||
|
|
@ -314,3 +314,45 @@ 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
399
crates/chattyness-user-ui/src/components/reconnection_overlay.rs
Normal file
399
crates/chattyness-user-ui/src/components/reconnection_overlay.rs
Normal file
|
|
@ -0,0 +1,399 @@
|
||||||
|
//! 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);
|
||||||
|
}
|
||||||
127
crates/chattyness-user-ui/src/components/scene_list_popup.rs
Normal file
127
crates/chattyness-user-ui/src/components/scene_list_popup.rs
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
//! 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -56,10 +56,14 @@ 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);
|
||||||
|
|
@ -183,14 +187,19 @@ pub fn RealmSceneViewer(
|
||||||
move |ev: web_sys::MouseEvent| {
|
move |ev: web_sys::MouseEvent| {
|
||||||
use wasm_bindgen::JsCast;
|
use wasm_bindgen::JsCast;
|
||||||
|
|
||||||
// Get click position
|
// Guests cannot message other users - don't show context menu
|
||||||
let client_x = ev.client_x() as f64;
|
if is_guest.get() {
|
||||||
let client_y = ev.client_y() as f64;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 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();
|
||||||
|
|
||||||
|
|
@ -1033,6 +1042,7 @@ 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 {
|
||||||
|
|
|
||||||
|
|
@ -9,18 +9,21 @@ 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::ClientMessage;
|
use chattyness_db::ws_messages::{close_codes, 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 {
|
||||||
|
|
@ -43,6 +46,9 @@ 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.
|
||||||
|
|
@ -60,6 +66,8 @@ 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.
|
||||||
|
|
@ -71,6 +79,15 @@ 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:
|
||||||
|
|
@ -80,6 +97,7 @@ pub struct WsError {
|
||||||
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>>,
|
||||||
|
|
@ -88,6 +106,7 @@ 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;
|
||||||
|
|
@ -97,6 +116,11 @@ 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();
|
||||||
|
|
@ -116,14 +140,24 @@ 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() {
|
||||||
let _ = old_ws.close();
|
#[cfg(debug_assertions)]
|
||||||
|
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 {
|
||||||
|
|
@ -185,10 +219,13 @@ 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();
|
||||||
|
|
@ -203,6 +240,9 @@ 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;
|
||||||
|
|
@ -237,6 +277,7 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
@ -251,6 +292,8 @@ 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,
|
||||||
|
¤t_user_id_for_msg,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -260,22 +303,82 @@ 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={}", e.code(), e.reason()).into(),
|
&format!("[WS] Closed: code={}, reason={}", 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();
|
||||||
|
|
@ -298,6 +401,8 @@ 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();
|
||||||
|
|
||||||
|
|
@ -324,6 +429,18 @@ 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()
|
||||||
|
|
@ -450,6 +567,17 @@ 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -458,6 +586,7 @@ 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>>,
|
||||||
|
|
@ -466,6 +595,7 @@ 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);
|
||||||
|
|
|
||||||
|
|
@ -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, SettingsPopup,
|
NotificationMessage, NotificationToast, RealmHeader, RealmSceneViewer, ReconnectionOverlay,
|
||||||
ViewerSettings,
|
SettingsPopup, 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,
|
||||||
WsError, add_to_history, use_channel_websocket,
|
TeleportInfo, 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,
|
RealmWithUserRole, Scene, SceneSummary,
|
||||||
};
|
};
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
use chattyness_db::ws_messages::ClientMessage;
|
use chattyness_db::ws_messages::ClientMessage;
|
||||||
|
|
@ -44,6 +44,9 @@ 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);
|
||||||
|
|
@ -98,6 +101,8 @@ 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);
|
||||||
|
|
@ -118,6 +123,18 @@ 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 {
|
||||||
|
|
@ -310,6 +327,7 @@ 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.)
|
||||||
|
|
@ -318,6 +336,8 @@ 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));
|
||||||
|
|
@ -329,10 +349,74 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
.forget();
|
.forget();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Callback for teleport approval - navigate to new scene
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
let (_ws_state, ws_sender) = use_channel_websocket(
|
let on_teleport_approved = Callback::new(move |info: TeleportInfo| {
|
||||||
|
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,
|
||||||
|
|
@ -341,23 +425,172 @@ 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 and scene dimensions when scene loads
|
// Set channel ID, current scene, and scene dimensions when entry 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 |_| {
|
||||||
let Some(scene) = entry_scene.get().flatten() else {
|
// Skip if already handled
|
||||||
|
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));
|
|
||||||
|
|
||||||
// Extract scene dimensions from bounds_wkt
|
// Set allow_user_teleport from realm settings
|
||||||
if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) {
|
set_allow_user_teleport.set(realm_with_role.realm.allow_user_teleport);
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -686,7 +919,8 @@ 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();
|
||||||
let online_count = realm.current_user_count;
|
// Derive online count reactively from members signal
|
||||||
|
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;
|
||||||
|
|
@ -721,11 +955,16 @@ 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(scene) => {
|
Some(entry_scene_data) => {
|
||||||
|
// 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());
|
||||||
|
|
@ -747,10 +986,22 @@ 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=scene
|
scene=display_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
|
||||||
|
|
@ -767,6 +1018,7 @@ 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">
|
||||||
|
|
@ -780,6 +1032,9 @@ 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>
|
||||||
|
|
@ -818,6 +1073,7 @@ 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())
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -862,6 +1118,7 @@ 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())
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -953,6 +1210,22 @@ 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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,15 @@ 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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ 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,
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue