2026-07-08 21:53:56 +02:00
|
|
|
use leptos::prelude::*;
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
|
|
|
|
/// Rooms available in the UI. Each maps to the NATS subject
|
|
|
|
|
/// `chat.room.<name>`, 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,
|
2026-07-08 22:14:26 +02:00
|
|
|
/// Publish instant as UTC epoch milliseconds; orders history.
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub ts: i64,
|
2026-07-08 21:53:56 +02:00
|
|
|
}
|
|
|
|
|
|
2026-07-08 22:14:26 +02:00
|
|
|
/// How many messages `room_history` backfills when joining a room.
|
|
|
|
|
pub const HISTORY_LIMIT: i64 = 100;
|
|
|
|
|
|
2026-07-08 21:53:56 +02:00
|
|
|
/// 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::<User>(SESSION_USER_KEY)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| ServerFnError::new(e.to_string()))?
|
|
|
|
|
else {
|
|
|
|
|
return Err(ServerFnError::new("not signed in"));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let state = expect_context::<AppState>();
|
2026-07-08 22:14:26 +02:00
|
|
|
let now = chrono::Utc::now();
|
2026-07-08 21:53:56 +02:00
|
|
|
let msg = ChatMessage {
|
|
|
|
|
id: uuid::Uuid::new_v4().to_string(),
|
|
|
|
|
room: room.clone(),
|
|
|
|
|
username: user.username,
|
|
|
|
|
display_name: user.display_name,
|
|
|
|
|
text,
|
2026-07-08 22:14:26 +02:00
|
|
|
time: now.format("%H:%M:%S").to_string(),
|
|
|
|
|
ts: now.timestamp_millis(),
|
2026-07-08 21:53:56 +02:00
|
|
|
};
|
|
|
|
|
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(())
|
|
|
|
|
}
|
2026-07-08 22:14:26 +02:00
|
|
|
|
|
|
|
|
/// 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<Vec<ChatMessage>, 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::<User>(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::<AppState>();
|
|
|
|
|
crate::server::store::recent(&state.pool, &room, HISTORY_LIMIT)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| ServerFnError::new(format!("history unavailable: {e}")))
|
|
|
|
|
}
|