add initial crates and apps
This commit is contained in:
parent
5c87ba3519
commit
1ca300098f
113 changed files with 28169 additions and 0 deletions
257
crates/chattyness-user-ui/src/components/ws_client.rs
Normal file
257
crates/chattyness-user-ui/src/components/ws_client.rs
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue