Compare commits

..

No commits in common. "be60cf9a3dd30b4b0c771e727fedd9a2160a54630cc833ae638e29196bcb0864" and "3da420fe59256d14d9d18c31a940cd2cf7ce297552529e806a031b1aabec555b" have entirely different histories.

18 changed files with 352 additions and 1153 deletions

View file

@ -1,135 +0,0 @@
[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

View file

@ -1619,43 +1619,6 @@ 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 {

View file

@ -1,6 +1,6 @@
//! User-related database queries. //! User-related database queries.
use sqlx::{PgConnection, PgPool}; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use crate::models::{StaffMember, User, UserWithAuth}; use crate::models::{StaffMember, User, UserWithAuth};
@ -499,74 +499,6 @@ 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.

View file

@ -98,10 +98,6 @@ 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.
@ -264,14 +260,4 @@ 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,
},
} }

View file

@ -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,
RegisterGuestRequest, RegisterGuestResponse, SignupRequest, SignupResponse, UserSummary, SignupRequest, SignupResponse, UserSummary,
}, },
queries::{guests, memberships, realms, users}, queries::{guests, memberships, realms, users},
}; };
@ -471,62 +471,3 @@ 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,
}))
}

View file

@ -27,10 +27,6 @@ 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))

View file

@ -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, users}, queries::{avatars, channel_members, loose_props, memberships, moderation, realms, scenes},
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,35 +1074,6 @@ 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) => {

View file

@ -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::*;

View file

@ -177,12 +177,6 @@ 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);
@ -377,11 +371,6 @@ 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);
@ -403,7 +392,6 @@ 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 {
@ -422,7 +410,6 @@ 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();
@ -573,19 +560,6 @@ 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();
@ -644,24 +618,6 @@ 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() {
@ -883,13 +839,6 @@ 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>

View file

@ -31,9 +31,6 @@ 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.

View file

@ -14,7 +14,6 @@ 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>,
@ -42,7 +41,6 @@ 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()

View file

@ -1,7 +1,6 @@
//! 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;
@ -9,77 +8,6 @@ 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 {
@ -90,23 +18,16 @@ 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);
@ -117,46 +38,17 @@ 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| {
match current_filter { log.all_messages()
LogFilter::Whispers => { .iter()
// For whispers, merge session messages with persistent history .filter(|msg| match current_filter {
let mut session_whispers: Vec<ChatMessage> = message_log.with_value(|log| { LogFilter::All => true,
log.all_messages() LogFilter::Chat => !msg.is_whisper,
.iter() LogFilter::Whispers => msg.is_whisper,
.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
@ -219,12 +111,6 @@ 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
@ -241,114 +127,52 @@ pub fn LogPopup(
<For <For
each=move || filtered_messages() each=move || filtered_messages()
key=|msg| msg.message_id key=|msg| msg.message_id
children={ children=move |msg: ChatMessage| {
let on_reply = on_reply.clone(); let is_whisper = msg.is_whisper;
let on_context = on_context.clone(); let display_name = msg.display_name.clone();
let on_close = on_close.clone(); let content = msg.content.clone();
move |msg: ChatMessage| { let timestamp = msg.timestamp;
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;
let on_reply = on_reply.clone(); view! {
let on_context = on_context.clone(); <li class=move || {
let on_close = on_close.clone(); if is_whisper {
let on_close2 = on_close.clone(); "py-1 px-2 rounded bg-purple-900/30 border-l-2 border-purple-500"
} else {
view! { "py-1 px-2"
<li class=move || { }
if is_system { }>
"py-1 px-2 rounded bg-yellow-900/30 border-l-2 border-yellow-500" <span class="text-gray-500">
} else if is_whisper { "["
"py-1 px-2 rounded bg-purple-900/30 border-l-2 border-purple-500" {format_timestamp(timestamp)}
"] "
</span>
<span class=move || {
if is_whisper {
"text-purple-300 font-medium"
} else { } else {
"py-1 px-2" "text-blue-300 font-medium"
} }
}> }>
<div class="flex items-start justify-between"> {display_name}
<div class="flex-1"> </span>
<span class="text-gray-500"> <Show when=move || is_whisper>
"[" <span class="text-purple-400 text-xs ml-1">
{format_timestamp(timestamp)} "(whisper)"
"] " </span>
</span> </Show>
<span class=move || { <span class="text-gray-400">
if is_system { ": "
"text-yellow-300 font-medium" </span>
} else if is_whisper { <span class=move || {
"text-purple-300 font-medium" if is_whisper {
} else { "text-gray-300 italic"
"text-blue-300 font-medium" } else {
} "text-gray-200"
}> }
{display_name} }>
</span> {content}
<Show when=move || is_system> </span>
<span class="text-yellow-400 text-xs ml-1"> </li>
"(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>
}
} }
} }
/> />

View file

@ -0,0 +1,241 @@
//! 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()
}
}

View file

@ -56,6 +56,8 @@ 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 {
@ -137,6 +139,7 @@ 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(
@ -153,6 +156,11 @@ 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);
@ -209,6 +217,10 @@ 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>

View file

@ -1,277 +0,0 @@
//! 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>
}
}

View file

@ -733,61 +733,25 @@ pub fn RealmSceneViewer(
}); });
} }
// Dynamically add/remove wheel listener based on pan mode // Create wheel handler closure for use in view
// This avoids Chrome's "non-passive wheel listener" warning when not in pan mode let handle_wheel = move |ev: leptos::web_sys::WheelEvent| {
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
{ {
use std::cell::RefCell; // Only zoom in pan mode and without Ctrl key
use std::rc::Rc; if is_pan_mode.get() && !ev.ctrl_key() {
use wasm_bindgen::{closure::Closure, JsCast}; if let Some(zoom_callback) = on_zoom_change {
let delta_y = ev.delta_y();
let wheel_closure: Rc<RefCell<Option<Closure<dyn Fn(web_sys::WheelEvent)>>>> = // Normalize: scroll up (negative deltaY) = zoom in (positive delta)
Rc::new(RefCell::new(None)); // Scroll down (positive deltaY) = zoom out (negative delta)
let wheel_closure_clone = wheel_closure.clone(); let zoom_delta = if delta_y < 0.0 { 0.1 } else { -0.1 };
zoom_callback.run(zoom_delta);
Effect::new(move |_| { ev.prevent_default();
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;
@ -935,7 +899,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> <div node_ref=outer_container_ref class=outer_container_class style=outer_container_style on:wheel=handle_wheel>
<div <div
class=container_class class=container_class
style=container_style style=container_style

View file

@ -108,17 +108,6 @@ 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:
@ -140,7 +129,6 @@ 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;
@ -256,7 +244,6 @@ 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));
@ -332,7 +319,6 @@ 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,
&current_user_id_for_msg, &current_user_id_for_msg,
); );
} }
@ -444,7 +430,6 @@ 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();
@ -578,7 +563,6 @@ 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);
} }
@ -640,29 +624,6 @@ 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,
});
}
}
} }
} }
@ -683,7 +644,6 @@ 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);

