diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index d0c600a..0000000 --- a/Cargo.toml +++ /dev/null @@ -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 diff --git a/crates/chattyness-db/src/models.rs b/crates/chattyness-db/src/models.rs index bb1c4bd..3dee106 100644 --- a/crates/chattyness-db/src/models.rs +++ b/crates/chattyness-db/src/models.rs @@ -1619,43 +1619,6 @@ pub struct GuestLoginResponse { 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, - 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. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct JoinRealmRequest { diff --git a/crates/chattyness-db/src/queries/users.rs b/crates/chattyness-db/src/queries/users.rs index cddeb96..609d4ee 100644 --- a/crates/chattyness-db/src/queries/users.rs +++ b/crates/chattyness-db/src/queries/users.rs @@ -1,6 +1,6 @@ //! User-related database queries. -use sqlx::{PgConnection, PgPool}; +use sqlx::PgPool; use uuid::Uuid; use crate::models::{StaffMember, User, UserWithAuth}; @@ -499,74 +499,6 @@ pub async fn get_staff_member( 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). /// /// Guests are created with the 'guest' tag and no password. diff --git a/crates/chattyness-db/src/ws_messages.rs b/crates/chattyness-db/src/ws_messages.rs index 1b16cad..350ca0f 100644 --- a/crates/chattyness-db/src/ws_messages.rs +++ b/crates/chattyness-db/src/ws_messages.rs @@ -98,10 +98,6 @@ pub enum ClientMessage { /// Arguments for the subcommand. args: Vec, }, - - /// 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. @@ -264,14 +260,4 @@ pub enum ServerMessage { /// Human-readable result message. 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, - }, } diff --git a/crates/chattyness-user-ui/src/api/auth.rs b/crates/chattyness-user-ui/src/api/auth.rs index a40d82b..689fd6a 100644 --- a/crates/chattyness-user-ui/src/api/auth.rs +++ b/crates/chattyness-user-ui/src/api/auth.rs @@ -9,7 +9,7 @@ use chattyness_db::{ AccountStatus, AuthenticatedUser, CurrentUserResponse, GuestLoginRequest, GuestLoginResponse, JoinRealmRequest, JoinRealmResponse, LoginRequest, LoginResponse, LoginType, PasswordResetRequest, PasswordResetResponse, RealmRole, RealmSummary, - RegisterGuestRequest, RegisterGuestResponse, SignupRequest, SignupResponse, UserSummary, + SignupRequest, SignupResponse, UserSummary, }, queries::{guests, memberships, realms, users}, }; @@ -471,62 +471,3 @@ pub async fn reset_password( 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, - session: Session, - AuthUser(user): AuthUser, - Json(req): Json, -) -> Result, 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, - })) -} diff --git a/crates/chattyness-user-ui/src/api/routes.rs b/crates/chattyness-user-ui/src/api/routes.rs index 292ae39..dd528e4 100644 --- a/crates/chattyness-user-ui/src/api/routes.rs +++ b/crates/chattyness-user-ui/src/api/routes.rs @@ -27,10 +27,6 @@ pub fn api_router() -> Router { "/auth/reset-password", axum::routing::post(auth::reset_password), ) - .route( - "/auth/register-guest", - axum::routing::post(auth::register_guest), - ) // Realm routes (READ-ONLY) .route("/realms", get(realms::list_realms)) .route("/realms/{slug}", get(realms::get_realm)) diff --git a/crates/chattyness-user-ui/src/api/websocket.rs b/crates/chattyness-user-ui/src/api/websocket.rs index 3791d71..1473f24 100644 --- a/crates/chattyness-user-ui/src/api/websocket.rs +++ b/crates/chattyness-user-ui/src/api/websocket.rs @@ -19,7 +19,7 @@ use uuid::Uuid; use chattyness_db::{ 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}, }; 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) => { diff --git a/crates/chattyness-user-ui/src/components.rs b/crates/chattyness-user-ui/src/components.rs index 363a261..ec189ea 100644 --- a/crates/chattyness-user-ui/src/components.rs +++ b/crates/chattyness-user-ui/src/components.rs @@ -16,8 +16,8 @@ pub mod keybindings_popup; pub mod layout; pub mod log_popup; pub mod modals; +pub mod notification_history; pub mod notifications; -pub mod register_modal; pub mod scene_list_popup; pub mod scene_viewer; pub mod settings; @@ -42,8 +42,8 @@ pub use keybindings_popup::*; pub use layout::*; pub use log_popup::*; pub use modals::*; +pub use notification_history::*; pub use notifications::*; -pub use register_modal::*; pub use reconnection_overlay::*; pub use scene_list_popup::*; pub use scene_viewer::*; diff --git a/crates/chattyness-user-ui/src/components/chat.rs b/crates/chattyness-user-ui/src/components/chat.rs index 066698d..3f3dfe7 100644 --- a/crates/chattyness-user-ui/src/components/chat.rs +++ b/crates/chattyness-user-ui/src/components/chat.rs @@ -177,12 +177,6 @@ pub fn ChatInput( /// Callback to send a mod command. #[prop(optional)] on_mod_command: Option)>>, - /// Whether the current user is a guest (can register). - #[prop(default = Signal::derive(|| false))] - is_guest: Signal, - /// Callback to open registration modal. - #[prop(optional)] - on_open_register: Option>, ) -> impl IntoView { let (message, set_message) = signal(String::new()); let (command_mode, set_command_mode) = signal(CommandMode::None); @@ -377,11 +371,6 @@ pub fn ChatInput( && "mod".starts_with(&cmd) && 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 { // User is typing the argument part, no hint needed set_command_mode.set(CommandMode::None); @@ -403,7 +392,6 @@ pub fn ChatInput( || cmd.starts_with("t ") || cmd.starts_with("teleport ") || is_partial_mod - || is_partial_register { set_command_mode.set(CommandMode::ShowingSlashHint); } else { @@ -422,7 +410,6 @@ pub fn ChatInput( let on_open_settings = on_open_settings.clone(); let on_open_inventory = on_open_inventory.clone(); let on_open_log = on_open_log.clone(); - let on_open_register = on_open_register.clone(); move |ev: web_sys::KeyboardEvent| { let key = ev.key(); let current_mode = command_mode.get_untracked(); @@ -573,19 +560,6 @@ pub fn ChatInput( ev.prevent_default(); 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 ev.prevent_default(); @@ -644,24 +618,6 @@ pub fn ChatInput( 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 if let Some((target_name, whisper_content)) = parse_whisper_command(&msg) { if !whisper_content.trim().is_empty() { @@ -883,13 +839,6 @@ pub fn ChatInput( "/" "mod" - // Show /register hint for guests - - "|" - "/" - "r" - "[egister]" - diff --git a/crates/chattyness-user-ui/src/components/chat_types.rs b/crates/chattyness-user-ui/src/components/chat_types.rs index d5bfbb1..60b3f14 100644 --- a/crates/chattyness-user-ui/src/components/chat_types.rs +++ b/crates/chattyness-user-ui/src/components/chat_types.rs @@ -31,9 +31,6 @@ pub struct ChatMessage { /// For whispers: true = same scene (show as bubble), false = cross-scene (show as notification). #[serde(default = "default_true")] 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. diff --git a/crates/chattyness-user-ui/src/components/forms.rs b/crates/chattyness-user-ui/src/components/forms.rs index 61a6f90..eb0decb 100644 --- a/crates/chattyness-user-ui/src/components/forms.rs +++ b/crates/chattyness-user-ui/src/components/forms.rs @@ -14,7 +14,6 @@ pub fn TextInput( #[prop(optional)] minlength: Option, #[prop(optional)] maxlength: Option, #[prop(optional)] pattern: &'static str, - #[prop(optional)] autocomplete: &'static str, #[prop(optional)] class: &'static str, #[prop(into)] value: Signal, on_input: Callback, @@ -42,7 +41,6 @@ pub fn TextInput( minlength=minlength maxlength=maxlength 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 } class="input-base" prop:value=move || value.get() diff --git a/crates/chattyness-user-ui/src/components/log_popup.rs b/crates/chattyness-user-ui/src/components/log_popup.rs index 23c8426..c4b09f1 100644 --- a/crates/chattyness-user-ui/src/components/log_popup.rs +++ b/crates/chattyness-user-ui/src/components/log_popup.rs @@ -1,7 +1,6 @@ //! Message log popup component. //! //! Displays a filterable chronological log of received messages. -//! Includes persistent whisper history via LocalStorage. use leptos::prelude::*; use leptos::reactive::owner::LocalStorage; @@ -9,77 +8,6 @@ use leptos::reactive::owner::LocalStorage; use super::chat_types::{ChatMessage, MessageLog}; 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 { - 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 { - 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. #[derive(Clone, Copy, PartialEq, Eq, Default)] pub enum LogFilter { @@ -90,23 +18,16 @@ pub enum LogFilter { Chat, /// Show only whispers. Whispers, - /// Show only system/admin messages (teleports, summons, mod commands). - System, } /// Message log popup component. /// -/// Displays a filterable list of messages from the session, -/// with persistent whisper history from LocalStorage. +/// Displays a filterable list of messages from the session. #[component] pub fn LogPopup( #[prop(into)] open: Signal, message_log: StoredValue, on_close: Callback<()>, - /// Callback when user wants to reply to a whisper. - on_reply: Callback, - /// Callback when user wants to see conversation context. - on_context: Callback, ) -> impl IntoView { let (filter, set_filter) = signal(LogFilter::All); @@ -117,46 +38,17 @@ pub fn LogPopup( // Reading open ensures we re-fetch messages when modal opens let _ = open.get(); let current_filter = filter.get(); - - match current_filter { - LogFilter::Whispers => { - // For whispers, merge session messages with persistent history - let mut session_whispers: Vec = message_log.with_value(|log| { - log.all_messages() - .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::>() + message_log.with_value(|log| { + log.all_messages() + .iter() + .filter(|msg| match current_filter { + LogFilter::All => true, + LogFilter::Chat => !msg.is_whisper, + LogFilter::Whispers => msg.is_whisper, }) - } - } + .cloned() + .collect::>() + }) }; // Auto-scroll to bottom when modal opens @@ -219,12 +111,6 @@ pub fn LogPopup( > "Whispers" - // Message list @@ -241,114 +127,52 @@ pub fn LogPopup( + + "[" + {format_timestamp(timestamp)} + "] " + + -
-
- - "[" - {format_timestamp(timestamp)} - "] " - - - {display_name} - - - - "(system)" - - - - - "(whisper)" - - - - ": " - - - {content} - -
- -
- - -
-
-
- - } + {display_name} +
+ + + "(whisper)" + + + + ": " + + + {content} + + } } /> diff --git a/crates/chattyness-user-ui/src/components/notification_history.rs b/crates/chattyness-user-ui/src/components/notification_history.rs new file mode 100644 index 0000000..f1c9fdf --- /dev/null +++ b/crates/chattyness-user-ui/src/components/notification_history.rs @@ -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 { + 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 { + 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, + on_close: Callback<()>, + /// Callback when user wants to reply to a message. + on_reply: Callback, + /// Callback when user wants to see conversation context. + on_context: Callback, +) -> impl IntoView { + // Load history when modal opens + let history = Signal::derive(move || { + if open.get() { + load_history() + } else { + Vec::new() + } + }); + + view! { + +
+ + "No notifications yet" +

+ } + > +
    + +
    +
    +
    + + {sender_name} + + + {format_timestamp(entry.timestamp)} + + + + "whisper" + + +
    +

    + {entry.content.clone()} +

    +
    +
    + + +
    +
    + + } + } + } + /> +
+
+
+ + // Footer hint +
+ "Press " "Esc" " to close" +
+
+ } +} + +/// 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() + } +} diff --git a/crates/chattyness-user-ui/src/components/notifications.rs b/crates/chattyness-user-ui/src/components/notifications.rs index a87c84e..f060463 100644 --- a/crates/chattyness-user-ui/src/components/notifications.rs +++ b/crates/chattyness-user-ui/src/components/notifications.rs @@ -56,6 +56,8 @@ pub fn NotificationToast( on_reply: Callback, /// Callback when user wants to see context (press 'c'). on_context: Callback, + /// Callback when user wants to see history (press 'h'). + on_history: Callback<()>, /// Callback when notification is dismissed. on_dismiss: Callback, ) -> impl IntoView { @@ -137,6 +139,7 @@ pub fn NotificationToast( let on_reply = on_reply.clone(); let on_context = on_context.clone(); + let on_history = on_history.clone(); let on_dismiss = on_dismiss.clone(); let closure = wasm_bindgen::closure::Closure::::new( @@ -153,6 +156,11 @@ pub fn NotificationToast( on_context.run(display_name.clone()); on_dismiss.run(notif_id); } + "h" | "H" => { + ev.prevent_default(); + on_history.run(()); + on_dismiss.run(notif_id); + } "Escape" => { ev.prevent_default(); on_dismiss.run(notif_id); @@ -209,6 +217,10 @@ pub fn NotificationToast( "c" " context" + + "h" + " history" + diff --git a/crates/chattyness-user-ui/src/components/register_modal.rs b/crates/chattyness-user-ui/src/components/register_modal.rs deleted file mode 100644 index 2214f80..0000000 --- a/crates/chattyness-user-ui/src/components/register_modal.rs +++ /dev/null @@ -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, - on_close: Callback<()>, - #[prop(optional)] on_success: Option>, -) -> 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::::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::().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::().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! { -