use leptos::prelude::*; use serde::{Deserialize, Serialize}; /// Call signaling for a room, kept entirely separate from `chat::ChatMessage` /// - deliberately a different NATS subject namespace (`call.room.`, /// not `chat.room.`) so `server::store`'s JetStream/Postgres archive /// (scoped to `chat.room.*` only) never sees it. Ephemeral SDP/ICE has no /// business being durably stored. pub fn call_subject(room: &str) -> String { format!("call.room.{room}") } /// `from`/`to` are peer ids - currently just the signed-in username (same /// identity chat messages use). `to: None` is a room-wide broadcast (only /// `Join`/`Leave` use this); everything else is directed at one peer, with /// every other browser in the room ignoring it client-side. Mesh calls at /// this scale (~4 people) don't need per-peer NATS subjects - broadcast + /// client-side filter is the simplest thing that works. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct CallSignal { pub room: String, pub from: String, pub to: Option, pub kind: CallSignalKind, } #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(tag = "kind", content = "data")] pub enum CallSignalKind { /// Announces presence to the room; existing participants respond by /// initiating an offer to the new peer. Join, Leave, Offer(String), Answer(String), /// A single trickled ICE candidate, JSON-encoded /// (`RTCIceCandidateInit`, produced client-side). IceCandidate(String), } /// Publishes a call-signaling message to the room's signaling subject. /// Requires a signed-in, room-authorized session - same two checks as /// `chat::send_message`, and deliberately not shared via a helper since /// the existing chat functions already establish that each enforcement /// point re-does this small check inline rather than factoring it out. #[server] pub async fn send_signal( room: String, to: Option, kind: CallSignalKind, ) -> Result<(), ServerFnError> { use crate::auth::{User, SESSION_USER_KEY}; use crate::chat::is_authorized_for_room; use crate::server::AppState; if !crate::chat::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")); }; if !is_authorized_for_room(&user, &room) { return Err(ServerFnError::new("not authorized for this room")); } let state = expect_context::(); let signal = CallSignal { room: room.clone(), from: user.username, to, kind, }; let payload = serde_json::to_vec(&signal).map_err(|e| ServerFnError::new(e.to_string()))?; state .nats .publish(call_subject(&room), payload.into()) .await .map_err(|e| ServerFnError::new(format!("nats publish failed: {e}")))?; Ok(()) }