diff --git a/apps/chattyness-app/style/tailwind.css b/apps/chattyness-app/style/tailwind.css index a3d3d22..8f689c9 100644 --- a/apps/chattyness-app/style/tailwind.css +++ b/apps/chattyness-app/style/tailwind.css @@ -75,4 +75,14 @@ .error-message { @apply p-4 bg-red-900/50 border border-red-500 rounded-lg text-red-200; } + + /* Reconnection overlay spinner animation */ + .reconnect-spinner { + animation: reconnect-pulse 1.5s ease-in-out infinite; + } +} + +@keyframes reconnect-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } } diff --git a/crates/chattyness-user-ui/src/components.rs b/crates/chattyness-user-ui/src/components.rs index 5c365db..ce93976 100644 --- a/crates/chattyness-user-ui/src/components.rs +++ b/crates/chattyness-user-ui/src/components.rs @@ -20,6 +20,7 @@ pub mod scene_viewer; pub mod settings; pub mod settings_popup; pub mod tabs; +pub mod reconnection_overlay; pub mod ws_client; pub use avatar_canvas::*; @@ -38,6 +39,7 @@ pub use layout::*; pub use modals::*; pub use notification_history::*; pub use notifications::*; +pub use reconnection_overlay::*; pub use scene_viewer::*; pub use settings::*; pub use settings_popup::*; diff --git a/crates/chattyness-user-ui/src/components/reconnection_overlay.rs b/crates/chattyness-user-ui/src/components/reconnection_overlay.rs new file mode 100644 index 0000000..c74c0cf --- /dev/null +++ b/crates/chattyness-user-ui/src/components/reconnection_overlay.rs @@ -0,0 +1,391 @@ +//! Reconnection overlay component. +//! +//! Displays a full-screen overlay when WebSocket connection is lost, +//! with countdown timer and automatic retry logic. + +use leptos::prelude::*; + +use super::ws_client::WsState; + +/// Reconnection attempt phase. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReconnectionPhase { + /// Initial phase: 5 attempts with 5-second countdown each. + Initial { attempt: u8 }, + /// Extended phase: 10 attempts with 10-second countdown each. + Extended { attempt: u8 }, + /// All attempts exhausted. + Failed, +} + +impl ReconnectionPhase { + /// Get the countdown duration in seconds for the current phase. + pub fn countdown_duration(&self) -> u32 { + match self { + Self::Initial { .. } => 5, + Self::Extended { .. } => 10, + Self::Failed => 0, + } + } + + /// Get the maximum attempts for the current phase. + pub fn max_attempts(&self) -> u8 { + match self { + Self::Initial { .. } => 5, + Self::Extended { .. } => 10, + Self::Failed => 0, + } + } + + /// Advance to the next attempt or phase. + pub fn next(self) -> Self { + match self { + Self::Initial { attempt } if attempt < 5 => Self::Initial { attempt: attempt + 1 }, + Self::Initial { .. } => Self::Extended { attempt: 1 }, + Self::Extended { attempt } if attempt < 10 => Self::Extended { attempt: attempt + 1 }, + Self::Extended { .. } | Self::Failed => Self::Failed, + } + } + + /// Get the current attempt number. + pub fn attempt(&self) -> u8 { + match self { + Self::Initial { attempt } | Self::Extended { attempt } => *attempt, + Self::Failed => 0, + } + } + + /// Check if this is the initial phase. + pub fn is_initial(&self) -> bool { + matches!(self, Self::Initial { .. }) + } +} + +/// Internal state for the reconnection overlay. +#[derive(Clone, Copy, Debug)] +enum OverlayState { + /// Hidden (connected). + Hidden, + /// Showing countdown. + Countdown { + phase: ReconnectionPhase, + remaining: u32, + }, + /// Currently attempting to reconnect. + Reconnecting { phase: ReconnectionPhase }, + /// All attempts failed. + Failed, +} + +/// Reconnection overlay component. +/// +/// Shows a full-screen overlay when WebSocket connection is lost, +/// with countdown timer and automatic retry logic. +#[component] +pub fn ReconnectionOverlay( + /// WebSocket connection state to monitor. + ws_state: Signal, + /// Callback to trigger a reconnection attempt. + on_reconnect: Callback<()>, +) -> impl IntoView { + // Internal overlay state + let (overlay_state, set_overlay_state) = signal(OverlayState::Hidden); + + // Timer handle stored for cleanup + #[cfg(feature = "hydrate")] + let timer_handle: std::rc::Rc>> = + std::rc::Rc::new(std::cell::RefCell::new(None)); + + // Watch for WebSocket state changes + #[cfg(feature = "hydrate")] + { + let timer_handle = timer_handle.clone(); + + Effect::new(move |_| { + let state = ws_state.get(); + + match state { + WsState::Connected => { + // Connection restored - hide overlay and stop timer + if let Some(timer) = timer_handle.borrow_mut().take() { + drop(timer); + } + set_overlay_state.set(OverlayState::Hidden); + } + WsState::Disconnected | WsState::Error => { + // Check current state - only start countdown if we're hidden + let current = overlay_state.get_untracked(); + if matches!(current, OverlayState::Hidden) { + // Start initial countdown + let phase = ReconnectionPhase::Initial { attempt: 1 }; + let duration = phase.countdown_duration(); + set_overlay_state.set(OverlayState::Countdown { + phase, + remaining: duration, + }); + + // Start timer + start_countdown_timer( + timer_handle.clone(), + set_overlay_state, + on_reconnect.clone(), + ); + } else if matches!(current, OverlayState::Reconnecting { .. }) { + // Reconnection attempt failed - advance to next attempt + if let OverlayState::Reconnecting { phase } = current { + let next_phase = phase.next(); + if matches!(next_phase, ReconnectionPhase::Failed) { + set_overlay_state.set(OverlayState::Failed); + } else { + let duration = next_phase.countdown_duration(); + set_overlay_state.set(OverlayState::Countdown { + phase: next_phase, + remaining: duration, + }); + // Restart timer for next countdown + start_countdown_timer( + timer_handle.clone(), + set_overlay_state, + on_reconnect.clone(), + ); + } + } + } + } + WsState::Connecting => { + // Currently attempting to connect - keep current state + // The reconnecting state should already be set + } + } + }); + } + + // Render based on state + move || { + let state = overlay_state.get(); + + match state { + OverlayState::Hidden => None, + OverlayState::Countdown { phase, remaining } => { + let (phase_text, attempt_text) = match phase { + ReconnectionPhase::Initial { attempt } => { + ("Attempt", format!("{} of 5", attempt)) + } + ReconnectionPhase::Extended { attempt } => { + ("Extended attempt", format!("{} of 10", attempt)) + } + ReconnectionPhase::Failed => ("", String::new()), + }; + + Some( + view! { +
+ // Darkened backdrop + + + // Dialog box +
+ // Countdown circle +
+ + {remaining} + +
+ +

