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! { } } #[component] pub fn App() -> impl IntoView { provide_meta_context(); view! { <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> } }