2026-07-08 21:53:56 +02:00
|
|
|
|
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};
|
2026-07-27 23:52:27 +02:00
|
|
|
|
use crate::chat::{
|
|
|
|
|
|
is_authorized_for_room, is_valid_room, room_subject, ChatMessage, SendMessage, DEFAULT_ROOM,
|
|
|
|
|
|
ROOMS,
|
|
|
|
|
|
};
|
2026-07-08 22:14:26 +02:00
|
|
|
|
#[cfg(feature = "hydrate")]
|
|
|
|
|
|
use crate::chat::room_history;
|
2026-07-08 21:53:56 +02:00
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-07-27 23:52:27 +02:00
|
|
|
|
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()
|
|
|
|
|
|
}
|
2026-07-08 21:53:56 +02:00
|
|
|
|
_ => view! { <LoginGate/> }.into_any(),
|
|
|
|
|
|
})
|
|
|
|
|
|
}}
|
|
|
|
|
|
</Suspense>
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-27 23:52:27 +02:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 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>
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-08 21:53:56 +02:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 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">
|
2026-07-27 23:52:27 +02:00
|
|
|
|
<PulseMark/>
|
2026-07-08 21:53:56 +02:00
|
|
|
|
"CN" <span class="gate-title-accent">"ATS"</span>
|
|
|
|
|
|
</h1>
|
2026-07-27 23:52:27 +02:00
|
|
|
|
<p class="wordmark-sub gate-tagline">"chat over the bus"</p>
|
2026-07-08 21:53:56 +02:00
|
|
|
|
<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 |_| {
|
2026-07-08 22:14:26 +02:00
|
|
|
|
let room_name = room.get();
|
2026-07-08 21:53:56 +02:00
|
|
|
|
es_handle.update_value(|es| {
|
|
|
|
|
|
if let Some(es) = es.take() {
|
|
|
|
|
|
es.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
messages.set(Vec::new());
|
|
|
|
|
|
status.set(BusStatus::Connecting);
|
2026-07-08 22:14:26 +02:00
|
|
|
|
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;
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-07-08 21:53:56 +02:00
|
|
|
|
});
|
|
|
|
|
|
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();
|
2026-07-27 23:52:27 +02:00
|
|
|
|
let call_me = me.clone();
|
2026-07-08 21:53:56 +02:00
|
|
|
|
|
|
|
|
|
|
view! {
|
|
|
|
|
|
<div class="console">
|
|
|
|
|
|
<nav class="rail">
|
|
|
|
|
|
<div class="wordmark">
|
2026-07-27 23:52:27 +02:00
|
|
|
|
<PulseMark/>
|
2026-07-08 21:53:56 +02:00
|
|
|
|
"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()
|
2026-07-27 23:52:27 +02:00
|
|
|
|
.filter(|(name, _, _)| is_authorized_for_room(&user, name))
|
|
|
|
|
|
.map(|(name, desc, _)| {
|
2026-07-08 21:53:56 +02:00
|
|
|
|
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>
|
|
|
|
|
|
|
2026-07-27 23:52:27 +02:00
|
|
|
|
<Show when=move || room.get() == "lobby">
|
|
|
|
|
|
<CallPanel room=room me=call_me.clone()/>
|
|
|
|
|
|
</Show>
|
|
|
|
|
|
|
2026-07-08 21:53:56 +02:00
|
|
|
|
<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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-27 23:52:27 +02:00
|
|
|
|
/// 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">
|
|
|
|
|
|
<Show
|
|
|
|
|
|
when=move || in_call.get()
|
|
|
|
|
|
fallback=move || {
|
|
|
|
|
|
view! {
|
|
|
|
|
|
<button class="call-join" on:click=on_join>
|
|
|
|
|
|
"☎ join call"
|
|
|
|
|
|
</button>
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<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.get_value().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>
|
|
|
|
|
|
</Show>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
#[cfg(not(feature = "hydrate"))]
|
|
|
|
|
|
{
|
|
|
|
|
|
let _ = (room, me);
|
|
|
|
|
|
view! { <div class="call-panel"></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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-08 21:53:56 +02:00
|
|
|
|
#[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>
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|