add initial crates and apps

This commit is contained in:
Evan Carroll 2026-01-12 15:34:40 -06:00
parent 5c87ba3519
commit 1ca300098f
113 changed files with 28169 additions and 0 deletions

View file

@ -0,0 +1,257 @@
//! WebSocket client for channel presence.
//!
//! Provides a Leptos hook to manage WebSocket connections for real-time
//! position updates, emotion changes, and member synchronization.
use leptos::prelude::*;
use leptos::reactive::owner::LocalStorage;
use chattyness_db::models::ChannelMemberWithAvatar;
use chattyness_db::ws_messages::{ClientMessage, ServerMessage};
/// WebSocket connection state.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum WsState {
/// Attempting to connect.
Connecting,
/// Connected and ready.
Connected,
/// Disconnected (not connected).
Disconnected,
/// Connection error occurred.
Error,
}
/// Sender function type for WebSocket messages.
pub type WsSender = Box<dyn Fn(ClientMessage)>;
/// Local stored value type for the sender (non-Send, WASM-compatible).
pub type WsSenderStorage = StoredValue<Option<WsSender>, LocalStorage>;
/// Hook to manage WebSocket connection for a channel.
///
/// Returns a tuple of:
/// - `Signal<WsState>` - The current connection state
/// - `WsSenderStorage` - A stored sender function to send messages
#[cfg(feature = "hydrate")]
pub fn use_channel_websocket(
realm_slug: Signal<String>,
channel_id: Signal<Option<uuid::Uuid>>,
on_members_update: Callback<Vec<ChannelMemberWithAvatar>>,
) -> (Signal<WsState>, WsSenderStorage) {
use std::cell::RefCell;
use std::rc::Rc;
use wasm_bindgen::{closure::Closure, JsCast};
use web_sys::{CloseEvent, ErrorEvent, MessageEvent, WebSocket};
let (ws_state, set_ws_state) = signal(WsState::Disconnected);
let ws_ref: Rc<RefCell<Option<WebSocket>>> = Rc::new(RefCell::new(None));
let members: Rc<RefCell<Vec<ChannelMemberWithAvatar>>> = Rc::new(RefCell::new(Vec::new()));
// Create a stored sender function (using new_local for WASM single-threaded environment)
let ws_ref_for_send = ws_ref.clone();
let sender: WsSenderStorage = StoredValue::new_local(Some(Box::new(
move |msg: ClientMessage| {
if let Some(ws) = ws_ref_for_send.borrow().as_ref() {
if ws.ready_state() == WebSocket::OPEN {
if let Ok(json) = serde_json::to_string(&msg) {
#[cfg(debug_assertions)]
web_sys::console::log_1(&format!("[WS->Server] {}", json).into());
let _ = ws.send_with_str(&json);
}
}
}
},
)));
// Effect to manage WebSocket lifecycle
let ws_ref_clone = ws_ref.clone();
let members_clone = members.clone();
Effect::new(move |_| {
let slug = realm_slug.get();
let ch_id = channel_id.get();
// Cleanup previous connection
if let Some(old_ws) = ws_ref_clone.borrow_mut().take() {
let _ = old_ws.close();
}
let Some(ch_id) = ch_id else {
set_ws_state.set(WsState::Disconnected);
return;
};
if slug.is_empty() {
set_ws_state.set(WsState::Disconnected);
return;
}
// Construct WebSocket URL
let window = web_sys::window().unwrap();
let location = window.location();
let protocol = if location.protocol().unwrap_or_default() == "https:" {
"wss:"
} else {
"ws:"
};
let host = location.host().unwrap_or_default();
let url = format!(
"{}//{}/api/realms/{}/channels/{}/ws",
protocol, host, slug, ch_id
);
#[cfg(debug_assertions)]
web_sys::console::log_1(&format!("[WS] Connecting to: {}", url).into());
set_ws_state.set(WsState::Connecting);
let ws = match WebSocket::new(&url) {
Ok(ws) => ws,
Err(e) => {
#[cfg(debug_assertions)]
web_sys::console::error_1(&format!("[WS] Failed to create: {:?}", e).into());
set_ws_state.set(WsState::Error);
return;
}
};
// onopen
let set_ws_state_open = set_ws_state;
let onopen = Closure::wrap(Box::new(move |_: web_sys::Event| {
#[cfg(debug_assertions)]
web_sys::console::log_1(&"[WS] Connected".into());
set_ws_state_open.set(WsState::Connected);
}) as Box<dyn FnMut(web_sys::Event)>);
ws.set_onopen(Some(onopen.as_ref().unchecked_ref()));
onopen.forget();
// onmessage
let members_for_msg = members_clone.clone();
let on_members_update_clone = on_members_update.clone();
let onmessage = Closure::wrap(Box::new(move |e: MessageEvent| {
if let Ok(text) = e.data().dyn_into::<js_sys::JsString>() {
let text: String = text.into();
#[cfg(debug_assertions)]
web_sys::console::log_1(&format!("[WS<-Server] {}", text).into());
if let Ok(msg) = serde_json::from_str::<ServerMessage>(&text) {
handle_server_message(msg, &members_for_msg, &on_members_update_clone);
}
}
}) as Box<dyn FnMut(MessageEvent)>);
ws.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
onmessage.forget();
// onerror
let set_ws_state_err = set_ws_state;
let onerror = Closure::wrap(Box::new(move |e: ErrorEvent| {
#[cfg(debug_assertions)]
web_sys::console::error_1(&format!("[WS] Error: {:?}", e.message()).into());
set_ws_state_err.set(WsState::Error);
}) as Box<dyn FnMut(ErrorEvent)>);
ws.set_onerror(Some(onerror.as_ref().unchecked_ref()));
onerror.forget();
// onclose
let set_ws_state_close = set_ws_state;
let onclose = Closure::wrap(Box::new(move |e: CloseEvent| {
#[cfg(debug_assertions)]
web_sys::console::log_1(
&format!("[WS] Closed: code={}, reason={}", e.code(), e.reason()).into(),
);
set_ws_state_close.set(WsState::Disconnected);
}) as Box<dyn FnMut(CloseEvent)>);
ws.set_onclose(Some(onclose.as_ref().unchecked_ref()));
onclose.forget();
*ws_ref_clone.borrow_mut() = Some(ws);
});
(Signal::derive(move || ws_state.get()), sender)
}
/// Handle a message received from the server.
#[cfg(feature = "hydrate")]
fn handle_server_message(
msg: ServerMessage,
members: &std::rc::Rc<std::cell::RefCell<Vec<ChannelMemberWithAvatar>>>,
on_update: &Callback<Vec<ChannelMemberWithAvatar>>,
) {
let mut members_vec = members.borrow_mut();
match msg {
ServerMessage::Welcome {
member: _,
members: initial_members,
} => {
*members_vec = initial_members;
on_update.run(members_vec.clone());
}
ServerMessage::MemberJoined { member } => {
// Remove if exists (rejoin case), then add
members_vec.retain(|m| {
m.member.user_id != member.member.user_id
|| m.member.guest_session_id != member.member.guest_session_id
});
members_vec.push(member);
on_update.run(members_vec.clone());
}
ServerMessage::MemberLeft {
user_id,
guest_session_id,
} => {
members_vec.retain(|m| {
m.member.user_id != user_id || m.member.guest_session_id != guest_session_id
});
on_update.run(members_vec.clone());
}
ServerMessage::PositionUpdated {
user_id,
guest_session_id,
x,
y,
} => {
if let Some(m) = members_vec.iter_mut().find(|m| {
m.member.user_id == user_id && m.member.guest_session_id == guest_session_id
}) {
m.member.position_x = x;
m.member.position_y = y;
}
on_update.run(members_vec.clone());
}
ServerMessage::EmotionUpdated {
user_id,
guest_session_id,
emotion,
emotion_layer,
} => {
if let Some(m) = members_vec.iter_mut().find(|m| {
m.member.user_id == user_id && m.member.guest_session_id == guest_session_id
}) {
m.member.current_emotion = emotion as i16;
m.avatar.emotion_layer = emotion_layer;
}
on_update.run(members_vec.clone());
}
ServerMessage::Pong => {
// Heartbeat acknowledged - nothing to do
}
ServerMessage::Error { code, message } => {
#[cfg(debug_assertions)]
web_sys::console::error_1(&format!("[WS] Server error: {} - {}", code, message).into());
}
}
}
/// Stub implementation for SSR (server-side rendering).
#[cfg(not(feature = "hydrate"))]
pub fn use_channel_websocket(
_realm_slug: Signal<String>,
_channel_id: Signal<Option<uuid::Uuid>>,
_on_members_update: Callback<Vec<ChannelMemberWithAvatar>>,
) -> (Signal<WsState>, WsSenderStorage) {
let (ws_state, _) = signal(WsState::Disconnected);
let sender: WsSenderStorage = StoredValue::new_local(None);
(Signal::derive(move || ws_state.get()), sender)
}