Compare commits
5 commits
3da420fe59
...
be60cf9a3d
| Author | SHA256 | Date | |
|---|---|---|---|
| be60cf9a3d | |||
| 39b5ac3f1d | |||
| 8c2e5d4f61 | |||
| ed1a1f10f9 | |||
| 31e01292f9 |
18 changed files with 1153 additions and 352 deletions
135
Cargo.toml
Normal file
135
Cargo.toml
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = [
|
||||||
|
"crates/chattyness-db",
|
||||||
|
"crates/chattyness-error",
|
||||||
|
"crates/chattyness-shared",
|
||||||
|
"crates/chattyness-admin-ui",
|
||||||
|
"crates/chattyness-user-ui",
|
||||||
|
"apps/chattyness-owner",
|
||||||
|
"apps/chattyness-app",
|
||||||
|
]
|
||||||
|
exclude = ["chattyness-webserver"]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
license = "MIT"
|
||||||
|
repository = "https://github.com/your-org/chattyness"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
# Internal crates
|
||||||
|
chattyness-db = { path = "crates/chattyness-db" }
|
||||||
|
chattyness-error = { path = "crates/chattyness-error" }
|
||||||
|
chattyness-shared = { path = "crates/chattyness-shared" }
|
||||||
|
chattyness-admin-ui = { path = "crates/chattyness-admin-ui" }
|
||||||
|
chattyness-user-ui = { path = "crates/chattyness-user-ui" }
|
||||||
|
|
||||||
|
# Leptos framework
|
||||||
|
leptos = "0.8"
|
||||||
|
leptos_meta = "0.8"
|
||||||
|
leptos_router = "0.8"
|
||||||
|
leptos_axum = "0.8"
|
||||||
|
|
||||||
|
# Axum web server
|
||||||
|
axum = { version = "0.8", features = ["macros", "ws"] }
|
||||||
|
axum-extra = { version = "0.10", features = ["multipart"] }
|
||||||
|
tower = "0.5"
|
||||||
|
tower-http = { version = "0.6", features = ["fs", "compression-gzip"] }
|
||||||
|
|
||||||
|
# Session management
|
||||||
|
tower-sessions = "0.14"
|
||||||
|
tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] }
|
||||||
|
|
||||||
|
# Database
|
||||||
|
sqlx = { version = "0.8", features = [
|
||||||
|
"runtime-tokio",
|
||||||
|
"tls-rustls",
|
||||||
|
"postgres",
|
||||||
|
"uuid",
|
||||||
|
"chrono",
|
||||||
|
"json"
|
||||||
|
] }
|
||||||
|
|
||||||
|
# Serialization
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
# Error handling
|
||||||
|
thiserror = "2"
|
||||||
|
|
||||||
|
# CLI
|
||||||
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
|
|
||||||
|
# Async runtime
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
futures = "0.3"
|
||||||
|
|
||||||
|
# Concurrent data structures
|
||||||
|
dashmap = "6"
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
toml = "0.8"
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
uuid = { version = "1", features = ["v4", "serde", "js"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
dotenvy = "0.15"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
http = "1"
|
||||||
|
regex = "1"
|
||||||
|
|
||||||
|
# Password hashing
|
||||||
|
argon2 = "0.5"
|
||||||
|
|
||||||
|
# Image processing
|
||||||
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||||
|
|
||||||
|
# Random token generation
|
||||||
|
rand = "0.8"
|
||||||
|
sha2 = "0.10"
|
||||||
|
hex = "0.4"
|
||||||
|
|
||||||
|
# WASM dependencies
|
||||||
|
console_error_panic_hook = "0.1"
|
||||||
|
wasm-bindgen = "0.2"
|
||||||
|
gloo-net = "0.6"
|
||||||
|
gloo-timers = "0.3"
|
||||||
|
urlencoding = "2"
|
||||||
|
web-sys = { version = "0.3", features = [
|
||||||
|
"Blob",
|
||||||
|
"CanvasRenderingContext2d",
|
||||||
|
"CloseEvent",
|
||||||
|
"Document",
|
||||||
|
"DomRect",
|
||||||
|
"Element",
|
||||||
|
"ErrorEvent",
|
||||||
|
"EventTarget",
|
||||||
|
"File",
|
||||||
|
"FileList",
|
||||||
|
"FormData",
|
||||||
|
"HtmlCanvasElement",
|
||||||
|
"HtmlCollection",
|
||||||
|
"HtmlElement",
|
||||||
|
"HtmlFormElement",
|
||||||
|
"HtmlImageElement",
|
||||||
|
"HtmlInputElement",
|
||||||
|
"ImageData",
|
||||||
|
"KeyboardEvent",
|
||||||
|
"Location",
|
||||||
|
"MessageEvent",
|
||||||
|
"MouseEvent",
|
||||||
|
"Node",
|
||||||
|
"Storage",
|
||||||
|
"TextMetrics",
|
||||||
|
"WebSocket",
|
||||||
|
"Window",
|
||||||
|
] }
|
||||||
|
js-sys = "0.3"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = true
|
||||||
|
opt-level = 'z'
|
||||||
|
codegen-units = 1
|
||||||
|
|
@ -1619,6 +1619,43 @@ pub struct GuestLoginResponse {
|
||||||
pub realm: RealmSummary,
|
pub realm: RealmSummary,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Request to register a guest as a full user.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RegisterGuestRequest {
|
||||||
|
pub username: String,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub password: String,
|
||||||
|
pub confirm_password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
impl RegisterGuestRequest {
|
||||||
|
/// Validate the registration request.
|
||||||
|
pub fn validate(&self) -> Result<(), AppError> {
|
||||||
|
validation::validate_username(&self.username)?;
|
||||||
|
|
||||||
|
// Email: basic format if provided and non-empty
|
||||||
|
if let Some(ref email) = self.email {
|
||||||
|
let email_trimmed = email.trim();
|
||||||
|
if !email_trimmed.is_empty() && !validation::is_valid_email(email_trimmed) {
|
||||||
|
return Err(AppError::Validation("Invalid email address".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validation::validate_password(&self.password)?;
|
||||||
|
validation::validate_passwords_match(&self.password, &self.confirm_password)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response after successful guest registration.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RegisterGuestResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Request to join a realm.
|
/// Request to join a realm.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct JoinRealmRequest {
|
pub struct JoinRealmRequest {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! User-related database queries.
|
//! User-related database queries.
|
||||||
|
|
||||||
use sqlx::PgPool;
|
use sqlx::{PgConnection, PgPool};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::models::{StaffMember, User, UserWithAuth};
|
use crate::models::{StaffMember, User, UserWithAuth};
|
||||||
|
|
@ -499,6 +499,74 @@ pub async fn get_staff_member(
|
||||||
Ok(staff)
|
Ok(staff)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Upgrade a guest user to a full user account.
|
||||||
|
///
|
||||||
|
/// Updates the user's username, password, and optionally email,
|
||||||
|
/// and removes the 'guest' tag. Returns error if the user is not a guest.
|
||||||
|
pub async fn upgrade_guest_to_user(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
email: Option<&str>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let mut conn = pool.acquire().await?;
|
||||||
|
upgrade_guest_to_user_conn(&mut *conn, user_id, username, password, email).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upgrade a guest user to a full user account using an existing connection.
|
||||||
|
///
|
||||||
|
/// Updates the user's username, password, and optionally email,
|
||||||
|
/// and removes the 'guest' tag. Returns error if the user is not a guest.
|
||||||
|
/// Use this version when you need RLS context set on the connection.
|
||||||
|
pub async fn upgrade_guest_to_user_conn(
|
||||||
|
conn: &mut PgConnection,
|
||||||
|
user_id: Uuid,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
email: Option<&str>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
use argon2::{
|
||||||
|
Argon2, PasswordHasher,
|
||||||
|
password_hash::{SaltString, rand_core::OsRng},
|
||||||
|
};
|
||||||
|
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let argon2 = Argon2::default();
|
||||||
|
let password_hash = argon2
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to hash password: {}", e)))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let result: sqlx::postgres::PgQueryResult = sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE auth.users
|
||||||
|
SET username = $2,
|
||||||
|
password_hash = $3,
|
||||||
|
email = $4,
|
||||||
|
display_name = $2,
|
||||||
|
tags = array_remove(tags, 'guest'),
|
||||||
|
auth_provider = 'local',
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1 AND 'guest' = ANY(tags)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(username)
|
||||||
|
.bind(&password_hash)
|
||||||
|
.bind(email)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(AppError::Forbidden(
|
||||||
|
"User is not a guest or does not exist".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a guest user (no password required).
|
/// Create a guest user (no password required).
|
||||||
///
|
///
|
||||||
/// Guests are created with the 'guest' tag and no password.
|
/// Guests are created with the 'guest' tag and no password.
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,10 @@ pub enum ClientMessage {
|
||||||
/// Arguments for the subcommand.
|
/// Arguments for the subcommand.
|
||||||
args: Vec<String>,
|
args: Vec<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Request to refresh identity after registration (guest → user conversion).
|
||||||
|
/// Server will fetch updated user data and broadcast to all members.
|
||||||
|
RefreshIdentity,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Server-to-client WebSocket messages.
|
/// Server-to-client WebSocket messages.
|
||||||
|
|
@ -260,4 +264,14 @@ pub enum ServerMessage {
|
||||||
/// Human-readable result message.
|
/// Human-readable result message.
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// A member's identity was updated (e.g., guest registered as user).
|
||||||
|
MemberIdentityUpdated {
|
||||||
|
/// User ID of the member.
|
||||||
|
user_id: Uuid,
|
||||||
|
/// New display name.
|
||||||
|
display_name: String,
|
||||||
|
/// Whether the member is still a guest.
|
||||||
|
is_guest: bool,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ use chattyness_db::{
|
||||||
AccountStatus, AuthenticatedUser, CurrentUserResponse, GuestLoginRequest,
|
AccountStatus, AuthenticatedUser, CurrentUserResponse, GuestLoginRequest,
|
||||||
GuestLoginResponse, JoinRealmRequest, JoinRealmResponse, LoginRequest, LoginResponse,
|
GuestLoginResponse, JoinRealmRequest, JoinRealmResponse, LoginRequest, LoginResponse,
|
||||||
LoginType, PasswordResetRequest, PasswordResetResponse, RealmRole, RealmSummary,
|
LoginType, PasswordResetRequest, PasswordResetResponse, RealmRole, RealmSummary,
|
||||||
SignupRequest, SignupResponse, UserSummary,
|
RegisterGuestRequest, RegisterGuestResponse, SignupRequest, SignupResponse, UserSummary,
|
||||||
},
|
},
|
||||||
queries::{guests, memberships, realms, users},
|
queries::{guests, memberships, realms, users},
|
||||||
};
|
};
|
||||||
|
|
@ -471,3 +471,62 @@ pub async fn reset_password(
|
||||||
redirect_url,
|
redirect_url,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Register guest handler.
|
||||||
|
///
|
||||||
|
/// Upgrades a guest user to a full user account with username and password.
|
||||||
|
pub async fn register_guest(
|
||||||
|
rls_conn: crate::auth::RlsConn,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
session: Session,
|
||||||
|
AuthUser(user): AuthUser,
|
||||||
|
Json(req): Json<RegisterGuestRequest>,
|
||||||
|
) -> Result<Json<RegisterGuestResponse>, AppError> {
|
||||||
|
// Validate the request
|
||||||
|
req.validate()?;
|
||||||
|
|
||||||
|
// Verify user is a guest
|
||||||
|
if !user.is_guest() {
|
||||||
|
return Err(AppError::Forbidden(
|
||||||
|
"Only guests can register for an account".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check username availability
|
||||||
|
if users::username_exists(&pool, &req.username).await? {
|
||||||
|
return Err(AppError::Conflict("Username already taken".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check email availability if provided
|
||||||
|
if let Some(ref email) = req.email {
|
||||||
|
let email_trimmed = email.trim();
|
||||||
|
if !email_trimmed.is_empty() && users::email_exists(&pool, email_trimmed).await? {
|
||||||
|
return Err(AppError::Conflict("Email already registered".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get email as Option<&str>
|
||||||
|
let email_opt = req.email.as_ref().and_then(|e| {
|
||||||
|
let trimmed = e.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upgrade the guest to a full user using RLS connection
|
||||||
|
let mut conn = rls_conn.acquire().await;
|
||||||
|
users::upgrade_guest_to_user_conn(&mut *conn, user.id, &req.username, &req.password, email_opt).await?;
|
||||||
|
|
||||||
|
// Update session login type from 'guest' to 'realm'
|
||||||
|
session
|
||||||
|
.insert(SESSION_LOGIN_TYPE_KEY, "realm")
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Json(RegisterGuestResponse {
|
||||||
|
success: true,
|
||||||
|
username: req.username,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,10 @@ pub fn api_router() -> Router<AppState> {
|
||||||
"/auth/reset-password",
|
"/auth/reset-password",
|
||||||
axum::routing::post(auth::reset_password),
|
axum::routing::post(auth::reset_password),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/auth/register-guest",
|
||||||
|
axum::routing::post(auth::register_guest),
|
||||||
|
)
|
||||||
// Realm routes (READ-ONLY)
|
// Realm routes (READ-ONLY)
|
||||||
.route("/realms", get(realms::list_realms))
|
.route("/realms", get(realms::list_realms))
|
||||||
.route("/realms/{slug}", get(realms::get_realm))
|
.route("/realms/{slug}", get(realms::get_realm))
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ use uuid::Uuid;
|
||||||
|
|
||||||
use chattyness_db::{
|
use chattyness_db::{
|
||||||
models::{ActionType, AvatarRenderData, ChannelMemberWithAvatar, EmotionState, User},
|
models::{ActionType, AvatarRenderData, ChannelMemberWithAvatar, EmotionState, User},
|
||||||
queries::{avatars, channel_members, loose_props, memberships, moderation, realms, scenes},
|
queries::{avatars, channel_members, loose_props, memberships, moderation, realms, scenes, users},
|
||||||
ws_messages::{close_codes, ClientMessage, DisconnectReason, ServerMessage, WsConfig},
|
ws_messages::{close_codes, ClientMessage, DisconnectReason, ServerMessage, WsConfig},
|
||||||
};
|
};
|
||||||
use chattyness_error::AppError;
|
use chattyness_error::AppError;
|
||||||
|
|
@ -1074,6 +1074,35 @@ async fn handle_socket(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ClientMessage::RefreshIdentity => {
|
||||||
|
// Fetch updated user info from database
|
||||||
|
match users::get_user_by_id(&pool, user_id).await {
|
||||||
|
Ok(Some(updated_user)) => {
|
||||||
|
let is_guest: bool = updated_user.is_guest();
|
||||||
|
let display_name = updated_user.display_name.clone();
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"[WS] User {} refreshed identity: display_name={}, is_guest={}",
|
||||||
|
user_id,
|
||||||
|
display_name,
|
||||||
|
is_guest
|
||||||
|
);
|
||||||
|
|
||||||
|
// Broadcast identity update to all channel members
|
||||||
|
let _ = tx.send(ServerMessage::MemberIdentityUpdated {
|
||||||
|
user_id,
|
||||||
|
display_name,
|
||||||
|
is_guest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
tracing::warn!("[WS] RefreshIdentity: user {} not found", user_id);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("[WS] RefreshIdentity failed for user {}: {:?}", user_id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Message::Close(close_frame) => {
|
Message::Close(close_frame) => {
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ pub mod keybindings_popup;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod log_popup;
|
pub mod log_popup;
|
||||||
pub mod modals;
|
pub mod modals;
|
||||||
pub mod notification_history;
|
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
|
pub mod register_modal;
|
||||||
pub mod scene_list_popup;
|
pub mod scene_list_popup;
|
||||||
pub mod scene_viewer;
|
pub mod scene_viewer;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
|
@ -42,8 +42,8 @@ pub use keybindings_popup::*;
|
||||||
pub use layout::*;
|
pub use layout::*;
|
||||||
pub use log_popup::*;
|
pub use log_popup::*;
|
||||||
pub use modals::*;
|
pub use modals::*;
|
||||||
pub use notification_history::*;
|
|
||||||
pub use notifications::*;
|
pub use notifications::*;
|
||||||
|
pub use register_modal::*;
|
||||||
pub use reconnection_overlay::*;
|
pub use reconnection_overlay::*;
|
||||||
pub use scene_list_popup::*;
|
pub use scene_list_popup::*;
|
||||||
pub use scene_viewer::*;
|
pub use scene_viewer::*;
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,12 @@ pub fn ChatInput(
|
||||||
/// Callback to send a mod command.
|
/// Callback to send a mod command.
|
||||||
#[prop(optional)]
|
#[prop(optional)]
|
||||||
on_mod_command: Option<Callback<(String, Vec<String>)>>,
|
on_mod_command: Option<Callback<(String, Vec<String>)>>,
|
||||||
|
/// Whether the current user is a guest (can register).
|
||||||
|
#[prop(default = Signal::derive(|| false))]
|
||||||
|
is_guest: Signal<bool>,
|
||||||
|
/// Callback to open registration modal.
|
||||||
|
#[prop(optional)]
|
||||||
|
on_open_register: Option<Callback<()>>,
|
||||||
) -> 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);
|
||||||
|
|
@ -371,6 +377,11 @@ pub fn ChatInput(
|
||||||
&& "mod".starts_with(&cmd)
|
&& "mod".starts_with(&cmd)
|
||||||
&& cmd != "mod";
|
&& cmd != "mod";
|
||||||
|
|
||||||
|
// Show /register in slash hints when just starting to type it (only for guests)
|
||||||
|
let is_partial_register = is_guest.get_untracked()
|
||||||
|
&& !cmd.is_empty()
|
||||||
|
&& "register".starts_with(&cmd);
|
||||||
|
|
||||||
if is_complete_whisper || is_complete_teleport {
|
if is_complete_whisper || is_complete_teleport {
|
||||||
// User is typing the argument part, no hint needed
|
// User is typing the argument part, no hint needed
|
||||||
set_command_mode.set(CommandMode::None);
|
set_command_mode.set(CommandMode::None);
|
||||||
|
|
@ -392,6 +403,7 @@ pub fn ChatInput(
|
||||||
|| cmd.starts_with("t ")
|
|| cmd.starts_with("t ")
|
||||||
|| cmd.starts_with("teleport ")
|
|| cmd.starts_with("teleport ")
|
||||||
|| is_partial_mod
|
|| is_partial_mod
|
||||||
|
|| is_partial_register
|
||||||
{
|
{
|
||||||
set_command_mode.set(CommandMode::ShowingSlashHint);
|
set_command_mode.set(CommandMode::ShowingSlashHint);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -410,6 +422,7 @@ pub fn ChatInput(
|
||||||
let on_open_settings = on_open_settings.clone();
|
let on_open_settings = on_open_settings.clone();
|
||||||
let on_open_inventory = on_open_inventory.clone();
|
let on_open_inventory = on_open_inventory.clone();
|
||||||
let on_open_log = on_open_log.clone();
|
let on_open_log = on_open_log.clone();
|
||||||
|
let on_open_register = on_open_register.clone();
|
||||||
move |ev: web_sys::KeyboardEvent| {
|
move |ev: web_sys::KeyboardEvent| {
|
||||||
let key = ev.key();
|
let key = ev.key();
|
||||||
let current_mode = command_mode.get_untracked();
|
let current_mode = command_mode.get_untracked();
|
||||||
|
|
@ -560,6 +573,19 @@ pub fn ChatInput(
|
||||||
ev.prevent_default();
|
ev.prevent_default();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Autocomplete to /register if /r, /re, /reg, etc. (only for guests)
|
||||||
|
if is_guest.get_untracked()
|
||||||
|
&& !cmd.is_empty()
|
||||||
|
&& "register".starts_with(&cmd)
|
||||||
|
&& cmd != "register"
|
||||||
|
{
|
||||||
|
set_message.set("/register".to_string());
|
||||||
|
if let Some(input) = input_ref.get() {
|
||||||
|
input.set_value("/register");
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
|
@ -618,6 +644,24 @@ pub fn ChatInput(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /r, /re, /reg, /regi, /regis, /regist, /registe, /register - open register modal (only for guests)
|
||||||
|
if is_guest.get_untracked()
|
||||||
|
&& !cmd.is_empty()
|
||||||
|
&& "register".starts_with(&cmd)
|
||||||
|
{
|
||||||
|
if let Some(ref callback) = on_open_register {
|
||||||
|
callback.run(());
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// /w NAME message or /whisper NAME message
|
// /w NAME message or /whisper NAME message
|
||||||
if let Some((target_name, whisper_content)) = parse_whisper_command(&msg) {
|
if let Some((target_name, whisper_content)) = parse_whisper_command(&msg) {
|
||||||
if !whisper_content.trim().is_empty() {
|
if !whisper_content.trim().is_empty() {
|
||||||
|
|
@ -839,6 +883,13 @@ pub fn ChatInput(
|
||||||
<span class="text-purple-400">"/"</span>
|
<span class="text-purple-400">"/"</span>
|
||||||
<span class="text-purple-400">"mod"</span>
|
<span class="text-purple-400">"mod"</span>
|
||||||
</Show>
|
</Show>
|
||||||
|
// Show /register hint for guests
|
||||||
|
<Show when=move || is_guest.get()>
|
||||||
|
<span class="text-gray-600 mx-2">"|"</span>
|
||||||
|
<span class="text-gray-400">"/"</span>
|
||||||
|
<span class="text-green-400">"r"</span>
|
||||||
|
<span class="text-gray-500">"[egister]"</span>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,9 @@ pub struct ChatMessage {
|
||||||
/// For whispers: true = same scene (show as bubble), false = cross-scene (show as notification).
|
/// For whispers: true = same scene (show as bubble), false = cross-scene (show as notification).
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub is_same_scene: bool,
|
pub is_same_scene: bool,
|
||||||
|
/// Whether this is a system/admin message (teleport, summon, mod commands).
|
||||||
|
#[serde(default)]
|
||||||
|
pub is_system: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default function for serde that returns true.
|
/// Default function for serde that returns true.
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ pub fn TextInput(
|
||||||
#[prop(optional)] minlength: Option<i32>,
|
#[prop(optional)] minlength: Option<i32>,
|
||||||
#[prop(optional)] maxlength: Option<i32>,
|
#[prop(optional)] maxlength: Option<i32>,
|
||||||
#[prop(optional)] pattern: &'static str,
|
#[prop(optional)] pattern: &'static str,
|
||||||
|
#[prop(optional)] autocomplete: &'static str,
|
||||||
#[prop(optional)] class: &'static str,
|
#[prop(optional)] class: &'static str,
|
||||||
#[prop(into)] value: Signal<String>,
|
#[prop(into)] value: Signal<String>,
|
||||||
on_input: Callback<String>,
|
on_input: Callback<String>,
|
||||||
|
|
@ -41,6 +42,7 @@ pub fn TextInput(
|
||||||
minlength=minlength
|
minlength=minlength
|
||||||
maxlength=maxlength
|
maxlength=maxlength
|
||||||
pattern=if pattern.is_empty() { None } else { Some(pattern) }
|
pattern=if pattern.is_empty() { None } else { Some(pattern) }
|
||||||
|
autocomplete=if autocomplete.is_empty() { None } else { Some(autocomplete) }
|
||||||
aria-describedby=if has_help { Some(help_id.clone()) } else { None }
|
aria-describedby=if has_help { Some(help_id.clone()) } else { None }
|
||||||
class="input-base"
|
class="input-base"
|
||||||
prop:value=move || value.get()
|
prop:value=move || value.get()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
//! Message log popup component.
|
//! Message log popup component.
|
||||||
//!
|
//!
|
||||||
//! Displays a filterable chronological log of received messages.
|
//! Displays a filterable chronological log of received messages.
|
||||||
|
//! Includes persistent whisper history via LocalStorage.
|
||||||
|
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::reactive::owner::LocalStorage;
|
use leptos::reactive::owner::LocalStorage;
|
||||||
|
|
@ -8,6 +9,77 @@ use leptos::reactive::owner::LocalStorage;
|
||||||
use super::chat_types::{ChatMessage, MessageLog};
|
use super::chat_types::{ChatMessage, MessageLog};
|
||||||
use super::modals::Modal;
|
use super::modals::Modal;
|
||||||
|
|
||||||
|
/// LocalStorage key for persistent whisper history.
|
||||||
|
const WHISPER_STORAGE_KEY: &str = "chattyness_whisper_history";
|
||||||
|
/// Maximum number of whispers to keep in persistent history.
|
||||||
|
const MAX_WHISPER_HISTORY: usize = 100;
|
||||||
|
|
||||||
|
/// Load whisper history from LocalStorage.
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
pub fn load_whisper_history() -> Vec<ChatMessage> {
|
||||||
|
let window = match web_sys::window() {
|
||||||
|
Some(w) => w,
|
||||||
|
None => return Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let storage = match window.local_storage() {
|
||||||
|
Ok(Some(s)) => s,
|
||||||
|
_ => return Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = match storage.get_item(WHISPER_STORAGE_KEY) {
|
||||||
|
Ok(Some(j)) => j,
|
||||||
|
_ => return Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
serde_json::from_str(&json).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save whisper history to LocalStorage.
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
pub fn save_whisper_history(whispers: &[ChatMessage]) {
|
||||||
|
let window = match web_sys::window() {
|
||||||
|
Some(w) => w,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let storage = match window.local_storage() {
|
||||||
|
Ok(Some(s)) => s,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(json) = serde_json::to_string(whispers) {
|
||||||
|
let _ = storage.set_item(WHISPER_STORAGE_KEY, &json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a whisper to persistent history, maintaining max size.
|
||||||
|
/// Deduplicates by message_id.
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
pub fn add_whisper_to_history(msg: ChatMessage) {
|
||||||
|
let mut history = load_whisper_history();
|
||||||
|
// Avoid duplicates by message_id
|
||||||
|
if !history.iter().any(|m| m.message_id == msg.message_id) {
|
||||||
|
history.insert(0, msg);
|
||||||
|
if history.len() > MAX_WHISPER_HISTORY {
|
||||||
|
history.truncate(MAX_WHISPER_HISTORY);
|
||||||
|
}
|
||||||
|
save_whisper_history(&history);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SSR stubs for persistence functions.
|
||||||
|
#[cfg(not(feature = "hydrate"))]
|
||||||
|
pub fn load_whisper_history() -> Vec<ChatMessage> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "hydrate"))]
|
||||||
|
pub fn save_whisper_history(_whispers: &[ChatMessage]) {}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "hydrate"))]
|
||||||
|
pub fn add_whisper_to_history(_msg: ChatMessage) {}
|
||||||
|
|
||||||
/// Filter mode for message log display.
|
/// Filter mode for message log display.
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||||
pub enum LogFilter {
|
pub enum LogFilter {
|
||||||
|
|
@ -18,16 +90,23 @@ pub enum LogFilter {
|
||||||
Chat,
|
Chat,
|
||||||
/// Show only whispers.
|
/// Show only whispers.
|
||||||
Whispers,
|
Whispers,
|
||||||
|
/// Show only system/admin messages (teleports, summons, mod commands).
|
||||||
|
System,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Message log popup component.
|
/// Message log popup component.
|
||||||
///
|
///
|
||||||
/// Displays a filterable list of messages from the session.
|
/// Displays a filterable list of messages from the session,
|
||||||
|
/// with persistent whisper history from LocalStorage.
|
||||||
#[component]
|
#[component]
|
||||||
pub fn LogPopup(
|
pub fn LogPopup(
|
||||||
#[prop(into)] open: Signal<bool>,
|
#[prop(into)] open: Signal<bool>,
|
||||||
message_log: StoredValue<MessageLog, LocalStorage>,
|
message_log: StoredValue<MessageLog, LocalStorage>,
|
||||||
on_close: Callback<()>,
|
on_close: Callback<()>,
|
||||||
|
/// Callback when user wants to reply to a whisper.
|
||||||
|
on_reply: Callback<String>,
|
||||||
|
/// Callback when user wants to see conversation context.
|
||||||
|
on_context: Callback<String>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let (filter, set_filter) = signal(LogFilter::All);
|
let (filter, set_filter) = signal(LogFilter::All);
|
||||||
|
|
||||||
|
|
@ -38,17 +117,46 @@ pub fn LogPopup(
|
||||||
// Reading open ensures we re-fetch messages when modal opens
|
// Reading open ensures we re-fetch messages when modal opens
|
||||||
let _ = open.get();
|
let _ = open.get();
|
||||||
let current_filter = filter.get();
|
let current_filter = filter.get();
|
||||||
message_log.with_value(|log| {
|
|
||||||
log.all_messages()
|
match current_filter {
|
||||||
.iter()
|
LogFilter::Whispers => {
|
||||||
.filter(|msg| match current_filter {
|
// For whispers, merge session messages with persistent history
|
||||||
LogFilter::All => true,
|
let mut session_whispers: Vec<ChatMessage> = message_log.with_value(|log| {
|
||||||
LogFilter::Chat => !msg.is_whisper,
|
log.all_messages()
|
||||||
LogFilter::Whispers => msg.is_whisper,
|
.iter()
|
||||||
|
.filter(|msg| msg.is_whisper)
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load persistent whispers and merge (avoiding duplicates by message_id)
|
||||||
|
let persistent = load_whisper_history();
|
||||||
|
for msg in persistent {
|
||||||
|
if !session_whispers.iter().any(|m| m.message_id == msg.message_id) {
|
||||||
|
session_whispers.push(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by timestamp (oldest first for display)
|
||||||
|
session_whispers.sort_by_key(|m| m.timestamp);
|
||||||
|
session_whispers
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// For All, Chat, or System, use session messages only
|
||||||
|
message_log.with_value(|log| {
|
||||||
|
log.all_messages()
|
||||||
|
.iter()
|
||||||
|
.filter(|msg| match current_filter {
|
||||||
|
LogFilter::All => true,
|
||||||
|
LogFilter::Chat => !msg.is_whisper && !msg.is_system,
|
||||||
|
LogFilter::System => msg.is_system,
|
||||||
|
LogFilter::Whispers => unreachable!(),
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
})
|
})
|
||||||
.cloned()
|
}
|
||||||
.collect::<Vec<_>>()
|
}
|
||||||
})
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto-scroll to bottom when modal opens
|
// Auto-scroll to bottom when modal opens
|
||||||
|
|
@ -111,6 +219,12 @@ pub fn LogPopup(
|
||||||
>
|
>
|
||||||
"Whispers"
|
"Whispers"
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class=move || tab_class(filter.get() == LogFilter::System)
|
||||||
|
on:click=move |_| set_filter.set(LogFilter::System)
|
||||||
|
>
|
||||||
|
"System"
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// Message list
|
// Message list
|
||||||
|
|
@ -127,52 +241,114 @@ pub fn LogPopup(
|
||||||
<For
|
<For
|
||||||
each=move || filtered_messages()
|
each=move || filtered_messages()
|
||||||
key=|msg| msg.message_id
|
key=|msg| msg.message_id
|
||||||
children=move |msg: ChatMessage| {
|
children={
|
||||||
let is_whisper = msg.is_whisper;
|
let on_reply = on_reply.clone();
|
||||||
let display_name = msg.display_name.clone();
|
let on_context = on_context.clone();
|
||||||
let content = msg.content.clone();
|
let on_close = on_close.clone();
|
||||||
let timestamp = msg.timestamp;
|
move |msg: ChatMessage| {
|
||||||
|
let is_whisper = msg.is_whisper;
|
||||||
|
let is_system = msg.is_system;
|
||||||
|
let display_name = msg.display_name.clone();
|
||||||
|
let display_name_for_reply = display_name.clone();
|
||||||
|
let display_name_for_context = display_name.clone();
|
||||||
|
let content = msg.content.clone();
|
||||||
|
let timestamp = msg.timestamp;
|
||||||
|
|
||||||
view! {
|
let on_reply = on_reply.clone();
|
||||||
<li class=move || {
|
let on_context = on_context.clone();
|
||||||
if is_whisper {
|
let on_close = on_close.clone();
|
||||||
"py-1 px-2 rounded bg-purple-900/30 border-l-2 border-purple-500"
|
let on_close2 = on_close.clone();
|
||||||
} else {
|
|
||||||
"py-1 px-2"
|
view! {
|
||||||
}
|
<li class=move || {
|
||||||
}>
|
if is_system {
|
||||||
<span class="text-gray-500">
|
"py-1 px-2 rounded bg-yellow-900/30 border-l-2 border-yellow-500"
|
||||||
"["
|
} else if is_whisper {
|
||||||
{format_timestamp(timestamp)}
|
"py-1 px-2 rounded bg-purple-900/30 border-l-2 border-purple-500"
|
||||||
"] "
|
|
||||||
</span>
|
|
||||||
<span class=move || {
|
|
||||||
if is_whisper {
|
|
||||||
"text-purple-300 font-medium"
|
|
||||||
} else {
|
} else {
|
||||||
"text-blue-300 font-medium"
|
"py-1 px-2"
|
||||||
}
|
}
|
||||||
}>
|
}>
|
||||||
{display_name}
|
<div class="flex items-start justify-between">
|
||||||
</span>
|
<div class="flex-1">
|
||||||
<Show when=move || is_whisper>
|
<span class="text-gray-500">
|
||||||
<span class="text-purple-400 text-xs ml-1">
|
"["
|
||||||
"(whisper)"
|
{format_timestamp(timestamp)}
|
||||||
</span>
|
"] "
|
||||||
</Show>
|
</span>
|
||||||
<span class="text-gray-400">
|
<span class=move || {
|
||||||
": "
|
if is_system {
|
||||||
</span>
|
"text-yellow-300 font-medium"
|
||||||
<span class=move || {
|
} else if is_whisper {
|
||||||
if is_whisper {
|
"text-purple-300 font-medium"
|
||||||
"text-gray-300 italic"
|
} else {
|
||||||
} else {
|
"text-blue-300 font-medium"
|
||||||
"text-gray-200"
|
}
|
||||||
}
|
}>
|
||||||
}>
|
{display_name}
|
||||||
{content}
|
</span>
|
||||||
</span>
|
<Show when=move || is_system>
|
||||||
</li>
|
<span class="text-yellow-400 text-xs ml-1">
|
||||||
|
"(system)"
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<Show when=move || is_whisper && !is_system>
|
||||||
|
<span class="text-purple-400 text-xs ml-1">
|
||||||
|
"(whisper)"
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<span class="text-gray-400">
|
||||||
|
": "
|
||||||
|
</span>
|
||||||
|
<span class=move || {
|
||||||
|
if is_system {
|
||||||
|
"text-yellow-200"
|
||||||
|
} else if is_whisper {
|
||||||
|
"text-gray-300 italic"
|
||||||
|
} else {
|
||||||
|
"text-gray-200"
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
{content}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Show when=move || is_whisper && !is_system>
|
||||||
|
<div class="flex gap-1 ml-2 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
class="px-2 py-0.5 text-xs bg-gray-600 hover:bg-gray-500 rounded text-gray-300"
|
||||||
|
title="Reply"
|
||||||
|
on:click={
|
||||||
|
let on_reply = on_reply.clone();
|
||||||
|
let sender = display_name_for_reply.clone();
|
||||||
|
let on_close = on_close.clone();
|
||||||
|
move |_| {
|
||||||
|
on_reply.run(sender.clone());
|
||||||
|
on_close.run(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"Reply"
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-2 py-0.5 text-xs bg-gray-600 hover:bg-gray-500 rounded text-gray-300"
|
||||||
|
title="View conversation"
|
||||||
|
on:click={
|
||||||
|
let on_context = on_context.clone();
|
||||||
|
let sender = display_name_for_context.clone();
|
||||||
|
let on_close = on_close2.clone();
|
||||||
|
move |_| {
|
||||||
|
on_context.run(sender.clone());
|
||||||
|
on_close.run(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"Context"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,241 +0,0 @@
|
||||||
//! Notification history component with LocalStorage persistence.
|
|
||||||
//!
|
|
||||||
//! Shows last 100 notifications across sessions.
|
|
||||||
|
|
||||||
use leptos::prelude::*;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use super::chat_types::ChatMessage;
|
|
||||||
use super::modals::Modal;
|
|
||||||
|
|
||||||
const STORAGE_KEY: &str = "chattyness_notification_history";
|
|
||||||
const MAX_HISTORY_SIZE: usize = 100;
|
|
||||||
|
|
||||||
/// A stored notification entry for history.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct HistoryEntry {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub sender_name: String,
|
|
||||||
pub content: String,
|
|
||||||
pub timestamp: i64,
|
|
||||||
pub is_whisper: bool,
|
|
||||||
/// Type of notification (e.g., "whisper", "system", "mod").
|
|
||||||
pub notification_type: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HistoryEntry {
|
|
||||||
/// Create from a whisper chat message.
|
|
||||||
pub fn from_whisper(msg: &ChatMessage) -> Self {
|
|
||||||
Self {
|
|
||||||
id: Uuid::new_v4(),
|
|
||||||
sender_name: msg.display_name.clone(),
|
|
||||||
content: msg.content.clone(),
|
|
||||||
timestamp: msg.timestamp,
|
|
||||||
is_whisper: true,
|
|
||||||
notification_type: "whisper".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load history from LocalStorage.
|
|
||||||
#[cfg(feature = "hydrate")]
|
|
||||||
pub fn load_history() -> Vec<HistoryEntry> {
|
|
||||||
let window = match web_sys::window() {
|
|
||||||
Some(w) => w,
|
|
||||||
None => return Vec::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let storage = match window.local_storage() {
|
|
||||||
Ok(Some(s)) => s,
|
|
||||||
_ => return Vec::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let json = match storage.get_item(STORAGE_KEY) {
|
|
||||||
Ok(Some(j)) => j,
|
|
||||||
_ => return Vec::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
serde_json::from_str(&json).unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Save history to LocalStorage.
|
|
||||||
#[cfg(feature = "hydrate")]
|
|
||||||
pub fn save_history(history: &[HistoryEntry]) {
|
|
||||||
let window = match web_sys::window() {
|
|
||||||
Some(w) => w,
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
let storage = match window.local_storage() {
|
|
||||||
Ok(Some(s)) => s,
|
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Ok(json) = serde_json::to_string(history) {
|
|
||||||
let _ = storage.set_item(STORAGE_KEY, &json);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add an entry to history, maintaining max size.
|
|
||||||
#[cfg(feature = "hydrate")]
|
|
||||||
pub fn add_to_history(entry: HistoryEntry) {
|
|
||||||
let mut history = load_history();
|
|
||||||
history.insert(0, entry);
|
|
||||||
if history.len() > MAX_HISTORY_SIZE {
|
|
||||||
history.truncate(MAX_HISTORY_SIZE);
|
|
||||||
}
|
|
||||||
save_history(&history);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// SSR stubs
|
|
||||||
#[cfg(not(feature = "hydrate"))]
|
|
||||||
pub fn load_history() -> Vec<HistoryEntry> {
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(feature = "hydrate"))]
|
|
||||||
pub fn save_history(_history: &[HistoryEntry]) {}
|
|
||||||
|
|
||||||
#[cfg(not(feature = "hydrate"))]
|
|
||||||
pub fn add_to_history(_entry: HistoryEntry) {}
|
|
||||||
|
|
||||||
/// Notification history modal component.
|
|
||||||
#[component]
|
|
||||||
pub fn NotificationHistoryModal(
|
|
||||||
#[prop(into)] open: Signal<bool>,
|
|
||||||
on_close: Callback<()>,
|
|
||||||
/// Callback when user wants to reply to a message.
|
|
||||||
on_reply: Callback<String>,
|
|
||||||
/// Callback when user wants to see conversation context.
|
|
||||||
on_context: Callback<String>,
|
|
||||||
) -> impl IntoView {
|
|
||||||
// Load history when modal opens
|
|
||||||
let history = Signal::derive(move || {
|
|
||||||
if open.get() {
|
|
||||||
load_history()
|
|
||||||
} else {
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<Modal
|
|
||||||
open=open
|
|
||||||
on_close=on_close.clone()
|
|
||||||
title="Notification History"
|
|
||||||
title_id="notification-history-title"
|
|
||||||
max_width="max-w-2xl"
|
|
||||||
>
|
|
||||||
<div class="max-h-96 overflow-y-auto">
|
|
||||||
<Show
|
|
||||||
when=move || !history.get().is_empty()
|
|
||||||
fallback=|| view! {
|
|
||||||
<p class="text-gray-400 text-center py-8">
|
|
||||||
"No notifications yet"
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ul class="space-y-2">
|
|
||||||
<For
|
|
||||||
each=move || history.get()
|
|
||||||
key=|entry| entry.id
|
|
||||||
children={
|
|
||||||
let on_reply = on_reply.clone();
|
|
||||||
let on_context = on_context.clone();
|
|
||||||
let on_close = on_close.clone();
|
|
||||||
move |entry: HistoryEntry| {
|
|
||||||
let sender_name = entry.sender_name.clone();
|
|
||||||
let sender_for_reply = sender_name.clone();
|
|
||||||
let sender_for_context = sender_name.clone();
|
|
||||||
let on_reply = on_reply.clone();
|
|
||||||
let on_context = on_context.clone();
|
|
||||||
let on_close = on_close.clone();
|
|
||||||
let on_close2 = on_close.clone();
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<li class="p-3 bg-gray-700/50 rounded-lg border border-gray-600">
|
|
||||||
<div class="flex items-start justify-between">
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="flex items-center gap-2 mb-1">
|
|
||||||
<span class="text-purple-300 font-medium">
|
|
||||||
{sender_name}
|
|
||||||
</span>
|
|
||||||
<span class="text-xs text-gray-500">
|
|
||||||
{format_timestamp(entry.timestamp)}
|
|
||||||
</span>
|
|
||||||
<Show when=move || entry.is_whisper>
|
|
||||||
<span class="text-xs bg-purple-500/30 text-purple-300 px-1.5 py-0.5 rounded">
|
|
||||||
"whisper"
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<p class="text-gray-300 text-sm">
|
|
||||||
{entry.content.clone()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-1 ml-2">
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 text-xs bg-gray-600 hover:bg-gray-500 rounded text-gray-300"
|
|
||||||
title="Reply"
|
|
||||||
on:click={
|
|
||||||
let on_reply = on_reply.clone();
|
|
||||||
let sender = sender_for_reply.clone();
|
|
||||||
let on_close = on_close.clone();
|
|
||||||
move |_| {
|
|
||||||
on_reply.run(sender.clone());
|
|
||||||
on_close.run(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
"Reply"
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 text-xs bg-gray-600 hover:bg-gray-500 rounded text-gray-300"
|
|
||||||
title="View conversation"
|
|
||||||
on:click={
|
|
||||||
let on_context = on_context.clone();
|
|
||||||
let sender = sender_for_context.clone();
|
|
||||||
let on_close = on_close2.clone();
|
|
||||||
move |_| {
|
|
||||||
on_context.run(sender.clone());
|
|
||||||
on_close.run(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
"Context"
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ul>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Footer hint
|
|
||||||
<div class="mt-4 pt-4 border-t border-gray-600 text-xs text-gray-500 text-center">
|
|
||||||
"Press " <kbd class="px-1.5 py-0.5 bg-gray-700 rounded">"Esc"</kbd> " to close"
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Format a timestamp for display.
|
|
||||||
fn format_timestamp(timestamp: i64) -> String {
|
|
||||||
#[cfg(feature = "hydrate")]
|
|
||||||
{
|
|
||||||
let date = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(timestamp as f64));
|
|
||||||
let hours = date.get_hours();
|
|
||||||
let minutes = date.get_minutes();
|
|
||||||
format!("{:02}:{:02}", hours, minutes)
|
|
||||||
}
|
|
||||||
#[cfg(not(feature = "hydrate"))]
|
|
||||||
{
|
|
||||||
let _ = timestamp;
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -56,8 +56,6 @@ pub fn NotificationToast(
|
||||||
on_reply: Callback<String>,
|
on_reply: Callback<String>,
|
||||||
/// Callback when user wants to see context (press 'c').
|
/// Callback when user wants to see context (press 'c').
|
||||||
on_context: Callback<String>,
|
on_context: Callback<String>,
|
||||||
/// Callback when user wants to see history (press 'h').
|
|
||||||
on_history: Callback<()>,
|
|
||||||
/// Callback when notification is dismissed.
|
/// Callback when notification is dismissed.
|
||||||
on_dismiss: Callback<Uuid>,
|
on_dismiss: Callback<Uuid>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
|
|
@ -139,7 +137,6 @@ pub fn NotificationToast(
|
||||||
|
|
||||||
let on_reply = on_reply.clone();
|
let on_reply = on_reply.clone();
|
||||||
let on_context = on_context.clone();
|
let on_context = on_context.clone();
|
||||||
let on_history = on_history.clone();
|
|
||||||
let on_dismiss = on_dismiss.clone();
|
let on_dismiss = on_dismiss.clone();
|
||||||
|
|
||||||
let closure = wasm_bindgen::closure::Closure::<dyn Fn(web_sys::KeyboardEvent)>::new(
|
let closure = wasm_bindgen::closure::Closure::<dyn Fn(web_sys::KeyboardEvent)>::new(
|
||||||
|
|
@ -156,11 +153,6 @@ pub fn NotificationToast(
|
||||||
on_context.run(display_name.clone());
|
on_context.run(display_name.clone());
|
||||||
on_dismiss.run(notif_id);
|
on_dismiss.run(notif_id);
|
||||||
}
|
}
|
||||||
"h" | "H" => {
|
|
||||||
ev.prevent_default();
|
|
||||||
on_history.run(());
|
|
||||||
on_dismiss.run(notif_id);
|
|
||||||
}
|
|
||||||
"Escape" => {
|
"Escape" => {
|
||||||
ev.prevent_default();
|
ev.prevent_default();
|
||||||
on_dismiss.run(notif_id);
|
on_dismiss.run(notif_id);
|
||||||
|
|
@ -217,10 +209,6 @@ pub fn NotificationToast(
|
||||||
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-gray-400">"c"</kbd>
|
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-gray-400">"c"</kbd>
|
||||||
" context"
|
" context"
|
||||||
</span>
|
</span>
|
||||||
<span>
|
|
||||||
<kbd class="px-1.5 py-0.5 bg-gray-700 rounded text-gray-400">"h"</kbd>
|
|
||||||
" history"
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
277
crates/chattyness-user-ui/src/components/register_modal.rs
Normal file
277
crates/chattyness-user-ui/src/components/register_modal.rs
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
//! Registration modal component for guest-to-user conversion.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
use super::forms::{ErrorAlert, SubmitButton, TextInput};
|
||||||
|
use crate::utils::use_escape_key;
|
||||||
|
|
||||||
|
/// Response from the register-guest API endpoint.
|
||||||
|
#[derive(Clone, serde::Deserialize)]
|
||||||
|
pub struct RegisterGuestResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registration modal for converting a guest account to a full user account.
|
||||||
|
///
|
||||||
|
/// Props:
|
||||||
|
/// - `open`: Signal controlling modal visibility
|
||||||
|
/// - `on_close`: Callback when modal should close
|
||||||
|
/// - `on_success`: Callback when registration succeeds (receives new username)
|
||||||
|
#[component]
|
||||||
|
pub fn RegisterModal(
|
||||||
|
#[prop(into)] open: Signal<bool>,
|
||||||
|
on_close: Callback<()>,
|
||||||
|
#[prop(optional)] on_success: Option<Callback<String>>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let (username, set_username) = signal(String::new());
|
||||||
|
let (email, set_email) = signal(String::new());
|
||||||
|
let (password, set_password) = signal(String::new());
|
||||||
|
let (confirm_password, set_confirm_password) = signal(String::new());
|
||||||
|
let (error, set_error) = signal(Option::<String>::None);
|
||||||
|
let (pending, set_pending) = signal(false);
|
||||||
|
|
||||||
|
// Handle escape key
|
||||||
|
use_escape_key(open, on_close.clone());
|
||||||
|
|
||||||
|
let on_close_backdrop = on_close.clone();
|
||||||
|
let on_close_button = on_close.clone();
|
||||||
|
|
||||||
|
// Client-side validation
|
||||||
|
let validate = move || -> Result<(), String> {
|
||||||
|
let username_val = username.get();
|
||||||
|
let password_val = password.get();
|
||||||
|
let confirm_val = confirm_password.get();
|
||||||
|
let email_val = email.get();
|
||||||
|
|
||||||
|
// Username validation: 3-30 chars, alphanumeric + underscore
|
||||||
|
if username_val.len() < 3 || username_val.len() > 30 {
|
||||||
|
return Err("Username must be between 3 and 30 characters".to_string());
|
||||||
|
}
|
||||||
|
if !username_val
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_alphanumeric() || c == '_')
|
||||||
|
{
|
||||||
|
return Err("Username can only contain letters, numbers, and underscores".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password validation: 8+ chars
|
||||||
|
if password_val.len() < 8 {
|
||||||
|
return Err("Password must be at least 8 characters".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm password
|
||||||
|
if password_val != confirm_val {
|
||||||
|
return Err("Passwords do not match".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email validation (optional, but must be valid if provided)
|
||||||
|
if !email_val.is_empty() && !email_val.contains('@') {
|
||||||
|
return Err("Please enter a valid email address".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Submit handler
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let on_submit = {
|
||||||
|
let on_close = on_close.clone();
|
||||||
|
move |ev: leptos::ev::SubmitEvent| {
|
||||||
|
ev.prevent_default();
|
||||||
|
|
||||||
|
// Validate first
|
||||||
|
if let Err(msg) = validate() {
|
||||||
|
set_error.set(Some(msg));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set_error.set(None);
|
||||||
|
set_pending.set(true);
|
||||||
|
|
||||||
|
let username_val = username.get();
|
||||||
|
let email_val = email.get();
|
||||||
|
let password_val = password.get();
|
||||||
|
let confirm_val = confirm_password.get();
|
||||||
|
|
||||||
|
let on_close = on_close.clone();
|
||||||
|
let on_success = on_success.clone();
|
||||||
|
|
||||||
|
leptos::task::spawn_local(async move {
|
||||||
|
use gloo_net::http::Request;
|
||||||
|
|
||||||
|
let request_body = serde_json::json!({
|
||||||
|
"username": username_val,
|
||||||
|
"email": if email_val.is_empty() { None } else { Some(email_val) },
|
||||||
|
"password": password_val,
|
||||||
|
"confirm_password": confirm_val,
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = Request::post("/api/auth/register-guest")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(request_body.to_string())
|
||||||
|
.unwrap()
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
set_pending.set(false);
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(resp) => {
|
||||||
|
if resp.ok() {
|
||||||
|
if let Ok(data) = resp.json::<RegisterGuestResponse>().await {
|
||||||
|
// Clear form
|
||||||
|
set_username.set(String::new());
|
||||||
|
set_email.set(String::new());
|
||||||
|
set_password.set(String::new());
|
||||||
|
set_confirm_password.set(String::new());
|
||||||
|
|
||||||
|
// Call success callback
|
||||||
|
if let Some(ref callback) = on_success {
|
||||||
|
callback.run(data.username);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
on_close.run(());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Try to parse error message
|
||||||
|
if let Ok(error_data) =
|
||||||
|
resp.json::<serde_json::Value>().await
|
||||||
|
{
|
||||||
|
let msg = error_data
|
||||||
|
.get("message")
|
||||||
|
.and_then(|m| m.as_str())
|
||||||
|
.unwrap_or("Registration failed");
|
||||||
|
set_error.set(Some(msg.to_string()));
|
||||||
|
} else {
|
||||||
|
set_error.set(Some("Registration failed".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
set_error.set(Some(format!("Network error: {}", e)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(feature = "hydrate"))]
|
||||||
|
let on_submit = move |_ev: leptos::ev::SubmitEvent| {};
|
||||||
|
|
||||||
|
// Use CSS-based visibility
|
||||||
|
let outer_class = move || {
|
||||||
|
if open.get() {
|
||||||
|
"fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
} else {
|
||||||
|
"hidden"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div
|
||||||
|
class=outer_class
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="register-modal-title"
|
||||||
|
aria-hidden=move || (!open.get()).to_string()
|
||||||
|
>
|
||||||
|
// Backdrop
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||||
|
on:click=move |_| on_close_backdrop.run(())
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Modal content
|
||||||
|
<div class="relative bg-gray-800 rounded-lg shadow-2xl max-w-md w-full mx-4 p-6 border border-gray-700">
|
||||||
|
// Header with title and close button
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 id="register-modal-title" class="text-xl font-bold text-white">
|
||||||
|
"Create Account"
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-gray-400 hover:text-white transition-colors"
|
||||||
|
on:click=move |_| on_close_button.run(())
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-gray-400 mb-6">
|
||||||
|
"Convert your guest account to a full account. You'll keep all your current chat history and settings."
|
||||||
|
</p>
|
||||||
|
|
||||||
|
// Error display
|
||||||
|
<ErrorAlert message=Signal::derive(move || error.get()) />
|
||||||
|
|
||||||
|
// Registration form
|
||||||
|
<form on:submit=on_submit class="space-y-4">
|
||||||
|
<TextInput
|
||||||
|
name="username"
|
||||||
|
label="Username"
|
||||||
|
placeholder="Choose a username"
|
||||||
|
help_text="3-30 characters, letters, numbers, and underscores only"
|
||||||
|
required=true
|
||||||
|
minlength=3
|
||||||
|
maxlength=30
|
||||||
|
pattern=r"^[a-zA-Z0-9_]+$"
|
||||||
|
autocomplete="username"
|
||||||
|
value=Signal::derive(move || username.get())
|
||||||
|
on_input=Callback::new(move |val| set_username.set(val))
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
name="email"
|
||||||
|
label="Email"
|
||||||
|
input_type="email"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
help_text="Optional, for account recovery"
|
||||||
|
required=false
|
||||||
|
autocomplete="email"
|
||||||
|
value=Signal::derive(move || email.get())
|
||||||
|
on_input=Callback::new(move |val| set_email.set(val))
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
name="password"
|
||||||
|
label="Password"
|
||||||
|
input_type="password"
|
||||||
|
placeholder="Choose a password"
|
||||||
|
help_text="At least 8 characters"
|
||||||
|
required=true
|
||||||
|
minlength=8
|
||||||
|
autocomplete="new-password"
|
||||||
|
value=Signal::derive(move || password.get())
|
||||||
|
on_input=Callback::new(move |val| set_password.set(val))
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
name="confirm_password"
|
||||||
|
label="Confirm Password"
|
||||||
|
input_type="password"
|
||||||
|
placeholder="Confirm your password"
|
||||||
|
required=true
|
||||||
|
minlength=8
|
||||||
|
autocomplete="new-password"
|
||||||
|
value=Signal::derive(move || confirm_password.get())
|
||||||
|
on_input=Callback::new(move |val| set_confirm_password.set(val))
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="pt-4">
|
||||||
|
<SubmitButton
|
||||||
|
text="Create Account"
|
||||||
|
loading_text="Creating account..."
|
||||||
|
pending=Signal::derive(move || pending.get())
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -733,25 +733,61 @@ pub fn RealmSceneViewer(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create wheel handler closure for use in view
|
// Dynamically add/remove wheel listener based on pan mode
|
||||||
let handle_wheel = move |ev: leptos::web_sys::WheelEvent| {
|
// This avoids Chrome's "non-passive wheel listener" warning when not in pan mode
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
{
|
{
|
||||||
// Only zoom in pan mode and without Ctrl key
|
use std::cell::RefCell;
|
||||||
if is_pan_mode.get() && !ev.ctrl_key() {
|
use std::rc::Rc;
|
||||||
if let Some(zoom_callback) = on_zoom_change {
|
use wasm_bindgen::{closure::Closure, JsCast};
|
||||||
let delta_y = ev.delta_y();
|
|
||||||
// Normalize: scroll up (negative deltaY) = zoom in (positive delta)
|
let wheel_closure: Rc<RefCell<Option<Closure<dyn Fn(web_sys::WheelEvent)>>>> =
|
||||||
// Scroll down (positive deltaY) = zoom out (negative delta)
|
Rc::new(RefCell::new(None));
|
||||||
let zoom_delta = if delta_y < 0.0 { 0.1 } else { -0.1 };
|
let wheel_closure_clone = wheel_closure.clone();
|
||||||
zoom_callback.run(zoom_delta);
|
|
||||||
ev.prevent_default();
|
Effect::new(move |_| {
|
||||||
|
let pan_mode = is_pan_mode.get();
|
||||||
|
|
||||||
|
if let Some(container) = outer_container_ref.get() {
|
||||||
|
let element: &web_sys::Element = &container;
|
||||||
|
|
||||||
|
// Remove existing listener if any
|
||||||
|
if let Some(closure) = wheel_closure_clone.borrow().as_ref() {
|
||||||
|
let _ = element.remove_event_listener_with_callback(
|
||||||
|
"wheel",
|
||||||
|
closure.as_ref().unchecked_ref(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if pan_mode {
|
||||||
|
// Add non-passive wheel listener for zoom
|
||||||
|
let closure = Closure::new(move |ev: web_sys::WheelEvent| {
|
||||||
|
if !ev.ctrl_key() {
|
||||||
|
if let Some(zoom_callback) = on_zoom_change {
|
||||||
|
let delta_y = ev.delta_y();
|
||||||
|
let zoom_delta = if delta_y < 0.0 { 0.1 } else { -0.1 };
|
||||||
|
zoom_callback.run(zoom_delta);
|
||||||
|
ev.prevent_default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let options = web_sys::AddEventListenerOptions::new();
|
||||||
|
options.set_passive(false);
|
||||||
|
|
||||||
|
let _ = element.add_event_listener_with_callback_and_add_event_listener_options(
|
||||||
|
"wheel",
|
||||||
|
closure.as_ref().unchecked_ref(),
|
||||||
|
&options,
|
||||||
|
);
|
||||||
|
|
||||||
|
*wheel_closure_clone.borrow_mut() = Some(closure);
|
||||||
|
} else {
|
||||||
|
*wheel_closure_clone.borrow_mut() = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
#[cfg(not(feature = "hydrate"))]
|
}
|
||||||
let _ = ev;
|
|
||||||
};
|
|
||||||
|
|
||||||
let aspect_ratio = scene_width as f64 / scene_height as f64;
|
let aspect_ratio = scene_width as f64 / scene_height as f64;
|
||||||
|
|
||||||
|
|
@ -899,7 +935,7 @@ pub fn RealmSceneViewer(
|
||||||
let scene_name = scene.name.clone();
|
let scene_name = scene.name.clone();
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div node_ref=outer_container_ref class=outer_container_class style=outer_container_style on:wheel=handle_wheel>
|
<div node_ref=outer_container_ref class=outer_container_class style=outer_container_style>
|
||||||
<div
|
<div
|
||||||
class=container_class
|
class=container_class
|
||||||
style=container_style
|
style=container_style
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,17 @@ pub struct ModCommandResultInfo {
|
||||||
pub message: String,
|
pub message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Member identity update (e.g., guest registered as user).
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct MemberIdentityInfo {
|
||||||
|
/// User ID of the member.
|
||||||
|
pub user_id: uuid::Uuid,
|
||||||
|
/// New display name.
|
||||||
|
pub display_name: String,
|
||||||
|
/// Whether the member is still a guest.
|
||||||
|
pub is_guest: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// Hook to manage WebSocket connection for a channel.
|
/// Hook to manage WebSocket connection for a channel.
|
||||||
///
|
///
|
||||||
/// Returns a tuple of:
|
/// Returns a tuple of:
|
||||||
|
|
@ -129,6 +140,7 @@ pub fn use_channel_websocket(
|
||||||
on_teleport_approved: Option<Callback<TeleportInfo>>,
|
on_teleport_approved: Option<Callback<TeleportInfo>>,
|
||||||
on_summoned: Option<Callback<SummonInfo>>,
|
on_summoned: Option<Callback<SummonInfo>>,
|
||||||
on_mod_command_result: Option<Callback<ModCommandResultInfo>>,
|
on_mod_command_result: Option<Callback<ModCommandResultInfo>>,
|
||||||
|
on_member_identity_updated: Option<Callback<MemberIdentityInfo>>,
|
||||||
) -> (Signal<WsState>, WsSenderStorage) {
|
) -> (Signal<WsState>, WsSenderStorage) {
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
@ -244,6 +256,7 @@ pub fn use_channel_websocket(
|
||||||
let on_teleport_approved_clone = on_teleport_approved.clone();
|
let on_teleport_approved_clone = on_teleport_approved.clone();
|
||||||
let on_summoned_clone = on_summoned.clone();
|
let on_summoned_clone = on_summoned.clone();
|
||||||
let on_mod_command_result_clone = on_mod_command_result.clone();
|
let on_mod_command_result_clone = on_mod_command_result.clone();
|
||||||
|
let on_member_identity_updated_clone = on_member_identity_updated.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));
|
||||||
|
|
@ -319,6 +332,7 @@ pub fn use_channel_websocket(
|
||||||
&on_teleport_approved_clone,
|
&on_teleport_approved_clone,
|
||||||
&on_summoned_clone,
|
&on_summoned_clone,
|
||||||
&on_mod_command_result_clone,
|
&on_mod_command_result_clone,
|
||||||
|
&on_member_identity_updated_clone,
|
||||||
¤t_user_id_for_msg,
|
¤t_user_id_for_msg,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -430,6 +444,7 @@ fn handle_server_message(
|
||||||
on_teleport_approved: &Option<Callback<TeleportInfo>>,
|
on_teleport_approved: &Option<Callback<TeleportInfo>>,
|
||||||
on_summoned: &Option<Callback<SummonInfo>>,
|
on_summoned: &Option<Callback<SummonInfo>>,
|
||||||
on_mod_command_result: &Option<Callback<ModCommandResultInfo>>,
|
on_mod_command_result: &Option<Callback<ModCommandResultInfo>>,
|
||||||
|
on_member_identity_updated: &Option<Callback<MemberIdentityInfo>>,
|
||||||
current_user_id: &std::rc::Rc<std::cell::RefCell<Option<uuid::Uuid>>>,
|
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();
|
||||||
|
|
@ -563,6 +578,7 @@ fn handle_server_message(
|
||||||
timestamp,
|
timestamp,
|
||||||
is_whisper,
|
is_whisper,
|
||||||
is_same_scene,
|
is_same_scene,
|
||||||
|
is_system: false,
|
||||||
};
|
};
|
||||||
on_chat_message.run(chat_msg);
|
on_chat_message.run(chat_msg);
|
||||||
}
|
}
|
||||||
|
|
@ -624,6 +640,29 @@ fn handle_server_message(
|
||||||
callback.run(ModCommandResultInfo { success, message });
|
callback.run(ModCommandResultInfo { success, message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ServerMessage::MemberIdentityUpdated {
|
||||||
|
user_id,
|
||||||
|
display_name,
|
||||||
|
is_guest,
|
||||||
|
} => {
|
||||||
|
// Update the internal members list so subsequent updates don't overwrite
|
||||||
|
if let Some(member) = members_vec
|
||||||
|
.iter_mut()
|
||||||
|
.find(|m| m.member.user_id == Some(user_id))
|
||||||
|
{
|
||||||
|
member.member.display_name = display_name.clone();
|
||||||
|
member.member.is_guest = is_guest;
|
||||||
|
}
|
||||||
|
on_update.run(members_vec.clone());
|
||||||
|
|
||||||
|
if let Some(callback) = on_member_identity_updated {
|
||||||
|
callback.run(MemberIdentityInfo {
|
||||||
|
user_id,
|
||||||
|
display_name,
|
||||||
|
is_guest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -644,6 +683,7 @@ pub fn use_channel_websocket(
|
||||||
_on_teleport_approved: Option<Callback<TeleportInfo>>,
|
_on_teleport_approved: Option<Callback<TeleportInfo>>,
|
||||||
_on_summoned: Option<Callback<SummonInfo>>,
|
_on_summoned: Option<Callback<SummonInfo>>,
|
||||||
_on_mod_command_result: Option<Callback<ModCommandResultInfo>>,
|
_on_mod_command_result: Option<Callback<ModCommandResultInfo>>,
|
||||||
|
_on_member_identity_updated: Option<Callback<MemberIdentityInfo>>,
|
||||||
) -> (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,13 +14,14 @@ use uuid::Uuid;
|
||||||
use crate::components::{
|
use crate::components::{
|
||||||
ActiveBubble, AvatarEditorPopup, Card, ChatInput, ConversationModal, EmotionKeybindings,
|
ActiveBubble, AvatarEditorPopup, Card, ChatInput, ConversationModal, EmotionKeybindings,
|
||||||
FadingMember, HotkeyHelp, InventoryPopup, KeybindingsPopup, LogPopup, MessageLog,
|
FadingMember, HotkeyHelp, InventoryPopup, KeybindingsPopup, LogPopup, MessageLog,
|
||||||
NotificationHistoryModal, NotificationMessage, NotificationToast, RealmHeader,
|
NotificationMessage, NotificationToast, RealmHeader, RealmSceneViewer, ReconnectionOverlay,
|
||||||
RealmSceneViewer, ReconnectionOverlay, SettingsPopup, ViewerSettings,
|
RegisterModal, 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,
|
||||||
ModCommandResultInfo, SummonInfo, TeleportInfo, WsError, add_to_history, use_channel_websocket,
|
MemberIdentityInfo, ModCommandResultInfo, SummonInfo, TeleportInfo, WsError,
|
||||||
|
add_whisper_to_history, use_channel_websocket,
|
||||||
};
|
};
|
||||||
use crate::utils::LocalStoragePersist;
|
use crate::utils::LocalStoragePersist;
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
|
|
@ -89,6 +90,9 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
// Store full avatar data for the editor
|
// Store full avatar data for the editor
|
||||||
let (full_avatar, set_full_avatar) = signal(Option::<AvatarWithPaths>::None);
|
let (full_avatar, set_full_avatar) = signal(Option::<AvatarWithPaths>::None);
|
||||||
|
|
||||||
|
// Register modal state (for guest-to-user conversion)
|
||||||
|
let (register_modal_open, set_register_modal_open) = signal(false);
|
||||||
|
|
||||||
// Scene dimensions (extracted from bounds_wkt when scene loads)
|
// Scene dimensions (extracted from bounds_wkt when scene loads)
|
||||||
let (scene_dimensions, set_scene_dimensions) = signal((800.0_f64, 600.0_f64));
|
let (scene_dimensions, set_scene_dimensions) = signal((800.0_f64, 600.0_f64));
|
||||||
|
|
||||||
|
|
@ -116,7 +120,6 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
// Notification state for cross-scene whispers
|
// Notification state for cross-scene whispers
|
||||||
let (current_notification, set_current_notification) =
|
let (current_notification, set_current_notification) =
|
||||||
signal(Option::<NotificationMessage>::None);
|
signal(Option::<NotificationMessage>::None);
|
||||||
let (history_modal_open, set_history_modal_open) = signal(false);
|
|
||||||
let (conversation_modal_open, set_conversation_modal_open) = signal(false);
|
let (conversation_modal_open, set_conversation_modal_open) = signal(false);
|
||||||
let (conversation_partner, set_conversation_partner) = signal(String::new());
|
let (conversation_partner, set_conversation_partner) = signal(String::new());
|
||||||
// Track all whisper messages for conversation view (client-side only)
|
// Track all whisper messages for conversation view (client-side only)
|
||||||
|
|
@ -264,8 +267,8 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add to notification history for persistence
|
// Add to persistent whisper history in LocalStorage
|
||||||
add_to_history(HistoryEntry::from_whisper(&msg));
|
add_whisper_to_history(msg.clone());
|
||||||
|
|
||||||
if msg.is_same_scene {
|
if msg.is_same_scene {
|
||||||
// Same scene whisper: show as italic bubble (handled by bubble rendering)
|
// Same scene whisper: show as italic bubble (handled by bubble rendering)
|
||||||
|
|
@ -364,6 +367,23 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
// Callback for teleport approval - navigate to new scene
|
// Callback for teleport approval - navigate to new scene
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
let on_teleport_approved = Callback::new(move |info: TeleportInfo| {
|
let on_teleport_approved = Callback::new(move |info: TeleportInfo| {
|
||||||
|
// Log teleport to message log
|
||||||
|
let teleport_msg = ChatMessage {
|
||||||
|
message_id: Uuid::new_v4(),
|
||||||
|
user_id: None,
|
||||||
|
guest_session_id: None,
|
||||||
|
display_name: "[SYSTEM]".to_string(),
|
||||||
|
content: format!("Teleported to scene: {}", info.scene_slug),
|
||||||
|
emotion: "neutral".to_string(),
|
||||||
|
x: 0.0,
|
||||||
|
y: 0.0,
|
||||||
|
timestamp: js_sys::Date::now() as i64,
|
||||||
|
is_whisper: false,
|
||||||
|
is_same_scene: true,
|
||||||
|
is_system: true,
|
||||||
|
};
|
||||||
|
message_log.update_value(|log| log.push(teleport_msg));
|
||||||
|
|
||||||
let scene_id = info.scene_id;
|
let scene_id = info.scene_id;
|
||||||
let scene_slug = info.scene_slug.clone();
|
let scene_slug = info.scene_slug.clone();
|
||||||
let realm_slug = slug.get_untracked();
|
let realm_slug = slug.get_untracked();
|
||||||
|
|
@ -427,6 +447,23 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
// Callback for being summoned by a moderator - show notification and teleport
|
// Callback for being summoned by a moderator - show notification and teleport
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
let on_summoned = Callback::new(move |info: SummonInfo| {
|
let on_summoned = Callback::new(move |info: SummonInfo| {
|
||||||
|
// Log summon to message log
|
||||||
|
let summon_msg = ChatMessage {
|
||||||
|
message_id: Uuid::new_v4(),
|
||||||
|
user_id: None,
|
||||||
|
guest_session_id: None,
|
||||||
|
display_name: "[MOD]".to_string(),
|
||||||
|
content: format!("Summoned by {} to scene: {}", info.summoned_by, info.scene_slug),
|
||||||
|
emotion: "neutral".to_string(),
|
||||||
|
x: 0.0,
|
||||||
|
y: 0.0,
|
||||||
|
timestamp: js_sys::Date::now() as i64,
|
||||||
|
is_whisper: false,
|
||||||
|
is_same_scene: true,
|
||||||
|
is_system: true,
|
||||||
|
};
|
||||||
|
message_log.update_value(|log| log.push(summon_msg));
|
||||||
|
|
||||||
// Show notification
|
// Show notification
|
||||||
set_mod_notification.set(Some((true, format!("Summoned by {}", info.summoned_by))));
|
set_mod_notification.set(Some((true, format!("Summoned by {}", info.summoned_by))));
|
||||||
|
|
||||||
|
|
@ -499,6 +536,24 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
// Callback for mod command result - show notification
|
// Callback for mod command result - show notification
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
let on_mod_command_result = Callback::new(move |info: ModCommandResultInfo| {
|
let on_mod_command_result = Callback::new(move |info: ModCommandResultInfo| {
|
||||||
|
// Log mod command result to message log
|
||||||
|
let status = if info.success { "OK" } else { "FAILED" };
|
||||||
|
let mod_msg = ChatMessage {
|
||||||
|
message_id: Uuid::new_v4(),
|
||||||
|
user_id: None,
|
||||||
|
guest_session_id: None,
|
||||||
|
display_name: "[MOD]".to_string(),
|
||||||
|
content: format!("[{}] {}", status, info.message),
|
||||||
|
emotion: "neutral".to_string(),
|
||||||
|
x: 0.0,
|
||||||
|
y: 0.0,
|
||||||
|
timestamp: js_sys::Date::now() as i64,
|
||||||
|
is_whisper: false,
|
||||||
|
is_same_scene: true,
|
||||||
|
is_system: true,
|
||||||
|
};
|
||||||
|
message_log.update_value(|log| log.push(mod_msg));
|
||||||
|
|
||||||
set_mod_notification.set(Some((info.success, info.message)));
|
set_mod_notification.set(Some((info.success, info.message)));
|
||||||
|
|
||||||
// Auto-dismiss notification after 3 seconds
|
// Auto-dismiss notification after 3 seconds
|
||||||
|
|
@ -508,6 +563,20 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
timeout.forget();
|
timeout.forget();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Callback for member identity updates (e.g., guest registered as user)
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let on_member_identity_updated = Callback::new(move |info: MemberIdentityInfo| {
|
||||||
|
// Update the member's display name in the members list
|
||||||
|
set_members.update(|members| {
|
||||||
|
if let Some(member) = members
|
||||||
|
.iter_mut()
|
||||||
|
.find(|m| m.member.user_id == Some(info.user_id))
|
||||||
|
{
|
||||||
|
member.member.display_name = info.display_name.clone();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
let (ws_state, ws_sender) = use_channel_websocket(
|
let (ws_state, ws_sender) = use_channel_websocket(
|
||||||
slug,
|
slug,
|
||||||
|
|
@ -524,6 +593,7 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
Some(on_teleport_approved),
|
Some(on_teleport_approved),
|
||||||
Some(on_summoned),
|
Some(on_summoned),
|
||||||
Some(on_mod_command_result),
|
Some(on_mod_command_result),
|
||||||
|
Some(on_member_identity_updated),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set channel ID, current scene, and scene dimensions when entry scene loads
|
// Set channel ID, current scene, and scene dimensions when entry scene loads
|
||||||
|
|
@ -783,6 +853,30 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If any input or textarea is focused (e.g., modal forms), skip hotkeys
|
||||||
|
if let Some(document) = web_sys::window().and_then(|w| w.document()) {
|
||||||
|
if let Some(active) = document.active_element() {
|
||||||
|
let tag = active.tag_name().to_lowercase();
|
||||||
|
if tag == "input" || tag == "textarea" {
|
||||||
|
*e_pressed_clone.borrow_mut() = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If any modal is open, skip hotkeys (modals handle their own Escape key)
|
||||||
|
if settings_open.get_untracked()
|
||||||
|
|| inventory_open.get_untracked()
|
||||||
|
|| log_open.get_untracked()
|
||||||
|
|| keybindings_open.get_untracked()
|
||||||
|
|| avatar_editor_open.get_untracked()
|
||||||
|
|| register_modal_open.get_untracked()
|
||||||
|
|| conversation_modal_open.get_untracked()
|
||||||
|
{
|
||||||
|
*e_pressed_clone.borrow_mut() = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle space to focus chat input (no prefix)
|
// Handle space to focus chat input (no prefix)
|
||||||
if key == " " {
|
if key == " " {
|
||||||
set_focus_prefix.set(' ');
|
set_focus_prefix.set(' ');
|
||||||
|
|
@ -1126,6 +1220,9 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
let on_open_log_cb = Callback::new(move |_: ()| {
|
let on_open_log_cb = Callback::new(move |_: ()| {
|
||||||
set_log_open.set(true);
|
set_log_open.set(true);
|
||||||
});
|
});
|
||||||
|
let on_open_register_cb = Callback::new(move |_: ()| {
|
||||||
|
set_register_modal_open.set(true);
|
||||||
|
});
|
||||||
let on_whisper_request_cb = Callback::new(move |target: String| {
|
let on_whisper_request_cb = Callback::new(move |target: String| {
|
||||||
whisper_target.set(Some(target));
|
whisper_target.set(Some(target));
|
||||||
});
|
});
|
||||||
|
|
@ -1192,6 +1289,8 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
on_teleport=on_teleport_cb
|
on_teleport=on_teleport_cb
|
||||||
is_moderator=is_moderator_signal
|
is_moderator=is_moderator_signal
|
||||||
on_mod_command=on_mod_command_cb
|
on_mod_command=on_mod_command_cb
|
||||||
|
is_guest=Signal::derive(move || is_guest.get())
|
||||||
|
on_open_register=on_open_register_cb
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1252,6 +1351,13 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
on_close=Callback::new(move |_: ()| {
|
on_close=Callback::new(move |_: ()| {
|
||||||
set_log_open.set(false);
|
set_log_open.set(false);
|
||||||
})
|
})
|
||||||
|
on_reply=Callback::new(move |name: String| {
|
||||||
|
whisper_target.set(Some(name));
|
||||||
|
})
|
||||||
|
on_context=Callback::new(move |name: String| {
|
||||||
|
set_conversation_partner.set(name);
|
||||||
|
set_conversation_modal_open.set(true);
|
||||||
|
})
|
||||||
/>
|
/>
|
||||||
|
|
||||||
// Keybindings popup
|
// Keybindings popup
|
||||||
|
|
@ -1289,6 +1395,39 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Registration modal for guest-to-user conversion
|
||||||
|
{
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let ws_sender_for_register = ws_sender.clone();
|
||||||
|
view! {
|
||||||
|
<RegisterModal
|
||||||
|
open=Signal::derive(move || register_modal_open.get())
|
||||||
|
on_close=Callback::new(move |_: ()| set_register_modal_open.set(false))
|
||||||
|
on_success=Callback::new(move |username: String| {
|
||||||
|
// Update is_guest to false since they're now a registered user
|
||||||
|
set_is_guest.set(false);
|
||||||
|
// Send RefreshIdentity to update display name for all channel members
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
ws_sender_for_register.with_value(|sender| {
|
||||||
|
if let Some(send_fn) = sender {
|
||||||
|
send_fn(ClientMessage::RefreshIdentity);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Show success notification
|
||||||
|
set_mod_notification.set(Some((true, format!("Welcome, {}! Your account has been created.", username))));
|
||||||
|
// Auto-dismiss after 5 seconds
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
let timeout = gloo_timers::callback::Timeout::new(5000, move || {
|
||||||
|
set_mod_notification.set(None);
|
||||||
|
});
|
||||||
|
timeout.forget();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Notification toast for cross-scene whispers
|
// Notification toast for cross-scene whispers
|
||||||
<NotificationToast
|
<NotificationToast
|
||||||
notification=Signal::derive(move || current_notification.get())
|
notification=Signal::derive(move || current_notification.get())
|
||||||
|
|
@ -1299,9 +1438,6 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
set_conversation_partner.set(name);
|
set_conversation_partner.set(name);
|
||||||
set_conversation_modal_open.set(true);
|
set_conversation_modal_open.set(true);
|
||||||
})
|
})
|
||||||
on_history=Callback::new(move |_: ()| {
|
|
||||||
set_history_modal_open.set(true);
|
|
||||||
})
|
|
||||||
on_dismiss=Callback::new(move |_: Uuid| {
|
on_dismiss=Callback::new(move |_: Uuid| {
|
||||||
set_current_notification.set(None);
|
set_current_notification.set(None);
|
||||||
})
|
})
|
||||||
|
|
@ -1361,19 +1497,6 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
// Notification history modal
|
|
||||||
<NotificationHistoryModal
|
|
||||||
open=Signal::derive(move || history_modal_open.get())
|
|
||||||
on_close=Callback::new(move |_: ()| set_history_modal_open.set(false))
|
|
||||||
on_reply=Callback::new(move |name: String| {
|
|
||||||
whisper_target.set(Some(name));
|
|
||||||
})
|
|
||||||
on_context=Callback::new(move |name: String| {
|
|
||||||
set_conversation_partner.set(name);
|
|
||||||
set_conversation_modal_open.set(true);
|
|
||||||
})
|
|
||||||
/>
|
|
||||||
|
|
||||||
// Conversation modal
|
// Conversation modal
|
||||||
{
|
{
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue