}
}>
{move || {
user.get()
.map(|res| match res {
Ok(Some(u)) => {
// `room`'s own Memo only knows about is_valid_room (no
// user context available that early) - fall back to
// DEFAULT_ROOM here too if this user isn't authorized
// for the room the URL asked for, same as an unknown
// room name already does.
let u2 = u.clone();
let effective_room = Memo::new(move |_| {
let r = room.get();
if is_authorized_for_room(&u2, &r) {
r
} else {
DEFAULT_ROOM.to_string()
}
});
view! { }.into_any()
}
_ => view! { }.into_any(),
})
}}
}
}
// ---------------------------------------------------------------------------
// brand mark - the "voice pulse" ornament, currentColor so it always
// matches whatever text color surrounds it (sidebar wordmark vs. the much
// larger gate title)
// ---------------------------------------------------------------------------
#[component]
fn PulseMark() -> impl IntoView {
view! {
}
}
// ---------------------------------------------------------------------------
// unauthenticated: the gate
// ---------------------------------------------------------------------------
#[component]
fn LoginGate() -> impl IntoView {
view! {
"MESSAGE BUS · AUTH REQUIRED"
"CN" "ATS"
"chat over the bus"
"Realtime chat carried on NATS subjects. Identity issued by your Kanidm realm — no separate passwords, no local accounts."
}
}
// ---------------------------------------------------------------------------
// authenticated: the console
// ---------------------------------------------------------------------------
#[derive(Clone, Copy, PartialEq, Eq)]
enum BusStatus {
Connecting,
Live,
Offline,
}
impl BusStatus {
fn label(self) -> &'static str {
match self {
BusStatus::Connecting => "SYN…",
BusStatus::Live => "LIVE",
BusStatus::Offline => "RETRY",
}
}
}
#[component]
fn ChatShell(room: Memo, user: User) -> impl IntoView {
let messages = RwSignal::new(Vec::::new());
let status = RwSignal::new(BusStatus::Connecting);
let list_ref = NodeRef::::new();
// Subscribe to the room's SSE feed (browser only). Reconnects whenever
// the room changes; closes the previous stream first.
#[cfg(feature = "hydrate")]
{
let es_handle = StoredValue::new_local(None::);
Effect::new(move |_| {
let room_name = room.get();
es_handle.update_value(|es| {
if let Some(es) = es.take() {
es.close();
}
});
messages.set(Vec::new());
status.set(BusStatus::Connecting);
es_handle.set_value(open_event_source(&room_name, messages, status));
// Backfill from the Postgres archive; live SSE messages may land
// first, so merge by id (history first, then unseen live ones).
leptos::task::spawn_local(async move {
let Ok(history) = room_history(room_name.clone()).await else {
return;
};
// The user may have switched rooms while we were fetching.
if room.get_untracked() != room_name {
return;
}
messages.update(|live| {
let mut merged = history;
for m in live.drain(..) {
if !merged.iter().any(|h| h.id == m.id) {
merged.push(m);
}
}
*live = merged;
});
});
});
on_cleanup(move || {
es_handle.update_value(|es| {
if let Some(es) = es.take() {
es.close();
}
});
});
// Pin the stream to the bottom as messages arrive.
Effect::new(move |_| {
messages.track();
if let Some(el) = list_ref.get() {
el.set_scroll_top(el.scroll_height());
}
});
}
let draft = RwSignal::new(String::new());
let send = ServerAction::::new();
let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
let text = draft.get();
if text.trim().is_empty() {
return;
}
send.dispatch(SendMessage {
room: room.get(),
text,
});
draft.set(String::new());
};
let me = user.username.clone();
let call_me = me.clone();
view! {
{move || room.get()}
{move || room_subject(&room.get())}
{move || status.get().label()}
"[ ∅ ]"
"no traffic on this subject yet — say something"
{m.time}{m.display_name}{m.text}
}
}
/>
}
}
#[cfg(feature = "hydrate")]
fn open_event_source(
room: &str,
messages: RwSignal>,
status: RwSignal,
) -> Option {
use wasm_bindgen::{prelude::Closure, JsCast};
use web_sys::{EventSource, MessageEvent};
let es = EventSource::new(&format!("/sse/{room}")).ok()?;
let on_open = Closure::::new(move || status.set(BusStatus::Live));
es.set_onopen(Some(on_open.as_ref().unchecked_ref()));
on_open.forget();
let on_error = Closure::::new(move || status.set(BusStatus::Offline));
es.set_onerror(Some(on_error.as_ref().unchecked_ref()));
on_error.forget();
let on_message = Closure::::new(move |ev: MessageEvent| {
if let Some(data) = ev.data().as_string() {
if let Ok(msg) = serde_json::from_str::(&data) {
messages.update(|m| {
m.push(msg);
// keep the DOM bounded on long-lived tabs
if m.len() > 500 {
m.remove(0);
}
});
}
}
});
es.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
on_message.forget();
Some(es)
}
/// Minimal mesh call entry point, scoped to the `lobby` room only (see
/// `ChatShell`'s ``) - the
/// signaling itself (`call.rs`, `/call-sse/{room}`) is already generic
/// per-room, so widening this later is a one-line UI change, not an
/// architectural one. `CallState` (browser-only: it holds `web_sys`
/// types) can't exist in the `ssr` build at all, so the two targets get
/// entirely separate bodies rather than sharing signals across the gate.
#[component]
fn CallPanel(room: Memo, me: String) -> impl IntoView {
#[cfg(feature = "hydrate")]
{
use crate::webrtc::CallState;
let call_state = StoredValue::new_local(CallState::new(room.get_untracked(), me.clone()));
let in_call = call_state.get_value().in_call;
let local_video_ref = NodeRef::::new();
let es_handle = StoredValue::new_local(None::);
Effect::new(move |_| {
let room_name = room.get();
es_handle.update_value(|es| {
if let Some(es) = es.take() {
es.close();
}
});
es_handle.set_value(open_call_event_source(&room_name, call_state.get_value()));
});
on_cleanup(move || {
es_handle.update_value(|es| {
if let Some(es) = es.take() {
es.close();
}
});
let cs = call_state.get_value();
if cs.in_call.get_untracked() {
leptos::task::spawn_local(async move {
cs.leave().await;
});
}
});
// Mirror the local MediaStream into the preview