View file

@ -14,14 +14,13 @@ 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,
NotificationMessage, NotificationToast, RealmHeader, RealmSceneViewer, ReconnectionOverlay, NotificationHistoryModal, NotificationMessage, NotificationToast, RealmHeader,
RegisterModal, SettingsPopup, ViewerSettings, RealmSceneViewer, ReconnectionOverlay, SettingsPopup, ViewerSettings,
}; };
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
use crate::components::{ use crate::components::{
ChannelMemberInfo, ChatMessage, DEFAULT_BUBBLE_TIMEOUT_MS, FADE_DURATION_MS, ChannelMemberInfo, ChatMessage, DEFAULT_BUBBLE_TIMEOUT_MS, FADE_DURATION_MS, HistoryEntry,
MemberIdentityInfo, ModCommandResultInfo, SummonInfo, TeleportInfo, WsError, ModCommandResultInfo, SummonInfo, TeleportInfo, WsError, add_to_history, use_channel_websocket,
add_whisper_to_history, use_channel_websocket,
}; };
use crate::utils::LocalStoragePersist; use crate::utils::LocalStoragePersist;
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
@ -90,9 +89,6 @@ 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));
@ -120,6 +116,7 @@ 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)
@ -267,8 +264,8 @@ pub fn RealmPage() -> impl IntoView {
} }
}); });
// Add to persistent whisper history in LocalStorage // Add to notification history for persistence
add_whisper_to_history(msg.clone()); add_to_history(HistoryEntry::from_whisper(&msg));
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)
@ -367,23 +364,6 @@ 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();
@ -447,23 +427,6 @@ 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))));
@ -536,24 +499,6 @@ 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
@ -563,20 +508,6 @@ 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,
@ -593,7 +524,6 @@ 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
@ -853,30 +783,6 @@ 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(' ');
@ -1220,9 +1126,6 @@ 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));
}); });
@ -1289,8 +1192,6 @@ 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>
@ -1351,13 +1252,6 @@ 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
@ -1395,39 +1289,6 @@ 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())
@ -1438,6 +1299,9 @@ 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);
}) })
@ -1497,6 +1361,19 @@ 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")]