+ "Lost connection..." +

+ +

+ {format!("attempting to reconnect in {} seconds", remaining)} +

+ +

+ {format!("{} {}", phase_text, attempt_text)} +

+
+
+ } + .into_any(), + ) + } + OverlayState::Reconnecting { phase } => { + let (phase_text, attempt_text) = match phase { + ReconnectionPhase::Initial { attempt } => { + ("Attempt", format!("{} of 5", attempt)) + } + ReconnectionPhase::Extended { attempt } => { + ("Extended attempt", format!("{} of 10", attempt)) + } + ReconnectionPhase::Failed => ("", String::new()), + }; + + Some( + view! { +
+ // Darkened backdrop + + + // Dialog box +
+ // Spinner +
+ + + + +
+ +

+ "Reconnecting..." +

+ +

"Attempting to restore connection"

+ +

+ {format!("{} {}", phase_text, attempt_text)} +

+
+
+ } + .into_any(), + ) + } + OverlayState::Failed => Some( + view! { +
+ // Darkened backdrop + + + // Dialog box +
+ // Error icon +
+ + + +
+ +

+ "Connection Failed" +

+ +

+ "Unable to reconnect after multiple attempts. Please check your network connection and try again." +

+ + +
+
+ } + .into_any(), + ), + } + } +} + +/// Start the countdown timer. +#[cfg(feature = "hydrate")] +fn start_countdown_timer( + timer_handle: std::rc::Rc>>, + set_overlay_state: WriteSignal, + on_reconnect: Callback<()>, +) { + use gloo_timers::callback::Interval; + + // Stop any existing timer + if let Some(old_timer) = timer_handle.borrow_mut().take() { + drop(old_timer); + } + + // Create new timer that ticks every second + let timer = Interval::new(1000, move || { + set_overlay_state.update(|state| { + if let OverlayState::Countdown { phase, remaining } = state { + if *remaining > 1 { + // Decrement countdown + *remaining -= 1; + } else { + // Countdown reached zero - trigger reconnection + *state = OverlayState::Reconnecting { phase: *phase }; + on_reconnect.run(()); + } + } + }); + }); + + *timer_handle.borrow_mut() = Some(timer); +} diff --git a/crates/chattyness-user-ui/src/components/ws_client.rs b/crates/chattyness-user-ui/src/components/ws_client.rs index a162edc..a4e6952 100644 --- a/crates/chattyness-user-ui/src/components/ws_client.rs +++ b/crates/chattyness-user-ui/src/components/ws_client.rs @@ -80,6 +80,7 @@ pub struct WsError { pub fn use_channel_websocket( realm_slug: Signal, channel_id: Signal>, + reconnect_trigger: Signal, on_members_update: Callback>, on_chat_message: Callback, on_loose_props_sync: Callback>, @@ -120,6 +121,8 @@ pub fn use_channel_websocket( Effect::new(move |_| { let slug = realm_slug.get(); let ch_id = channel_id.get(); + // Track reconnect_trigger to force reconnection when it changes + let _trigger = reconnect_trigger.get(); // Cleanup previous connection if let Some(old_ws) = ws_ref_clone.borrow_mut().take() { @@ -458,6 +461,7 @@ fn handle_server_message( pub fn use_channel_websocket( _realm_slug: Signal, _channel_id: Signal>, + _reconnect_trigger: Signal, _on_members_update: Callback>, _on_chat_message: Callback, _on_loose_props_sync: Callback>, diff --git a/crates/chattyness-user-ui/src/pages/realm.rs b/crates/chattyness-user-ui/src/pages/realm.rs index 78cd484..9a28abe 100644 --- a/crates/chattyness-user-ui/src/pages/realm.rs +++ b/crates/chattyness-user-ui/src/pages/realm.rs @@ -14,8 +14,8 @@ use uuid::Uuid; use crate::components::{ ActiveBubble, AvatarEditorPopup, Card, ChatInput, ConversationModal, EmotionKeybindings, FadingMember, InventoryPopup, KeybindingsPopup, MessageLog, NotificationHistoryModal, - NotificationMessage, NotificationToast, RealmHeader, RealmSceneViewer, SettingsPopup, - ViewerSettings, + NotificationMessage, NotificationToast, RealmHeader, RealmSceneViewer, ReconnectionOverlay, + SettingsPopup, ViewerSettings, }; #[cfg(feature = "hydrate")] use crate::components::{ @@ -118,6 +118,9 @@ pub fn RealmPage() -> impl IntoView { // Error notification state (for whisper failures, etc.) let (error_message, set_error_message) = signal(Option::::None); + // Reconnection trigger - increment to force WebSocket reconnection + let (reconnect_trigger, set_reconnect_trigger) = signal(0u32); + let realm_data = LocalResource::new(move || { let slug = slug.get(); async move { @@ -330,9 +333,10 @@ pub fn RealmPage() -> impl IntoView { }); #[cfg(feature = "hydrate")] - let (_ws_state, ws_sender) = use_channel_websocket( + let (ws_state, ws_sender) = use_channel_websocket( slug, Signal::derive(move || channel_id.get()), + Signal::derive(move || reconnect_trigger.get()), on_members_update, on_chat_message, on_loose_props_sync, @@ -954,6 +958,22 @@ pub fn RealmPage() -> impl IntoView { /> } } + + // Reconnection overlay - shown when WebSocket disconnects + { + #[cfg(feature = "hydrate")] + let ws_state_for_overlay = ws_state; + #[cfg(not(feature = "hydrate"))] + let ws_state_for_overlay = Signal::derive(|| crate::components::ws_client::WsState::Disconnected); + view! { + + } + } } .into_any() }