Files
cnats/src/app.rs
T
bl 9326f93576 Fix hydration mismatch in CallPanel that crashed room switching
The SSR branch rendered an empty call-panel div while the hydrate
branch expected a child button node. On any full load/refresh of the
lobby room, the mismatched hydration cursor hit tachys's
unreachable!() panic path, trapping the wasm instance and killing all
reactivity (routing, room switching, SSE) for the rest of the page
load, while the already-rendered SSR HTML stayed visually intact.

SSR now renders the same default join-button markup hydrate expects.
2026-07-29 10:50:02 +02:00

613 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use leptos::prelude::*;
use leptos_meta::{provide_meta_context, MetaTags, Stylesheet, Title};
use leptos_router::{
components::{Route, Router, Routes, A},
hooks::use_params_map,
path,
};
use crate::auth::{current_user, User};
use crate::chat::{
is_authorized_for_room, is_valid_room, room_subject, ChatMessage, SendMessage, DEFAULT_ROOM,
ROOMS,
};
#[cfg(feature = "hydrate")]
use crate::chat::room_history;
pub fn shell(options: LeptosOptions) -> impl IntoView {
view! {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<link rel="icon" href="/favicon.svg" type="image/svg+xml"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin=""/>
<link
href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700;900&family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap"
rel="stylesheet"
/>
<AutoReload options=options.clone()/>
<HydrationScripts options/>
<MetaTags/>
</head>
<body>
<App/>
</body>
</html>
}
}
#[component]
pub fn App() -> impl IntoView {
provide_meta_context();
view! {
<Stylesheet id="leptos" href="/pkg/cnats.css"/>
<Title text="cnats — chat over the bus"/>
<Router>
<Routes fallback=|| view! { <NotFound/> }>
<Route path=path!("") view=ChatPage/>
<Route path=path!("/r/:room") view=ChatPage/>
</Routes>
</Router>
}
}
#[component]
fn ChatPage() -> impl IntoView {
let params = use_params_map();
let room = Memo::new(move |_| {
params.with(|p| {
p.get("room")
.filter(|r| is_valid_room(r))
.unwrap_or_else(|| DEFAULT_ROOM.to_string())
})
});
let user = Resource::new(|| (), |_| current_user());
view! {
<Suspense fallback=move || {
view! {
<main class="gate">
<p class="gate-loading">"handshaking with the bus…"</p>
</main>
}
}>
{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! { <ChatShell room=effective_room user=u/> }.into_any()
}
_ => view! { <LoginGate/> }.into_any(),
})
}}
</Suspense>
}
}
// ---------------------------------------------------------------------------
// 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! {
<svg
class="pulse-mark"
viewBox="-15 -70 430 140"
fill="none"
stroke="currentColor"
stroke-width="18"
stroke-linecap="round"
>
<path d="M0,0 H60 Q80,-24 100,0 Q120,24 140,0 Q162,-52 184,0 Q206,52 228,0 Q250,-24 270,0 Q290,24 310,0 H400"></path>
</svg>
}
}
// ---------------------------------------------------------------------------
// unauthenticated: the gate
// ---------------------------------------------------------------------------
#[component]
fn LoginGate() -> impl IntoView {
view! {
<main class="gate">
<div class="gate-card">
<div class="gate-badge">"MESSAGE BUS · AUTH REQUIRED"</div>
<h1 class="gate-title">
<PulseMark/>
"CN" <span class="gate-title-accent">"ATS"</span>
</h1>
<p class="wordmark-sub gate-tagline">"chat over the bus"</p>
<p class="gate-sub">
"Realtime chat carried on NATS subjects. Identity issued by your Kanidm realm — no separate passwords, no local accounts."
</p>
<a class="gate-btn" href="/auth/login" rel="external">
<span class="gate-btn-glyph">"⏻"</span>
"Sign in with Kanidm"
</a>
<dl class="gate-meta">
<div><dt>"subjects"</dt><dd><code>"chat.room.*"</code></dd></div>
<div><dt>"downlink"</dt><dd><code>"SSE"</code></dd></div>
<div><dt>"identity"</dt><dd><code>"OIDC + PKCE"</code></dd></div>
</dl>
</div>
</main>
}
}
// ---------------------------------------------------------------------------
// 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<String>, user: User) -> impl IntoView {
let messages = RwSignal::new(Vec::<ChatMessage>::new());
let status = RwSignal::new(BusStatus::Connecting);
let list_ref = NodeRef::<leptos::html::Div>::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::<web_sys::EventSource>);
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::<SendMessage>::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! {
<div class="console">
<nav class="rail">
<div class="wordmark">
<PulseMark/>
"CN" <span class="wordmark-accent">"ATS"</span>
<span class="wordmark-sub">"chat over the bus"</span>
</div>
<div class="rail-label">"SUBJECTS"</div>
<div class="rooms">
{ROOMS
.iter()
.filter(|(name, _, _)| is_authorized_for_room(&user, name))
.map(|(name, desc, _)| {
let name = *name;
let desc = *desc;
view! {
<A
href=format!("/r/{name}")
attr:class=move || {
if room.get() == name { "room-link active" } else { "room-link" }
}
>
<span class="room-hash">""</span>
<span class="room-text">
<span class="room-name">{name}</span>
<span class="room-desc">{desc}</span>
</span>
</A>
}
})
.collect_view()}
</div>
<div class="rail-foot">
<div class="user-chip">
<span class="user-avatar">
{user.display_name.chars().next().unwrap_or('?').to_uppercase().to_string()}
</span>
<span class="user-names">
<span class="user-display">{user.display_name.clone()}</span>
<span class="user-handle">{format!("@{}", user.username)}</span>
</span>
</div>
<a class="logout" href="/auth/logout" rel="external" title="sign out">
"EOT ⏏"
</a>
</div>
</nav>
<main class="deck">
<header class="topbar">
<div class="topbar-room">
<h1>{move || room.get()}</h1>
<code class="subject-code">{move || room_subject(&room.get())}</code>
</div>
<div
class="topbar-status"
class:live=move || status.get() == BusStatus::Live
class:offline=move || status.get() == BusStatus::Offline
>
<span class="led"></span>
<span class="status-text">{move || status.get().label()}</span>
</div>
</header>
<Show when=move || room.get() == "lobby">
<CallPanel room=room me=call_me.clone()/>
</Show>
<div class="stream" node_ref=list_ref>
<Show when=move || messages.with(|m| m.is_empty())>
<div class="stream-empty">
<span class="empty-glyph">"[ ∅ ]"</span>
<p>"no traffic on this subject yet — say something"</p>
</div>
</Show>
<For
each=move || messages.get()
key=|m| m.id.clone()
children=move |m: ChatMessage| {
let mine = m.username == me;
view! {
<article class="msg" class:mine=mine>
<span class="msg-time">{m.time}</span>
<span class="msg-user">{m.display_name}</span>
<span class="msg-text">{m.text}</span>
</article>
}
}
/>
</div>
<form class="composer" on:submit=on_submit>
<span class="composer-prompt">""</span>
<input
type="text"
class="composer-input"
placeholder=move || format!("PUB {} …", room_subject(&room.get()))
prop:value=move || draft.get()
on:input=move |ev| draft.set(event_target_value(&ev))
autocomplete="off"
maxlength="2000"
/>
<button type="submit" class="composer-send" disabled=move || send.pending().get()>
"PUB ↵"
</button>
</form>
</main>
</div>
}
}
#[cfg(feature = "hydrate")]
fn open_event_source(
room: &str,
messages: RwSignal<Vec<ChatMessage>>,
status: RwSignal<BusStatus>,
) -> Option<web_sys::EventSource> {
use wasm_bindgen::{prelude::Closure, JsCast};
use web_sys::{EventSource, MessageEvent};
let es = EventSource::new(&format!("/sse/{room}")).ok()?;
let on_open = Closure::<dyn FnMut()>::new(move || status.set(BusStatus::Live));
es.set_onopen(Some(on_open.as_ref().unchecked_ref()));
on_open.forget();
let on_error = Closure::<dyn FnMut()>::new(move || status.set(BusStatus::Offline));
es.set_onerror(Some(on_error.as_ref().unchecked_ref()));
on_error.forget();
let on_message = Closure::<dyn FnMut(MessageEvent)>::new(move |ev: MessageEvent| {
if let Some(data) = ev.data().as_string() {
if let Ok(msg) = serde_json::from_str::<ChatMessage>(&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 `<Show when=move || room.get() == "lobby">`) - 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<String>, 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::<leptos::html::Video>::new();
let es_handle = StoredValue::new_local(None::<web_sys::EventSource>);
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 <video> element -
// `srcObject` has no HTML attribute form, has to be set via JS.
Effect::new(move |_| {
let stream = call_state.get_value().local_stream().get();
if let Some(el) = local_video_ref.get() {
el.set_src_object(stream.as_ref());
}
});
let on_join = move |_| {
let cs = call_state.get_value();
leptos::task::spawn_local(async move {
cs.join().await;
});
};
let on_leave = move |_| {
let cs = call_state.get_value();
leptos::task::spawn_local(async move {
cs.leave().await;
});
};
view! {
<div class="call-panel">
{move || {
if in_call.get() {
view! {
<CallActive
local_video_ref=local_video_ref
call_state=call_state.get_value()
on_leave=on_leave
/>
}
.into_any()
} else {
view! {
<button class="call-join" on:click=on_join>
"☎ join call"
</button>
}
.into_any()
}
}}
</div>
}
.into_any()
}
// Must mirror the hydrate branch's default (not-in-call) markup exactly -
// hydration reconciles this SSR output against what the hydrate branch
// above expects to find, and an empty div here (vs. the button hydrate
// wants) is a hydration mismatch that panics and traps the whole wasm
// instance, killing all reactivity on the page.
#[cfg(not(feature = "hydrate"))]
{
let _ = (room, me);
view! {
<div class="call-panel">
<button class="call-join" disabled=true>
"☎ join call"
</button>
</div>
}
.into_any()
}
}
/// The in-call subtree (video grid + leave button), split out of
/// `CallPanel` so its `<For>`-over-peers view doesn't get inlined as a type
/// parameter of `CallPanel`'s own `if`/`else` branch - that inlining is what
/// was blowing the compiler's query recursion limit once mesh calling's
/// nested `Show`/`For` landed inside `ChatShell`'s own `Show`.
#[cfg(feature = "hydrate")]
#[component]
fn CallActive(
local_video_ref: NodeRef<leptos::html::Video>,
call_state: crate::webrtc::CallState,
on_leave: impl Fn(leptos::ev::MouseEvent) + 'static,
) -> impl IntoView {
view! {
<div class="call-active">
<div class="video-grid">
<video
class="video-tile video-tile-local"
node_ref=local_video_ref
autoplay=true
muted=true
playsinline=true
></video>
<For
each=move || call_state.peer_streams()
key=|(id, _)| id.clone()
children=move |(_id, stream)| {
view! { <PeerVideoTile stream=stream/> }
}
/>
</div>
<button class="call-leave" on:click=on_leave>
"⏏ leave call"
</button>
</div>
}
}
/// One remote participant's video tile - a plain child component so each
/// tile gets its own `NodeRef`/effect pair instead of trying to juggle a
/// `Vec` of node refs by hand in the parent.
#[cfg(feature = "hydrate")]
#[component]
fn PeerVideoTile(stream: RwSignal<Option<web_sys::MediaStream>>) -> impl IntoView {
let video_ref = NodeRef::<leptos::html::Video>::new();
Effect::new(move |_| {
let s = stream.get();
if let Some(el) = video_ref.get() {
el.set_src_object(s.as_ref());
}
});
view! { <video class="video-tile" node_ref=video_ref autoplay=true playsinline=true></video> }
}
/// Bridges `/call-sse/{room}` into `CallState::handle_signal`. A separate
/// function from `open_event_source` (rather than a shared generic) since
/// the event name differs: `sse::call_events` emits a custom `"signal"`
/// SSE event, not the default unnamed one, so this needs
/// `add_event_listener_with_callback` instead of `set_onmessage` (which
/// only fires for the default event type).
#[cfg(feature = "hydrate")]
fn open_call_event_source(
room: &str,
call_state: crate::webrtc::CallState,
) -> Option<web_sys::EventSource> {
use wasm_bindgen::{prelude::Closure, JsCast};
use web_sys::{EventSource, MessageEvent};
let es = EventSource::new(&format!("/call-sse/{room}")).ok()?;
let on_signal = Closure::<dyn FnMut(MessageEvent)>::new(move |ev: MessageEvent| {
if let Some(data) = ev.data().as_string() {
if let Ok(signal) = serde_json::from_str::<crate::call::CallSignal>(&data) {
call_state.handle_signal(signal.from, signal.to, signal.kind);
}
}
});
es.add_event_listener_with_callback("signal", on_signal.as_ref().unchecked_ref())
.ok()?;
on_signal.forget();
Some(es)
}
#[component]
fn NotFound() -> impl IntoView {
view! {
<main class="gate">
<div class="gate-card">
<div class="gate-badge">"ERR · NO RESPONDERS"</div>
<h1 class="gate-title">"404"</h1>
<p class="gate-sub">"No subscribers on this subject."</p>
<a class="gate-btn" href="/">"⟵ back to the lobby"</a>
</div>
</main>
}
}