fix: reconnect on ws failure
This commit is contained in:
parent
84cb4e5e78
commit
27b3658e1d
5 changed files with 430 additions and 3 deletions
|
|
@ -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::*;
|
||||
|
|
|
|||
391
crates/chattyness-user-ui/src/components/reconnection_overlay.rs
Normal file
391
crates/chattyness-user-ui/src/components/reconnection_overlay.rs
Normal file
|
|
@ -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<WsState>,
|
||||
/// 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::cell::RefCell<Option<gloo_timers::callback::Interval>>> =
|
||||
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! {
|
||||
<div
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="reconnect-title"
|
||||
>
|
||||
// Darkened backdrop
|
||||
<div
|
||||
class="absolute inset-0 bg-black/80 backdrop-blur-md"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
// Dialog box
|
||||
<div class="relative bg-gray-800 rounded-lg shadow-2xl max-w-md w-full mx-4 p-8 border border-gray-700 text-center">
|
||||
// Countdown circle
|
||||
<div class="w-20 h-20 mx-auto rounded-full bg-yellow-900/30 flex items-center justify-center mb-6">
|
||||
<span class="text-4xl font-bold text-yellow-300">
|
||||
{remaining}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2
|
||||
id="reconnect-title"
|
||||
class="text-xl font-semibold text-white mb-2"
|
||||
>
|
||||
"Lost connection..."
|
||||
</h2>
|
||||
|
||||
<p class="text-gray-300 mb-4">
|
||||
{format!("attempting to reconnect in {} seconds", remaining)}
|
||||
</p>
|
||||
|
||||
<p class="text-sm text-gray-400">
|
||||
{format!("{} {}", phase_text, attempt_text)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
.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! {
|
||||
<div
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="reconnect-title"
|
||||
>
|
||||
// Darkened backdrop
|
||||
<div
|
||||
class="absolute inset-0 bg-black/80 backdrop-blur-md"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
// Dialog box
|
||||
<div class="relative bg-gray-800 rounded-lg shadow-2xl max-w-md w-full mx-4 p-8 border border-gray-700 text-center">
|
||||
// Spinner
|
||||
<div class="w-20 h-20 mx-auto rounded-full bg-blue-900/30 flex items-center justify-center mb-6 reconnect-spinner">
|
||||
<svg
|
||||
class="w-10 h-10 text-blue-400 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h2
|
||||
id="reconnect-title"
|
||||
class="text-xl font-semibold text-white mb-2"
|
||||
>
|
||||
"Reconnecting..."
|
||||
</h2>
|
||||
|
||||
<p class="text-gray-300 mb-4">"Attempting to restore connection"</p>
|
||||
|
||||
<p class="text-sm text-gray-400">
|
||||
{format!("{} {}", phase_text, attempt_text)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
.into_any(),
|
||||
)
|
||||
}
|
||||
OverlayState::Failed => Some(
|
||||
view! {
|
||||
<div
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="reconnect-title"
|
||||
>
|
||||
// Darkened backdrop
|
||||
<div
|
||||
class="absolute inset-0 bg-black/80 backdrop-blur-md"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
// Dialog box
|
||||
<div class="relative bg-gray-800 rounded-lg shadow-2xl max-w-md w-full mx-4 p-8 border border-gray-700 text-center">
|
||||
// Error icon
|
||||
<div class="w-20 h-20 mx-auto rounded-full bg-red-900/30 flex items-center justify-center mb-6">
|
||||
<svg
|
||||
class="w-10 h-10 text-red-400"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h2
|
||||
id="reconnect-title"
|
||||
class="text-xl font-semibold text-white mb-2"
|
||||
>
|
||||
"Connection Failed"
|
||||
</h2>
|
||||
|
||||
<p class="text-gray-300 mb-6">
|
||||
"Unable to reconnect after multiple attempts. Please check your network connection and try again."
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-800 transition-colors duration-200"
|
||||
on:click=move |_| {
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.location().reload();
|
||||
}
|
||||
}
|
||||
}
|
||||
>
|
||||
"Refresh Page"
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
.into_any(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the countdown timer.
|
||||
#[cfg(feature = "hydrate")]
|
||||
fn start_countdown_timer(
|
||||
timer_handle: std::rc::Rc<std::cell::RefCell<Option<gloo_timers::callback::Interval>>>,
|
||||
set_overlay_state: WriteSignal<OverlayState>,
|
||||
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);
|
||||
}
|
||||
|
|
@ -80,6 +80,7 @@ pub struct WsError {
|
|||
pub fn use_channel_websocket(
|
||||
realm_slug: Signal<String>,
|
||||
channel_id: Signal<Option<uuid::Uuid>>,
|
||||
reconnect_trigger: Signal<u32>,
|
||||
on_members_update: Callback<Vec<ChannelMemberWithAvatar>>,
|
||||
on_chat_message: Callback<ChatMessage>,
|
||||
on_loose_props_sync: Callback<Vec<LooseProp>>,
|
||||
|
|
@ -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<String>,
|
||||
_channel_id: Signal<Option<uuid::Uuid>>,
|
||||
_reconnect_trigger: Signal<u32>,
|
||||
_on_members_update: Callback<Vec<ChannelMemberWithAvatar>>,
|
||||
_on_chat_message: Callback<ChatMessage>,
|
||||
_on_loose_props_sync: Callback<Vec<LooseProp>>,
|
||||
|
|
|
|||
|
|
@ -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::<String>::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! {
|
||||
<ReconnectionOverlay
|
||||
ws_state=ws_state_for_overlay
|
||||
on_reconnect=Callback::new(move |_: ()| {
|
||||
set_reconnect_trigger.update(|t| *t += 1);
|
||||
})
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue