use leptos::prelude::*; use serde::{Deserialize, Serialize}; /// Rooms available in the UI. Each maps to the NATS subject /// `chat.room.`, so any other NATS client on the bus can join in. pub const ROOMS: &[(&str, &str)] = &[ ("lobby", "general traffic"), ("dev", "build & ship"), ("ops", "incidents & infra"), ("random", "off the record"), ]; pub const DEFAULT_ROOM: &str = "lobby"; pub fn is_valid_room(room: &str) -> bool { ROOMS.iter().any(|(name, _)| *name == room) } pub fn room_subject(room: &str) -> String { format!("chat.room.{room}") } /// A single chat message as it travels over NATS (JSON-encoded payload). #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ChatMessage { pub id: String, pub room: String, pub username: String, pub display_name: String, pub text: String, /// Pre-formatted UTC wall-clock time, e.g. "14:03:27". pub time: String, /// Publish instant as UTC epoch milliseconds; orders history. #[serde(default)] pub ts: i64, } /// How many messages `room_history` backfills when joining a room. pub const HISTORY_LIMIT: i64 = 100; /// Publishes a message to the room's NATS subject. Requires a signed-in /// session; the sender identity always comes from the session, never from /// the client. #[server] pub async fn send_message(room: String, text: String) -> Result<(), ServerFnError> { use crate::auth::{User, SESSION_USER_KEY}; use crate::server::AppState; let text = text.trim().to_string(); if text.is_empty() || text.len() > 2000 { return Err(ServerFnError::new("message must be 1..=2000 characters")); } if !is_valid_room(&room) { return Err(ServerFnError::new("unknown room")); } let session: tower_sessions::Session = leptos_axum::extract().await?; let Some(user) = session .get::(SESSION_USER_KEY) .await .map_err(|e| ServerFnError::new(e.to_string()))? else { return Err(ServerFnError::new("not signed in")); }; let state = expect_context::(); let now = chrono::Utc::now(); let msg = ChatMessage { id: uuid::Uuid::new_v4().to_string(), room: room.clone(), username: user.username, display_name: user.display_name, text, time: now.format("%H:%M:%S").to_string(), ts: now.timestamp_millis(), }; let payload = serde_json::to_vec(&msg).map_err(|e| ServerFnError::new(e.to_string()))?; state .nats .publish(room_subject(&room), payload.into()) .await .map_err(|e| ServerFnError::new(format!("nats publish failed: {e}")))?; Ok(()) } /// Returns the most recent messages for a room (oldest first), read from the /// Postgres archive maintained by the JetStream consumer. #[server] pub async fn room_history(room: String) -> Result, ServerFnError> { use crate::auth::{User, SESSION_USER_KEY}; use crate::server::AppState; if !is_valid_room(&room) { return Err(ServerFnError::new("unknown room")); } let session: tower_sessions::Session = leptos_axum::extract().await?; if session .get::(SESSION_USER_KEY) .await .map_err(|e| ServerFnError::new(e.to_string()))? .is_none() { return Err(ServerFnError::new("not signed in")); } let state = expect_context::(); crate::server::store::recent(&state.pool, &room, HISTORY_LIMIT) .await .map_err(|e| ServerFnError::new(format!("history unavailable: {e}"))) }