update to support user expire, timeout, and disconnect
This commit is contained in:
parent
fe65835f4a
commit
5fcd49e847
16 changed files with 744 additions and 238 deletions
|
|
@ -15,6 +15,7 @@ chattyness-admin-ui.workspace = true
|
||||||
chattyness-user-ui.workspace = true
|
chattyness-user-ui.workspace = true
|
||||||
chattyness-db.workspace = true
|
chattyness-db.workspace = true
|
||||||
chattyness-error.workspace = true
|
chattyness-error.workspace = true
|
||||||
|
chattyness-shared.workspace = true
|
||||||
leptos.workspace = true
|
leptos.workspace = true
|
||||||
leptos_meta.workspace = true
|
leptos_meta.workspace = true
|
||||||
leptos_router.workspace = true
|
leptos_router.workspace = true
|
||||||
|
|
@ -57,6 +58,7 @@ ssr = [
|
||||||
"chattyness-user-ui/ssr",
|
"chattyness-user-ui/ssr",
|
||||||
"chattyness-db/ssr",
|
"chattyness-db/ssr",
|
||||||
"chattyness-error/ssr",
|
"chattyness-error/ssr",
|
||||||
|
"chattyness-shared/ssr",
|
||||||
]
|
]
|
||||||
# Unified hydrate feature - admin routes are lazy-loaded via #[lazy] macro
|
# Unified hydrate feature - admin routes are lazy-loaded via #[lazy] macro
|
||||||
hydrate = [
|
hydrate = [
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,14 @@ mod server {
|
||||||
use leptos_axum::{generate_route_list, LeptosRoutes};
|
use leptos_axum::{generate_route_list, LeptosRoutes};
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
use tower_http::services::ServeDir;
|
use tower_http::services::ServeDir;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
use chattyness_app::{combined_shell, CombinedApp, CombinedAppState};
|
use chattyness_app::{combined_shell, CombinedApp, CombinedAppState};
|
||||||
|
use chattyness_shared::AppConfig;
|
||||||
use chattyness_user_ui::api::WebSocketState;
|
use chattyness_user_ui::api::WebSocketState;
|
||||||
|
|
||||||
/// CLI arguments.
|
/// CLI arguments.
|
||||||
|
|
@ -42,6 +44,10 @@ mod server {
|
||||||
/// Use secure cookies
|
/// Use secure cookies
|
||||||
#[arg(long, env = "SECURE_COOKIES", default_value = "false")]
|
#[arg(long, env = "SECURE_COOKIES", default_value = "false")]
|
||||||
secure_cookies: bool,
|
secure_cookies: bool,
|
||||||
|
|
||||||
|
/// Path to TOML configuration file
|
||||||
|
#[arg(long, short = 'c', env = "CONFIG_FILE")]
|
||||||
|
config: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
pub async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
|
@ -60,6 +66,10 @@ mod server {
|
||||||
// Parse arguments
|
// Parse arguments
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
let config = AppConfig::load(args.config.as_deref())?;
|
||||||
|
tracing::info!("Configuration loaded: {:?}", config);
|
||||||
|
|
||||||
tracing::info!("Starting Chattyness App Server");
|
tracing::info!("Starting Chattyness App Server");
|
||||||
|
|
||||||
// Create database pool for app access (fixed connection string, RLS-constrained)
|
// Create database pool for app access (fixed connection string, RLS-constrained)
|
||||||
|
|
@ -74,6 +84,53 @@ mod server {
|
||||||
|
|
||||||
tracing::info!("Connected to database (app role with RLS)");
|
tracing::info!("Connected to database (app role with RLS)");
|
||||||
|
|
||||||
|
// Startup cleanup: clear all instance_members if configured
|
||||||
|
// Uses SECURITY DEFINER function to bypass RLS
|
||||||
|
if config.cleanup.clear_on_startup {
|
||||||
|
tracing::info!("Clearing stale instance_members on startup...");
|
||||||
|
let deleted: i64 = sqlx::query_scalar("SELECT scene.clear_all_instance_members()")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await?;
|
||||||
|
tracing::info!("Cleared {} stale instance_members", deleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn background task for periodic stale member cleanup
|
||||||
|
// Uses SECURITY DEFINER function to bypass RLS
|
||||||
|
{
|
||||||
|
let cleanup_pool = pool.clone();
|
||||||
|
let cleanup_config = config.cleanup.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut interval = tokio::time::interval(Duration::from_secs(
|
||||||
|
cleanup_config.reap_interval_secs,
|
||||||
|
));
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
let threshold = cleanup_config.stale_threshold_secs as f64;
|
||||||
|
match sqlx::query_scalar::<_, i64>(
|
||||||
|
"SELECT scene.clear_stale_instance_members($1)",
|
||||||
|
)
|
||||||
|
.bind(threshold)
|
||||||
|
.fetch_one(&cleanup_pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(deleted) => {
|
||||||
|
if deleted > 0 {
|
||||||
|
tracing::info!("Reaped {} stale instance_members", deleted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Stale member cleanup failed: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tracing::info!(
|
||||||
|
"Started stale member reaper (interval: {}s, threshold: {}s)",
|
||||||
|
config.cleanup.reap_interval_secs,
|
||||||
|
config.cleanup.stale_threshold_secs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Configure Leptos
|
// Configure Leptos
|
||||||
let cargo_toml = concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml");
|
let cargo_toml = concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml");
|
||||||
let conf = get_configuration(Some(cargo_toml)).unwrap();
|
let conf = get_configuration(Some(cargo_toml)).unwrap();
|
||||||
|
|
@ -118,6 +175,7 @@ mod server {
|
||||||
pool: pool.clone(),
|
pool: pool.clone(),
|
||||||
leptos_options: leptos_options.clone(),
|
leptos_options: leptos_options.clone(),
|
||||||
ws_state: ws_state.clone(),
|
ws_state: ws_state.clone(),
|
||||||
|
ws_config: config.websocket.clone(),
|
||||||
};
|
};
|
||||||
let admin_api_state = chattyness_admin_ui::AdminAppState {
|
let admin_api_state = chattyness_admin_ui::AdminAppState {
|
||||||
pool: pool.clone(),
|
pool: pool.clone(),
|
||||||
|
|
|
||||||
18
config.toml
Normal file
18
config.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Chattyness Application Configuration
|
||||||
|
|
||||||
|
[websocket]
|
||||||
|
# Timeout for receiving messages from client before considering connection dead (seconds)
|
||||||
|
recv_timeout_secs = 45
|
||||||
|
|
||||||
|
# Interval for client to send ping to keep connection alive (seconds)
|
||||||
|
client_ping_interval_secs = 30
|
||||||
|
|
||||||
|
[cleanup]
|
||||||
|
# Interval for running stale member cleanup job (seconds)
|
||||||
|
reap_interval_secs = 120
|
||||||
|
|
||||||
|
# Members with no activity for this duration are considered stale (seconds)
|
||||||
|
stale_threshold_secs = 120
|
||||||
|
|
||||||
|
# Clear all instance_members on server startup (recommended for single-server deployments)
|
||||||
|
clear_on_startup = true
|
||||||
|
|
@ -273,3 +273,27 @@ pub async fn set_afk<'e>(
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update the last_moved_at timestamp to keep the member alive.
|
||||||
|
///
|
||||||
|
/// Called when a ping is received from the client to prevent
|
||||||
|
/// the member from being reaped by the stale member cleanup.
|
||||||
|
pub async fn touch_member<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
channel_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE scene.instance_members
|
||||||
|
SET last_moved_at = now()
|
||||||
|
WHERE instance_id = $1 AND user_id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(channel_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,25 @@ use uuid::Uuid;
|
||||||
|
|
||||||
use crate::models::{AvatarRenderData, ChannelMemberInfo, ChannelMemberWithAvatar, LooseProp};
|
use crate::models::{AvatarRenderData, ChannelMemberInfo, ChannelMemberWithAvatar, LooseProp};
|
||||||
|
|
||||||
|
/// WebSocket configuration sent to client on connect.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct WsConfig {
|
||||||
|
/// Interval for client to send ping to keep connection alive (seconds).
|
||||||
|
pub ping_interval_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reason for member disconnect.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum DisconnectReason {
|
||||||
|
/// Graceful disconnect (browser close, normal WebSocket close).
|
||||||
|
Graceful,
|
||||||
|
/// Scene navigation (custom close code 4000).
|
||||||
|
SceneChange,
|
||||||
|
/// Timeout (connection lost, no ping response).
|
||||||
|
Timeout,
|
||||||
|
}
|
||||||
|
|
||||||
/// Client-to-server WebSocket messages.
|
/// Client-to-server WebSocket messages.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
|
@ -60,6 +79,8 @@ pub enum ServerMessage {
|
||||||
member: ChannelMemberInfo,
|
member: ChannelMemberInfo,
|
||||||
/// All current members with avatars.
|
/// All current members with avatars.
|
||||||
members: Vec<ChannelMemberWithAvatar>,
|
members: Vec<ChannelMemberWithAvatar>,
|
||||||
|
/// WebSocket configuration for the client.
|
||||||
|
config: WsConfig,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// A member joined the channel.
|
/// A member joined the channel.
|
||||||
|
|
@ -74,6 +95,8 @@ pub enum ServerMessage {
|
||||||
user_id: Option<Uuid>,
|
user_id: Option<Uuid>,
|
||||||
/// Guest session ID (if guest).
|
/// Guest session ID (if guest).
|
||||||
guest_session_id: Option<Uuid>,
|
guest_session_id: Option<Uuid>,
|
||||||
|
/// Reason for disconnect.
|
||||||
|
reason: DisconnectReason,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// A member updated their position.
|
/// A member updated their position.
|
||||||
|
|
|
||||||
|
|
@ -7,3 +7,8 @@ edition.workspace = true
|
||||||
chattyness-error.workspace = true
|
chattyness-error.workspace = true
|
||||||
regex.workspace = true
|
regex.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
toml.workspace = true
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
ssr = []
|
||||||
|
|
|
||||||
88
crates/chattyness-shared/src/config.rs
Normal file
88
crates/chattyness-shared/src/config.rs
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
//! Application configuration for chattyness.
|
||||||
|
//!
|
||||||
|
//! Provides TOML-based configuration with sensible defaults for WebSocket
|
||||||
|
//! timeouts and stale member cleanup.
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use chattyness_error::AppError;
|
||||||
|
|
||||||
|
/// Root configuration structure.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct AppConfig {
|
||||||
|
/// WebSocket-related configuration.
|
||||||
|
pub websocket: WebSocketConfig,
|
||||||
|
/// Stale member cleanup configuration.
|
||||||
|
pub cleanup: CleanupConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WebSocket configuration.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct WebSocketConfig {
|
||||||
|
/// Timeout for receiving messages from client before considering connection dead (seconds).
|
||||||
|
pub recv_timeout_secs: u64,
|
||||||
|
/// Interval for client to send ping to keep connection alive (seconds).
|
||||||
|
pub client_ping_interval_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cleanup configuration for stale instance members.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct CleanupConfig {
|
||||||
|
/// Interval for running stale member cleanup job (seconds).
|
||||||
|
pub reap_interval_secs: u64,
|
||||||
|
/// Members with no activity for this duration are considered stale (seconds).
|
||||||
|
pub stale_threshold_secs: u64,
|
||||||
|
/// Clear all instance_members on server startup (recommended for single-server deployments).
|
||||||
|
pub clear_on_startup: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
websocket: WebSocketConfig::default(),
|
||||||
|
cleanup: CleanupConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WebSocketConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
recv_timeout_secs: 45,
|
||||||
|
client_ping_interval_secs: 30,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CleanupConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
reap_interval_secs: 120,
|
||||||
|
stale_threshold_secs: 120,
|
||||||
|
clear_on_startup: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppConfig {
|
||||||
|
/// Load configuration from a TOML file.
|
||||||
|
///
|
||||||
|
/// If no path is provided, returns default configuration.
|
||||||
|
pub fn load(path: Option<&Path>) -> Result<Self, AppError> {
|
||||||
|
match path {
|
||||||
|
Some(p) => {
|
||||||
|
let content = std::fs::read_to_string(p).map_err(|e| {
|
||||||
|
AppError::Internal(format!("Failed to read config file {:?}: {}", p, e))
|
||||||
|
})?;
|
||||||
|
toml::from_str(&content).map_err(|e| {
|
||||||
|
AppError::Internal(format!("Failed to parse config file {:?}: {}", p, e))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
None => Ok(Self::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
//! Shared utilities for chattyness.
|
//! Shared utilities for chattyness.
|
||||||
//!
|
//!
|
||||||
//! This crate provides common validation functions and utilities
|
//! This crate provides common validation functions, configuration,
|
||||||
//! used across the application.
|
//! and utilities used across the application.
|
||||||
|
|
||||||
|
pub mod config;
|
||||||
pub mod validation;
|
pub mod validation;
|
||||||
|
|
||||||
|
pub use config::*;
|
||||||
pub use validation::*;
|
pub use validation::*;
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ ssr = [
|
||||||
"leptos_router/ssr",
|
"leptos_router/ssr",
|
||||||
"chattyness-db/ssr",
|
"chattyness-db/ssr",
|
||||||
"chattyness-error/ssr",
|
"chattyness-error/ssr",
|
||||||
|
"chattyness-shared/ssr",
|
||||||
"dep:chattyness-error",
|
"dep:chattyness-error",
|
||||||
"dep:chattyness-shared",
|
"dep:chattyness-shared",
|
||||||
"dep:axum",
|
"dep:axum",
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{
|
extract::{
|
||||||
ws::{Message, WebSocket, WebSocketUpgrade},
|
ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade},
|
||||||
FromRef, Path, State,
|
FromRef, Path, State,
|
||||||
},
|
},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
|
|
@ -13,15 +13,20 @@ use dashmap::DashMap;
|
||||||
use futures::{SinkExt, StreamExt};
|
use futures::{SinkExt, StreamExt};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::broadcast;
|
use std::time::Duration;
|
||||||
|
use tokio::sync::{broadcast, mpsc};
|
||||||
use uuid::Uuid;
|
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, ServerMessage},
|
ws_messages::{ClientMessage, DisconnectReason, ServerMessage, WsConfig},
|
||||||
};
|
};
|
||||||
use chattyness_error::AppError;
|
use chattyness_error::AppError;
|
||||||
|
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;
|
||||||
|
|
||||||
|
|
@ -71,12 +76,14 @@ pub async fn ws_handler<S>(
|
||||||
auth_result: Result<AuthUser, crate::auth::AuthError>,
|
auth_result: Result<AuthUser, crate::auth::AuthError>,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
State(ws_state): State<Arc<WebSocketState>>,
|
State(ws_state): State<Arc<WebSocketState>>,
|
||||||
|
State(ws_config): State<WebSocketConfig>,
|
||||||
ws: WebSocketUpgrade,
|
ws: WebSocketUpgrade,
|
||||||
) -> Result<impl IntoResponse, AppError>
|
) -> Result<impl IntoResponse, AppError>
|
||||||
where
|
where
|
||||||
S: Send + Sync,
|
S: Send + Sync,
|
||||||
PgPool: FromRef<S>,
|
PgPool: FromRef<S>,
|
||||||
Arc<WebSocketState>: FromRef<S>,
|
Arc<WebSocketState>: FromRef<S>,
|
||||||
|
WebSocketConfig: FromRef<S>,
|
||||||
{
|
{
|
||||||
// Log auth result before checking
|
// Log auth result before checking
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
|
|
@ -117,7 +124,7 @@ where
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(ws.on_upgrade(move |socket| {
|
Ok(ws.on_upgrade(move |socket| {
|
||||||
handle_socket(socket, user, channel_id, realm.id, pool, ws_state)
|
handle_socket(socket, user, channel_id, realm.id, pool, ws_state, ws_config)
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -141,6 +148,7 @@ async fn handle_socket(
|
||||||
realm_id: Uuid,
|
realm_id: Uuid,
|
||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
ws_state: Arc<WebSocketState>,
|
ws_state: Arc<WebSocketState>,
|
||||||
|
ws_config: WebSocketConfig,
|
||||||
) {
|
) {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"[WS] handle_socket started for user {} channel {} realm {}",
|
"[WS] handle_socket started for user {} channel {} realm {}",
|
||||||
|
|
@ -217,10 +225,13 @@ async fn handle_socket(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Send welcome message
|
// Send welcome message with config
|
||||||
let welcome = ServerMessage::Welcome {
|
let welcome = ServerMessage::Welcome {
|
||||||
member: member.clone(),
|
member: member.clone(),
|
||||||
members,
|
members,
|
||||||
|
config: WsConfig {
|
||||||
|
ping_interval_secs: ws_config.client_ping_interval_secs,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
if let Ok(json) = serde_json::to_string(&welcome) {
|
if let Ok(json) = serde_json::to_string(&welcome) {
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
|
|
@ -284,269 +295,339 @@ async fn handle_socket(
|
||||||
// and pool for cleanup (leave_channel needs user_id match anyway)
|
// and pool for cleanup (leave_channel needs user_id match anyway)
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
|
// Channel for sending direct messages (Pong) to client
|
||||||
|
let (direct_tx, mut direct_rx) = mpsc::channel::<ServerMessage>(16);
|
||||||
|
|
||||||
|
// Create recv timeout from config
|
||||||
|
let recv_timeout = Duration::from_secs(ws_config.recv_timeout_secs);
|
||||||
|
|
||||||
// Spawn task to handle incoming messages from client
|
// Spawn task to handle incoming messages from client
|
||||||
let recv_task = tokio::spawn(async move {
|
let recv_task = tokio::spawn(async move {
|
||||||
while let Some(Ok(msg)) = receiver.next().await {
|
let mut disconnect_reason = DisconnectReason::Graceful;
|
||||||
if let Message::Text(text) = msg {
|
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
tracing::debug!("[WS<-Client] {}", text);
|
|
||||||
|
|
||||||
let Ok(client_msg) = serde_json::from_str::<ClientMessage>(&text) else {
|
loop {
|
||||||
continue;
|
// Use timeout to detect connection loss
|
||||||
};
|
let msg_result = tokio::time::timeout(recv_timeout, receiver.next()).await;
|
||||||
|
|
||||||
match client_msg {
|
match msg_result {
|
||||||
ClientMessage::UpdatePosition { x, y } => {
|
Ok(Some(Ok(msg))) => {
|
||||||
if let Err(e) =
|
match msg {
|
||||||
channel_members::update_position(&mut *recv_conn, channel_id, user_id, x, y)
|
Message::Text(text) => {
|
||||||
.await
|
|
||||||
{
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
tracing::error!("[WS] Position update failed: {:?}", e);
|
tracing::debug!("[WS<-Client] {}", text);
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let _ = tx.send(ServerMessage::PositionUpdated {
|
|
||||||
user_id: Some(user_id),
|
|
||||||
guest_session_id: None,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
ClientMessage::UpdateEmotion { emotion } => {
|
|
||||||
// Parse emotion name to EmotionState
|
|
||||||
let emotion_state = match emotion.parse::<EmotionState>() {
|
|
||||||
Ok(e) => e,
|
|
||||||
Err(_) => {
|
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
tracing::warn!("[WS] Invalid emotion name: {}", emotion);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let emotion_layer = match avatars::set_emotion(
|
|
||||||
&mut *recv_conn,
|
|
||||||
user_id,
|
|
||||||
realm_id,
|
|
||||||
emotion_state,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(layer) => layer,
|
|
||||||
Err(e) => {
|
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
tracing::error!("[WS] Emotion update failed: {:?}", e);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let _ = tx.send(ServerMessage::EmotionUpdated {
|
|
||||||
user_id: Some(user_id),
|
|
||||||
guest_session_id: None,
|
|
||||||
emotion,
|
|
||||||
emotion_layer,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
ClientMessage::Ping => {
|
|
||||||
// Respond with pong directly (not broadcast)
|
|
||||||
// This is handled in the send task via individual message
|
|
||||||
}
|
|
||||||
ClientMessage::SendChatMessage { content } => {
|
|
||||||
// Validate message
|
|
||||||
if content.is_empty() || content.len() > 500 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get member's current position and emotion
|
let Ok(client_msg) = serde_json::from_str::<ClientMessage>(&text) else {
|
||||||
let member_info = channel_members::get_channel_member(
|
continue;
|
||||||
&mut *recv_conn,
|
|
||||||
channel_id,
|
|
||||||
user_id,
|
|
||||||
realm_id,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if let Ok(Some(member)) = member_info {
|
|
||||||
// Convert emotion index to name
|
|
||||||
let emotion_name = EmotionState::from_index(member.current_emotion as u8)
|
|
||||||
.map(|e| e.to_string())
|
|
||||||
.unwrap_or_else(|| "neutral".to_string());
|
|
||||||
let msg = ServerMessage::ChatMessageReceived {
|
|
||||||
message_id: Uuid::new_v4(),
|
|
||||||
user_id: Some(user_id),
|
|
||||||
guest_session_id: None,
|
|
||||||
display_name: member.display_name.clone(),
|
|
||||||
content,
|
|
||||||
emotion: emotion_name,
|
|
||||||
x: member.position_x,
|
|
||||||
y: member.position_y,
|
|
||||||
timestamp: chrono::Utc::now().timestamp_millis(),
|
|
||||||
};
|
};
|
||||||
let _ = tx.send(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ClientMessage::DropProp { inventory_item_id } => {
|
|
||||||
// Get user's current position for random offset
|
|
||||||
let member_info = channel_members::get_channel_member(
|
|
||||||
&mut *recv_conn,
|
|
||||||
channel_id,
|
|
||||||
user_id,
|
|
||||||
realm_id,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if let Ok(Some(member)) = member_info {
|
match client_msg {
|
||||||
// Generate random offset (within ~50 pixels)
|
ClientMessage::UpdatePosition { x, y } => {
|
||||||
let offset_x = (rand::random::<f64>() - 0.5) * 100.0;
|
if let Err(e) =
|
||||||
let offset_y = (rand::random::<f64>() - 0.5) * 100.0;
|
channel_members::update_position(&mut *recv_conn, channel_id, user_id, x, y)
|
||||||
let pos_x = member.position_x + offset_x;
|
.await
|
||||||
let pos_y = member.position_y + offset_y;
|
{
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
match loose_props::drop_prop_to_canvas(
|
tracing::error!("[WS] Position update failed: {:?}", e);
|
||||||
&mut *recv_conn,
|
continue;
|
||||||
inventory_item_id,
|
}
|
||||||
user_id,
|
let _ = tx.send(ServerMessage::PositionUpdated {
|
||||||
channel_id,
|
user_id: Some(user_id),
|
||||||
pos_x,
|
guest_session_id: None,
|
||||||
pos_y,
|
x,
|
||||||
)
|
y,
|
||||||
.await
|
});
|
||||||
{
|
|
||||||
Ok(prop) => {
|
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
tracing::debug!(
|
|
||||||
"[WS] User {} dropped prop {} at ({}, {})",
|
|
||||||
user_id,
|
|
||||||
prop.id,
|
|
||||||
pos_x,
|
|
||||||
pos_y
|
|
||||||
);
|
|
||||||
let _ = tx.send(ServerMessage::PropDropped { prop });
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
ClientMessage::UpdateEmotion { emotion } => {
|
||||||
tracing::error!("[WS] Drop prop failed: {:?}", e);
|
// Parse emotion name to EmotionState
|
||||||
let (code, message) = match &e {
|
let emotion_state = match emotion.parse::<EmotionState>() {
|
||||||
chattyness_error::AppError::Forbidden(msg) => {
|
Ok(e) => e,
|
||||||
("PROP_NOT_DROPPABLE".to_string(), msg.clone())
|
Err(_) => {
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
tracing::warn!("[WS] Invalid emotion name: {}", emotion);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
chattyness_error::AppError::NotFound(msg) => {
|
|
||||||
("PROP_NOT_FOUND".to_string(), msg.clone())
|
|
||||||
}
|
|
||||||
_ => ("DROP_FAILED".to_string(), format!("{:?}", e)),
|
|
||||||
};
|
};
|
||||||
let _ = tx.send(ServerMessage::Error { code, message });
|
let emotion_layer = match avatars::set_emotion(
|
||||||
|
&mut *recv_conn,
|
||||||
|
user_id,
|
||||||
|
realm_id,
|
||||||
|
emotion_state,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(layer) => layer,
|
||||||
|
Err(e) => {
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
tracing::error!("[WS] Emotion update failed: {:?}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = tx.send(ServerMessage::EmotionUpdated {
|
||||||
|
user_id: Some(user_id),
|
||||||
|
guest_session_id: None,
|
||||||
|
emotion,
|
||||||
|
emotion_layer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ClientMessage::Ping => {
|
||||||
|
// Update last_moved_at to keep member alive for cleanup
|
||||||
|
let _ = channel_members::touch_member(&mut *recv_conn, channel_id, user_id).await;
|
||||||
|
// Respond with pong directly (not broadcast)
|
||||||
|
let _ = direct_tx.send(ServerMessage::Pong).await;
|
||||||
|
}
|
||||||
|
ClientMessage::SendChatMessage { content } => {
|
||||||
|
// Validate message
|
||||||
|
if content.is_empty() || content.len() > 500 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get member's current position and emotion
|
||||||
|
let member_info = channel_members::get_channel_member(
|
||||||
|
&mut *recv_conn,
|
||||||
|
channel_id,
|
||||||
|
user_id,
|
||||||
|
realm_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Ok(Some(member)) = member_info {
|
||||||
|
// Convert emotion index to name
|
||||||
|
let emotion_name = EmotionState::from_index(member.current_emotion as u8)
|
||||||
|
.map(|e| e.to_string())
|
||||||
|
.unwrap_or_else(|| "neutral".to_string());
|
||||||
|
let msg = ServerMessage::ChatMessageReceived {
|
||||||
|
message_id: Uuid::new_v4(),
|
||||||
|
user_id: Some(user_id),
|
||||||
|
guest_session_id: None,
|
||||||
|
display_name: member.display_name.clone(),
|
||||||
|
content,
|
||||||
|
emotion: emotion_name,
|
||||||
|
x: member.position_x,
|
||||||
|
y: member.position_y,
|
||||||
|
timestamp: chrono::Utc::now().timestamp_millis(),
|
||||||
|
};
|
||||||
|
let _ = tx.send(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ClientMessage::DropProp { inventory_item_id } => {
|
||||||
|
// Get user's current position for random offset
|
||||||
|
let member_info = channel_members::get_channel_member(
|
||||||
|
&mut *recv_conn,
|
||||||
|
channel_id,
|
||||||
|
user_id,
|
||||||
|
realm_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Ok(Some(member)) = member_info {
|
||||||
|
// Generate random offset (within ~50 pixels)
|
||||||
|
let offset_x = (rand::random::<f64>() - 0.5) * 100.0;
|
||||||
|
let offset_y = (rand::random::<f64>() - 0.5) * 100.0;
|
||||||
|
let pos_x = member.position_x + offset_x;
|
||||||
|
let pos_y = member.position_y + offset_y;
|
||||||
|
|
||||||
|
match loose_props::drop_prop_to_canvas(
|
||||||
|
&mut *recv_conn,
|
||||||
|
inventory_item_id,
|
||||||
|
user_id,
|
||||||
|
channel_id,
|
||||||
|
pos_x,
|
||||||
|
pos_y,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(prop) => {
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
tracing::debug!(
|
||||||
|
"[WS] User {} dropped prop {} at ({}, {})",
|
||||||
|
user_id,
|
||||||
|
prop.id,
|
||||||
|
pos_x,
|
||||||
|
pos_y
|
||||||
|
);
|
||||||
|
let _ = tx.send(ServerMessage::PropDropped { prop });
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("[WS] Drop prop failed: {:?}", e);
|
||||||
|
let (code, message) = match &e {
|
||||||
|
chattyness_error::AppError::Forbidden(msg) => {
|
||||||
|
("PROP_NOT_DROPPABLE".to_string(), msg.clone())
|
||||||
|
}
|
||||||
|
chattyness_error::AppError::NotFound(msg) => {
|
||||||
|
("PROP_NOT_FOUND".to_string(), msg.clone())
|
||||||
|
}
|
||||||
|
_ => ("DROP_FAILED".to_string(), format!("{:?}", e)),
|
||||||
|
};
|
||||||
|
let _ = tx.send(ServerMessage::Error { code, message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ClientMessage::PickUpProp { loose_prop_id } => {
|
||||||
|
match loose_props::pick_up_loose_prop(
|
||||||
|
&mut *recv_conn,
|
||||||
|
loose_prop_id,
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_inventory_item) => {
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
tracing::debug!(
|
||||||
|
"[WS] User {} picked up prop {}",
|
||||||
|
user_id,
|
||||||
|
loose_prop_id
|
||||||
|
);
|
||||||
|
let _ = tx.send(ServerMessage::PropPickedUp {
|
||||||
|
prop_id: loose_prop_id,
|
||||||
|
picked_up_by_user_id: Some(user_id),
|
||||||
|
picked_up_by_guest_id: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("[WS] Pick up prop failed: {:?}", e);
|
||||||
|
let _ = tx.send(ServerMessage::Error {
|
||||||
|
code: "PICKUP_FAILED".to_string(),
|
||||||
|
message: format!("{:?}", e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ClientMessage::SyncAvatar => {
|
||||||
|
// Fetch current avatar from database and broadcast to channel
|
||||||
|
match avatars::get_avatar_with_paths_conn(
|
||||||
|
&mut *recv_conn,
|
||||||
|
user_id,
|
||||||
|
realm_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(avatar_with_paths)) => {
|
||||||
|
let render_data = avatar_with_paths.to_render_data();
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
tracing::debug!(
|
||||||
|
"[WS] User {} syncing avatar to channel",
|
||||||
|
user_id
|
||||||
|
);
|
||||||
|
let _ = tx.send(ServerMessage::AvatarUpdated {
|
||||||
|
user_id: Some(user_id),
|
||||||
|
guest_session_id: None,
|
||||||
|
avatar: render_data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
tracing::warn!("[WS] No avatar found for user {} to sync", user_id);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("[WS] Avatar sync failed: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
Message::Close(close_frame) => {
|
||||||
ClientMessage::PickUpProp { loose_prop_id } => {
|
// Check close code for scene change
|
||||||
match loose_props::pick_up_loose_prop(
|
if let Some(CloseFrame { code, .. }) = close_frame {
|
||||||
&mut *recv_conn,
|
if code == SCENE_CHANGE_CLOSE_CODE {
|
||||||
loose_prop_id,
|
disconnect_reason = DisconnectReason::SceneChange;
|
||||||
user_id,
|
} else {
|
||||||
)
|
disconnect_reason = DisconnectReason::Graceful;
|
||||||
.await
|
}
|
||||||
{
|
|
||||||
Ok(_inventory_item) => {
|
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
tracing::debug!(
|
|
||||||
"[WS] User {} picked up prop {}",
|
|
||||||
user_id,
|
|
||||||
loose_prop_id
|
|
||||||
);
|
|
||||||
let _ = tx.send(ServerMessage::PropPickedUp {
|
|
||||||
prop_id: loose_prop_id,
|
|
||||||
picked_up_by_user_id: Some(user_id),
|
|
||||||
picked_up_by_guest_id: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("[WS] Pick up prop failed: {:?}", e);
|
|
||||||
let _ = tx.send(ServerMessage::Error {
|
|
||||||
code: "PICKUP_FAILED".to_string(),
|
|
||||||
message: format!("{:?}", e),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
_ => {
|
||||||
ClientMessage::SyncAvatar => {
|
// Ignore binary, ping, pong messages
|
||||||
// Fetch current avatar from database and broadcast to channel
|
|
||||||
match avatars::get_avatar_with_paths_conn(
|
|
||||||
&mut *recv_conn,
|
|
||||||
user_id,
|
|
||||||
realm_id,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(Some(avatar_with_paths)) => {
|
|
||||||
let render_data = avatar_with_paths.to_render_data();
|
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
tracing::debug!(
|
|
||||||
"[WS] User {} syncing avatar to channel",
|
|
||||||
user_id
|
|
||||||
);
|
|
||||||
let _ = tx.send(ServerMessage::AvatarUpdated {
|
|
||||||
user_id: Some(user_id),
|
|
||||||
guest_session_id: None,
|
|
||||||
avatar: render_data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(None) => {
|
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
tracing::warn!("[WS] No avatar found for user {} to sync", user_id);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("[WS] Avatar sync failed: {:?}", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
Ok(Some(Err(e))) => {
|
||||||
}
|
// WebSocket error
|
||||||
// Return the connection so we can use it for cleanup
|
tracing::warn!("[WS] Connection error: {:?}", e);
|
||||||
recv_conn
|
disconnect_reason = DisconnectReason::Timeout;
|
||||||
});
|
break;
|
||||||
|
}
|
||||||
// Spawn task to forward broadcasts to this client
|
Ok(None) => {
|
||||||
let send_task = tokio::spawn(async move {
|
// Stream ended gracefully
|
||||||
while let Ok(msg) = rx.recv().await {
|
break;
|
||||||
if let Ok(json) = serde_json::to_string(&msg) {
|
}
|
||||||
#[cfg(debug_assertions)]
|
Err(_) => {
|
||||||
tracing::debug!("[WS->Client] {}", json);
|
// Timeout elapsed - connection likely lost
|
||||||
if sender.send(Message::Text(json.into())).await.is_err() {
|
tracing::info!("[WS] Connection timeout for user {}", user_id);
|
||||||
|
disconnect_reason = DisconnectReason::Timeout;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Return the connection and disconnect reason for cleanup
|
||||||
|
(recv_conn, disconnect_reason)
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for either task to complete
|
// Spawn task to forward broadcasts and direct messages to this client
|
||||||
tokio::select! {
|
let send_task = tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
// Handle broadcast messages
|
||||||
|
Ok(msg) = rx.recv() => {
|
||||||
|
if let Ok(json) = serde_json::to_string(&msg) {
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
tracing::debug!("[WS->Client] {}", json);
|
||||||
|
if sender.send(Message::Text(json.into())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle direct messages (Pong)
|
||||||
|
Some(msg) = direct_rx.recv() => {
|
||||||
|
if let Ok(json) = serde_json::to_string(&msg) {
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
tracing::debug!("[WS->Client] {}", json);
|
||||||
|
if sender.send(Message::Text(json.into())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else => break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for either task to complete, track disconnect reason
|
||||||
|
let disconnect_reason = tokio::select! {
|
||||||
recv_result = recv_task => {
|
recv_result = recv_task => {
|
||||||
// recv_task finished, get connection back for cleanup
|
// recv_task finished, get connection and reason back for cleanup
|
||||||
if let Ok(mut cleanup_conn) = recv_result {
|
if let Ok((mut cleanup_conn, reason)) = recv_result {
|
||||||
let _ = channel_members::leave_channel(&mut *cleanup_conn, channel_id, user_id).await;
|
let _ = channel_members::leave_channel(&mut *cleanup_conn, channel_id, user_id).await;
|
||||||
|
reason
|
||||||
} else {
|
} else {
|
||||||
// Task panicked, use pool (RLS may fail but try anyway)
|
// Task panicked, use pool (RLS may fail but try anyway)
|
||||||
let _ = channel_members::leave_channel(&pool, channel_id, user_id).await;
|
let _ = channel_members::leave_channel(&pool, channel_id, user_id).await;
|
||||||
|
DisconnectReason::Timeout
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = send_task => {
|
_ = send_task => {
|
||||||
// send_task finished first, need to acquire a new connection for cleanup
|
// send_task finished first (likely client disconnect), need to acquire a new connection for cleanup
|
||||||
if let Ok(mut cleanup_conn) = pool.acquire().await {
|
if let Ok(mut cleanup_conn) = pool.acquire().await {
|
||||||
let _ = set_rls_user_id(&mut cleanup_conn, user_id).await;
|
let _ = set_rls_user_id(&mut cleanup_conn, user_id).await;
|
||||||
let _ = channel_members::leave_channel(&mut *cleanup_conn, channel_id, user_id).await;
|
let _ = channel_members::leave_channel(&mut *cleanup_conn, channel_id, user_id).await;
|
||||||
}
|
}
|
||||||
|
DisconnectReason::Graceful
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"[WS] User {} disconnected from channel {}",
|
"[WS] User {} disconnected from channel {} (reason: {:?})",
|
||||||
user_id,
|
user_id,
|
||||||
channel_id
|
channel_id,
|
||||||
|
disconnect_reason
|
||||||
);
|
);
|
||||||
|
|
||||||
// Broadcast departure
|
// Broadcast departure with reason
|
||||||
let _ = channel_state.tx.send(ServerMessage::MemberLeft {
|
let _ = channel_state.tx.send(ServerMessage::MemberLeft {
|
||||||
user_id: Some(user_id),
|
user_id: Some(user_id),
|
||||||
guest_session_id: None,
|
guest_session_id: None,
|
||||||
|
reason: disconnect_reason,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ use std::sync::Arc;
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
use crate::api::WebSocketState;
|
use crate::api::WebSocketState;
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
use chattyness_shared::WebSocketConfig;
|
||||||
|
|
||||||
/// Application state for the public app.
|
/// Application state for the public app.
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|
@ -19,6 +22,7 @@ pub struct AppState {
|
||||||
pub pool: sqlx::PgPool,
|
pub pool: sqlx::PgPool,
|
||||||
pub leptos_options: LeptosOptions,
|
pub leptos_options: LeptosOptions,
|
||||||
pub ws_state: Arc<WebSocketState>,
|
pub ws_state: Arc<WebSocketState>,
|
||||||
|
pub ws_config: WebSocketConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
|
|
@ -42,6 +46,13 @@ impl axum::extract::FromRef<AppState> for Arc<WebSocketState> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
impl axum::extract::FromRef<AppState> for WebSocketConfig {
|
||||||
|
fn from_ref(state: &AppState) -> Self {
|
||||||
|
state.ws_config.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Shell component for SSR.
|
/// Shell component for SSR.
|
||||||
pub fn shell(options: LeptosOptions) -> impl IntoView {
|
pub fn shell(options: LeptosOptions) -> impl IntoView {
|
||||||
view! {
|
view! {
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,9 @@ pub fn AvatarCanvas(
|
||||||
/// Text size multiplier for display names, chat bubbles, and badges.
|
/// Text size multiplier for display names, chat bubbles, and badges.
|
||||||
#[prop(default = 1.0)]
|
#[prop(default = 1.0)]
|
||||||
text_em_size: f64,
|
text_em_size: f64,
|
||||||
|
/// Opacity for fade-out animation (0.0 to 1.0, default 1.0).
|
||||||
|
#[prop(default = 1.0)]
|
||||||
|
opacity: f64,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
let canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
||||||
|
|
||||||
|
|
@ -171,19 +174,24 @@ pub fn AvatarCanvas(
|
||||||
let adjusted_y = canvas_y - bubble_extra;
|
let adjusted_y = canvas_y - bubble_extra;
|
||||||
|
|
||||||
// CSS positioning via transform (GPU-accelerated)
|
// CSS positioning via transform (GPU-accelerated)
|
||||||
|
// Disable pointer-events when fading (opacity < 1.0) so fading avatars aren't clickable
|
||||||
|
let pointer_events = if opacity < 1.0 { "none" } else { "auto" };
|
||||||
let style = format!(
|
let style = format!(
|
||||||
"position: absolute; \
|
"position: absolute; \
|
||||||
left: 0; top: 0; \
|
left: 0; top: 0; \
|
||||||
transform: translate({}px, {}px); \
|
transform: translate({}px, {}px); \
|
||||||
z-index: {}; \
|
z-index: {}; \
|
||||||
pointer-events: auto; \
|
pointer-events: {}; \
|
||||||
width: {}px; \
|
width: {}px; \
|
||||||
height: {}px;",
|
height: {}px; \
|
||||||
|
opacity: {};",
|
||||||
canvas_x - (canvas_width - avatar_size) / 2.0,
|
canvas_x - (canvas_width - avatar_size) / 2.0,
|
||||||
adjusted_y,
|
adjusted_y,
|
||||||
z_index,
|
z_index,
|
||||||
|
pointer_events,
|
||||||
canvas_width,
|
canvas_width,
|
||||||
canvas_height
|
canvas_height,
|
||||||
|
opacity
|
||||||
);
|
);
|
||||||
|
|
||||||
// Store references for the effect
|
// Store references for the effect
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ use super::chat_types::ActiveBubble;
|
||||||
use super::settings::{
|
use super::settings::{
|
||||||
calculate_min_zoom, ViewerSettings, BASE_PROP_SIZE, REFERENCE_HEIGHT, REFERENCE_WIDTH,
|
calculate_min_zoom, ViewerSettings, BASE_PROP_SIZE, REFERENCE_HEIGHT, REFERENCE_WIDTH,
|
||||||
};
|
};
|
||||||
|
use super::ws_client::FadingMember;
|
||||||
use crate::utils::parse_bounds_dimensions;
|
use crate::utils::parse_bounds_dimensions;
|
||||||
|
|
||||||
/// Scene viewer component for displaying a realm scene with avatars.
|
/// Scene viewer component for displaying a realm scene with avatars.
|
||||||
|
|
@ -49,6 +50,9 @@ pub fn RealmSceneViewer(
|
||||||
/// Callback for zoom changes (from mouse wheel). Receives zoom delta.
|
/// Callback for zoom changes (from mouse wheel). Receives zoom delta.
|
||||||
#[prop(optional)]
|
#[prop(optional)]
|
||||||
on_zoom_change: Option<Callback<f64>>,
|
on_zoom_change: Option<Callback<f64>>,
|
||||||
|
/// Members that are fading out after timeout disconnect.
|
||||||
|
#[prop(optional, into)]
|
||||||
|
fading_members: Option<Signal<Vec<FadingMember>>>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
// 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));
|
||||||
|
|
@ -774,7 +778,8 @@ pub fn RealmSceneViewer(
|
||||||
let ps = prop_size.get();
|
let ps = prop_size.get();
|
||||||
let te = text_em_size.get();
|
let te = text_em_size.get();
|
||||||
|
|
||||||
sorted_members.get()
|
// Render active members
|
||||||
|
let mut views: Vec<_> = sorted_members.get()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(idx, member)| {
|
.map(|(idx, member)| {
|
||||||
|
|
@ -795,7 +800,39 @@ pub fn RealmSceneViewer(
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect_view()
|
.collect();
|
||||||
|
|
||||||
|
// Render fading members with calculated opacity
|
||||||
|
if let Some(fading_signal) = fading_members {
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let now = js_sys::Date::now() as i64;
|
||||||
|
#[cfg(not(feature = "hydrate"))]
|
||||||
|
let now = 0i64;
|
||||||
|
|
||||||
|
for fading in fading_signal.get() {
|
||||||
|
let elapsed = now - fading.fade_start;
|
||||||
|
if elapsed < fading.fade_duration {
|
||||||
|
let opacity = 1.0 - (elapsed as f64 / fading.fade_duration as f64);
|
||||||
|
let opacity = opacity.max(0.0).min(1.0);
|
||||||
|
views.push(view! {
|
||||||
|
<AvatarCanvas
|
||||||
|
member=fading.member
|
||||||
|
scale_x=sx
|
||||||
|
scale_y=sy
|
||||||
|
offset_x=ox
|
||||||
|
offset_y=oy
|
||||||
|
prop_size=ps
|
||||||
|
z_index=5
|
||||||
|
active_bubble=None
|
||||||
|
text_em_size=te
|
||||||
|
opacity=opacity
|
||||||
|
/>
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
views.into_iter().collect_view()
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
// Click overlay - captures clicks for movement and hit-testing
|
// Click overlay - captures clicks for movement and hit-testing
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,32 @@
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::reactive::owner::LocalStorage;
|
use leptos::reactive::owner::LocalStorage;
|
||||||
|
|
||||||
use chattyness_db::models::{ChannelMemberWithAvatar, EmotionState, LooseProp};
|
use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp};
|
||||||
use chattyness_db::ws_messages::{ClientMessage, ServerMessage};
|
#[cfg(feature = "hydrate")]
|
||||||
|
use chattyness_db::models::EmotionState;
|
||||||
|
use chattyness_db::ws_messages::ClientMessage;
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
use chattyness_db::ws_messages::{DisconnectReason, ServerMessage, WsConfig};
|
||||||
|
|
||||||
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.
|
||||||
|
pub const FADE_DURATION_MS: i64 = 5000;
|
||||||
|
|
||||||
|
/// A member that is currently fading out after a timeout disconnect.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct FadingMember {
|
||||||
|
/// The member data.
|
||||||
|
pub member: ChannelMemberWithAvatar,
|
||||||
|
/// Timestamp when the fade started (milliseconds since epoch).
|
||||||
|
pub fade_start: i64,
|
||||||
|
/// Duration of the fade in milliseconds.
|
||||||
|
pub fade_duration: i64,
|
||||||
|
}
|
||||||
|
|
||||||
/// WebSocket connection state.
|
/// WebSocket connection state.
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
pub enum WsState {
|
pub enum WsState {
|
||||||
|
|
@ -44,6 +65,7 @@ pub fn use_channel_websocket(
|
||||||
on_loose_props_sync: Callback<Vec<LooseProp>>,
|
on_loose_props_sync: Callback<Vec<LooseProp>>,
|
||||||
on_prop_dropped: Callback<LooseProp>,
|
on_prop_dropped: Callback<LooseProp>,
|
||||||
on_prop_picked_up: Callback<uuid::Uuid>,
|
on_prop_picked_up: Callback<uuid::Uuid>,
|
||||||
|
on_member_fading: Callback<FadingMember>,
|
||||||
) -> (Signal<WsState>, WsSenderStorage) {
|
) -> (Signal<WsState>, WsSenderStorage) {
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
@ -139,6 +161,11 @@ pub fn use_channel_websocket(
|
||||||
let on_loose_props_sync_clone = on_loose_props_sync.clone();
|
let on_loose_props_sync_clone = on_loose_props_sync.clone();
|
||||||
let on_prop_dropped_clone = on_prop_dropped.clone();
|
let on_prop_dropped_clone = on_prop_dropped.clone();
|
||||||
let on_prop_picked_up_clone = on_prop_picked_up.clone();
|
let on_prop_picked_up_clone = on_prop_picked_up.clone();
|
||||||
|
let on_member_fading_clone = on_member_fading.clone();
|
||||||
|
// For starting heartbeat on Welcome
|
||||||
|
let ws_ref_for_heartbeat = ws_ref.clone();
|
||||||
|
let heartbeat_started: Rc<RefCell<bool>> = Rc::new(RefCell::new(false));
|
||||||
|
let heartbeat_started_clone = heartbeat_started.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();
|
||||||
|
|
@ -146,6 +173,28 @@ pub fn use_channel_websocket(
|
||||||
web_sys::console::log_1(&format!("[WS<-Server] {}", text).into());
|
web_sys::console::log_1(&format!("[WS<-Server] {}", text).into());
|
||||||
|
|
||||||
if let Ok(msg) = serde_json::from_str::<ServerMessage>(&text) {
|
if let Ok(msg) = serde_json::from_str::<ServerMessage>(&text) {
|
||||||
|
// Check for Welcome message to start heartbeat with server-provided config
|
||||||
|
if let ServerMessage::Welcome { ref config, .. } = msg {
|
||||||
|
if !*heartbeat_started_clone.borrow() {
|
||||||
|
*heartbeat_started_clone.borrow_mut() = true;
|
||||||
|
let ping_interval_ms = config.ping_interval_secs * 1000;
|
||||||
|
let ws_ref_ping = ws_ref_for_heartbeat.clone();
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!("[WS] Starting heartbeat with interval {}ms", ping_interval_ms).into(),
|
||||||
|
);
|
||||||
|
let heartbeat = gloo_timers::callback::Interval::new(ping_interval_ms as u32, move || {
|
||||||
|
if let Some(ws) = ws_ref_ping.borrow().as_ref() {
|
||||||
|
if ws.ready_state() == WebSocket::OPEN {
|
||||||
|
if let Ok(json) = serde_json::to_string(&ClientMessage::Ping) {
|
||||||
|
let _ = ws.send_with_str(&json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
std::mem::forget(heartbeat);
|
||||||
|
}
|
||||||
|
}
|
||||||
handle_server_message(
|
handle_server_message(
|
||||||
msg,
|
msg,
|
||||||
&members_for_msg,
|
&members_for_msg,
|
||||||
|
|
@ -154,6 +203,7 @@ pub fn use_channel_websocket(
|
||||||
&on_loose_props_sync_clone,
|
&on_loose_props_sync_clone,
|
||||||
&on_prop_dropped_clone,
|
&on_prop_dropped_clone,
|
||||||
&on_prop_picked_up_clone,
|
&on_prop_picked_up_clone,
|
||||||
|
&on_member_fading_clone,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -199,6 +249,7 @@ fn handle_server_message(
|
||||||
on_loose_props_sync: &Callback<Vec<LooseProp>>,
|
on_loose_props_sync: &Callback<Vec<LooseProp>>,
|
||||||
on_prop_dropped: &Callback<LooseProp>,
|
on_prop_dropped: &Callback<LooseProp>,
|
||||||
on_prop_picked_up: &Callback<uuid::Uuid>,
|
on_prop_picked_up: &Callback<uuid::Uuid>,
|
||||||
|
on_member_fading: &Callback<FadingMember>,
|
||||||
) {
|
) {
|
||||||
let mut members_vec = members.borrow_mut();
|
let mut members_vec = members.borrow_mut();
|
||||||
|
|
||||||
|
|
@ -206,6 +257,7 @@ fn handle_server_message(
|
||||||
ServerMessage::Welcome {
|
ServerMessage::Welcome {
|
||||||
member: _,
|
member: _,
|
||||||
members: initial_members,
|
members: initial_members,
|
||||||
|
config: _, // Config is handled in the caller for heartbeat setup
|
||||||
} => {
|
} => {
|
||||||
*members_vec = initial_members;
|
*members_vec = initial_members;
|
||||||
on_update.run(members_vec.clone());
|
on_update.run(members_vec.clone());
|
||||||
|
|
@ -222,11 +274,31 @@ fn handle_server_message(
|
||||||
ServerMessage::MemberLeft {
|
ServerMessage::MemberLeft {
|
||||||
user_id,
|
user_id,
|
||||||
guest_session_id,
|
guest_session_id,
|
||||||
|
reason,
|
||||||
} => {
|
} => {
|
||||||
|
// Find the member before removing
|
||||||
|
let leaving_member = members_vec
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.member.user_id == user_id && m.member.guest_session_id == guest_session_id)
|
||||||
|
.cloned();
|
||||||
|
|
||||||
|
// Always remove from active members list
|
||||||
members_vec.retain(|m| {
|
members_vec.retain(|m| {
|
||||||
m.member.user_id != user_id || m.member.guest_session_id != guest_session_id
|
m.member.user_id != user_id || m.member.guest_session_id != guest_session_id
|
||||||
});
|
});
|
||||||
on_update.run(members_vec.clone());
|
on_update.run(members_vec.clone());
|
||||||
|
|
||||||
|
// For timeout disconnects, trigger fading animation
|
||||||
|
if reason == DisconnectReason::Timeout {
|
||||||
|
if let Some(member) = leaving_member {
|
||||||
|
let fading = FadingMember {
|
||||||
|
member,
|
||||||
|
fade_start: js_sys::Date::now() as i64,
|
||||||
|
fade_duration: FADE_DURATION_MS,
|
||||||
|
};
|
||||||
|
on_member_fading.run(fading);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ServerMessage::PositionUpdated {
|
ServerMessage::PositionUpdated {
|
||||||
user_id,
|
user_id,
|
||||||
|
|
@ -333,6 +405,7 @@ pub fn use_channel_websocket(
|
||||||
_on_loose_props_sync: Callback<Vec<LooseProp>>,
|
_on_loose_props_sync: Callback<Vec<LooseProp>>,
|
||||||
_on_prop_dropped: Callback<LooseProp>,
|
_on_prop_dropped: Callback<LooseProp>,
|
||||||
_on_prop_picked_up: Callback<uuid::Uuid>,
|
_on_prop_picked_up: Callback<uuid::Uuid>,
|
||||||
|
_on_member_fading: Callback<FadingMember>,
|
||||||
) -> (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);
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,15 @@ use leptos_router::hooks::use_params_map;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::components::{
|
use crate::components::{
|
||||||
ActiveBubble, AvatarEditorPopup, Card, ChatInput, ChatMessage, EmotionKeybindings,
|
ActiveBubble, AvatarEditorPopup, Card, ChatInput, EmotionKeybindings, FadingMember,
|
||||||
InventoryPopup, KeybindingsPopup, MessageLog, RealmHeader, RealmSceneViewer, SettingsPopup,
|
InventoryPopup, KeybindingsPopup, MessageLog, RealmHeader, RealmSceneViewer, SettingsPopup,
|
||||||
ViewerSettings, DEFAULT_BUBBLE_TIMEOUT_MS,
|
ViewerSettings,
|
||||||
};
|
};
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
use crate::components::use_channel_websocket;
|
use crate::components::{use_channel_websocket, ChatMessage, DEFAULT_BUBBLE_TIMEOUT_MS, FADE_DURATION_MS};
|
||||||
use crate::utils::{parse_bounds_dimensions, LocalStoragePersist};
|
use crate::utils::LocalStoragePersist;
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
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,
|
||||||
|
|
@ -82,6 +84,9 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
// Loose props state
|
// Loose props state
|
||||||
let (loose_props, set_loose_props) = signal(Vec::<LooseProp>::new());
|
let (loose_props, set_loose_props) = signal(Vec::<LooseProp>::new());
|
||||||
|
|
||||||
|
// Fading members state (members that are fading out after timeout disconnect)
|
||||||
|
let (fading_members, set_fading_members) = signal(Vec::<FadingMember>::new());
|
||||||
|
|
||||||
// Track user's current position for saving on beforeunload
|
// Track user's current position for saving on beforeunload
|
||||||
let (current_position, set_current_position) = signal((400.0_f64, 300.0_f64));
|
let (current_position, set_current_position) = signal((400.0_f64, 300.0_f64));
|
||||||
|
|
||||||
|
|
@ -173,6 +178,15 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
// WebSocket connection for real-time updates
|
// WebSocket connection for real-time updates
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
let on_members_update = Callback::new(move |new_members: Vec<ChannelMemberWithAvatar>| {
|
let on_members_update = Callback::new(move |new_members: Vec<ChannelMemberWithAvatar>| {
|
||||||
|
// When members are updated (including rejoins), remove any matching fading members
|
||||||
|
set_fading_members.update(|fading| {
|
||||||
|
fading.retain(|f| {
|
||||||
|
!new_members.iter().any(|m| {
|
||||||
|
m.member.user_id == f.member.member.user_id
|
||||||
|
&& m.member.guest_session_id == f.member.member.guest_session_id
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
set_members.set(new_members);
|
set_members.set(new_members);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -217,6 +231,19 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Callback when a member starts fading (timeout disconnect)
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let on_member_fading = Callback::new(move |fading: FadingMember| {
|
||||||
|
set_fading_members.update(|members| {
|
||||||
|
// Remove any existing entry for this user (shouldn't happen, but be safe)
|
||||||
|
members.retain(|m| {
|
||||||
|
m.member.member.user_id != fading.member.member.user_id
|
||||||
|
|| m.member.member.guest_session_id != fading.member.member.guest_session_id
|
||||||
|
});
|
||||||
|
members.push(fading);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
let (_ws_state, ws_sender) = use_channel_websocket(
|
let (_ws_state, ws_sender) = use_channel_websocket(
|
||||||
slug,
|
slug,
|
||||||
|
|
@ -226,6 +253,7 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
on_loose_props_sync,
|
on_loose_props_sync,
|
||||||
on_prop_dropped,
|
on_prop_dropped,
|
||||||
on_prop_picked_up,
|
on_prop_picked_up,
|
||||||
|
on_member_fading,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set channel ID and scene dimensions when scene loads
|
// Set channel ID and scene dimensions when scene loads
|
||||||
|
|
@ -246,16 +274,21 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup expired speech bubbles every 5 seconds
|
// Cleanup expired speech bubbles and fading members every second
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
{
|
{
|
||||||
use gloo_timers::callback::Interval;
|
use gloo_timers::callback::Interval;
|
||||||
|
|
||||||
let cleanup_interval = Interval::new(5000, move || {
|
let cleanup_interval = Interval::new(1000, move || {
|
||||||
let now = js_sys::Date::now() as i64;
|
let now = js_sys::Date::now() as i64;
|
||||||
|
// Clean up expired bubbles
|
||||||
set_active_bubbles.update(|bubbles| {
|
set_active_bubbles.update(|bubbles| {
|
||||||
bubbles.retain(|_, bubble| bubble.expires_at > now);
|
bubbles.retain(|_, bubble| bubble.expires_at > now);
|
||||||
});
|
});
|
||||||
|
// Clean up completed fading members
|
||||||
|
set_fading_members.update(|members| {
|
||||||
|
members.retain(|m| now - m.fade_start < FADE_DURATION_MS);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
// Keep interval alive
|
// Keep interval alive
|
||||||
std::mem::forget(cleanup_interval);
|
std::mem::forget(cleanup_interval);
|
||||||
|
|
@ -638,6 +671,7 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
s.save();
|
s.save();
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
fading_members=Signal::derive(move || fading_members.get())
|
||||||
/>
|
/>
|
||||||
<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">
|
||||||
<ChatInput
|
<ChatInput
|
||||||
|
|
|
||||||
|
|
@ -277,4 +277,45 @@ $$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
COMMENT ON FUNCTION audit.log_event IS 'Helper to create audit log entries';
|
COMMENT ON FUNCTION audit.log_event IS 'Helper to create audit log entries';
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Instance Member Maintenance Functions
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Clear all instance members (for server startup cleanup)
|
||||||
|
-- Uses SECURITY DEFINER to bypass RLS
|
||||||
|
CREATE OR REPLACE FUNCTION scene.clear_all_instance_members()
|
||||||
|
RETURNS BIGINT AS $$
|
||||||
|
DECLARE
|
||||||
|
deleted_count BIGINT;
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM scene.instance_members;
|
||||||
|
GET DIAGNOSTICS deleted_count = ROW_COUNT;
|
||||||
|
RETURN deleted_count;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION scene.clear_all_instance_members() IS
|
||||||
|
'Clears all instance members on server startup. Bypasses RLS.';
|
||||||
|
|
||||||
|
-- Clear stale instance members based on last_moved_at threshold
|
||||||
|
-- Uses SECURITY DEFINER to bypass RLS
|
||||||
|
CREATE OR REPLACE FUNCTION scene.clear_stale_instance_members(threshold_seconds DOUBLE PRECISION)
|
||||||
|
RETURNS BIGINT AS $$
|
||||||
|
DECLARE
|
||||||
|
deleted_count BIGINT;
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM scene.instance_members
|
||||||
|
WHERE last_moved_at < NOW() - make_interval(secs => threshold_seconds);
|
||||||
|
GET DIAGNOSTICS deleted_count = ROW_COUNT;
|
||||||
|
RETURN deleted_count;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION scene.clear_stale_instance_members(DOUBLE PRECISION) IS
|
||||||
|
'Clears stale instance members older than threshold. Bypasses RLS.';
|
||||||
|
|
||||||
|
-- Grant execute to chattyness_app
|
||||||
|
GRANT EXECUTE ON FUNCTION scene.clear_all_instance_members() TO chattyness_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION scene.clear_stale_instance_members(DOUBLE PRECISION) TO chattyness_app;
|
||||||
|
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue