Skip to content

Commit 8a6ec8f

Browse files
authored
474 websocket add a snack bar to notify the user on websocket status changes (#483)
* [#474] added snackbar for websocket status * [#474 & #447] added ping to complete browser online/offline status * [#447] ping timeout as an env var
1 parent ce9ad27 commit 8a6ec8f

File tree

12 files changed

+496
-177
lines changed

12 files changed

+496
-177
lines changed

__test__/features/websocket/WebSocketGate.test.tsx

Lines changed: 169 additions & 168 deletions
Large diffs are not rendered by default.

public/.env.example.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,5 @@ var DEBUG = false;
1212
var LANG = "en";
1313
var WEBSOCKET_URL = "wss://calendar.example.com";
1414
var WS_DEBOUNCE_PERIOD_MS = 100; // milliseconds, remove or set to 0 to disable debounce
15+
var WS_PING_PERIOD_MS = 30000;
16+
var WS_PING_TIMEOUT_PERIOD_MS = 35000;

src/locales/en.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,13 @@
299299
"endDate": "End Date",
300300
"endTime": "End Time"
301301
},
302+
"websocket": {
303+
"browserOnline": "You’re back online. Reconnecting live updates…",
304+
"browserOffline": "You’re offline. Live updates are paused until the connection is restored.",
305+
"reconnected": "Live updates are back.",
306+
"error": "There was a problem with live updates: %{error}",
307+
"closedUnexpectedly": "Live updates were interrupted. Trying to reconnect…"
308+
},
302309
"months": {
303310
"standalone": {
304311
"0": "January",

src/locales/fr.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,13 @@
300300
"endDate": "Date de la fin",
301301
"endTime": "Heure de la fin"
302302
},
303+
"websocket": {
304+
"browserOnline": "Vous êtes de nouveau en ligne. Reconnexion des mises à jour en temps réel…",
305+
"browserOffline": "Vous êtes hors ligne. Les mises à jour en temps réel sont mises en pause jusqu’au rétablissement de la connexion.",
306+
"reconnected": "Les mises à jour en temps réel sont de nouveau actives.",
307+
"error": "Un problème est survenu avec les mises à jour en temps réel : %{error}",
308+
"closedUnexpectedly": "Les mises à jour en temps réel ont été interrompues. Tentative de reconnexion…"
309+
},
303310
"months": {
304311
"standalone": {
305312
"0": "Janvier",

src/locales/ru.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,13 @@
300300
"endDate": "Дата окончания",
301301
"endTime": "Время окончания"
302302
},
303+
"websocket": {
304+
"browserOnline": "Вы снова в сети. Подключаем обновления в реальном времени…",
305+
"browserOffline": "Вы не в сети. Обновления в реальном времени приостановлены до восстановления соединения.",
306+
"reconnected": "Обновления в реальном времени снова работают.",
307+
"error": "Произошла ошибка в обновлениях в реальном времени: %{error}",
308+
"closedUnexpectedly": "Обновления в реальном времени были прерваны. Пробуем переподключиться…"
309+
},
303310
"months": {
304311
"standalone": {
305312
"0": "Январь",

src/locales/vi.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,13 @@
298298
"endDate": "Ngày kết thúc",
299299
"endTime": "Giờ kết thúc"
300300
},
301+
"websocket": {
302+
"browserOnline": "Bạn đã trực tuyến trở lại. Đang kết nối lại cập nhật thời gian thực…",
303+
"browserOffline": "Bạn đang ngoại tuyến. Cập nhật thời gian thực sẽ tạm dừng cho đến khi kết nối được khôi phục.",
304+
"reconnected": "Cập nhật thời gian thực đã hoạt động trở lại.",
305+
"error": "Đã xảy ra sự cố với cập nhật thời gian thực: %{error}",
306+
"closedUnexpectedly": "Cập nhật thời gian thực đã bị gián đoạn. Đang thử kết nối lại…"
307+
},
301308
"months": {
302309
"standalone": {
303310
"0": "Tháng 1",

src/setupTests.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,10 @@ class IntersectionObserverMock {
5858
}
5959

6060
(global as any).IntersectionObserver = IntersectionObserverMock;
61-
61+
if (typeof window !== "undefined") {
62+
(window as any).WS_PING_PERIOD_MS = 5000;
63+
(window as any).WS_PING_TIMEOUT_PERIOD_MS = 5000;
64+
}
6265
// Suppress jsdom CSS selector parsing errors for Emotion/MUI
6366
if (typeof window !== "undefined" && window.getComputedStyle) {
6467
const originalGetComputedStyle = window.getComputedStyle;

src/websocket/WebSocketGate.tsx

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,18 @@ import { useAppDispatch, useAppSelector } from "@/app/hooks";
22
import { AppDispatch } from "@/app/store";
33
import { useSelectedCalendars } from "@/utils/storage/useSelectedCalendars";
44
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5+
import { useI18n } from "twake-i18n";
56
import type { WebSocketWithCleanup } from "./connection";
67
import { closeWebSocketConnection } from "./connection/lifecycle/closeWebSocketConnection";
78
import { establishWebSocketConnection } from "./connection/lifecycle/establishWebSocketConnection";
89
import { useWebSocketReconnect } from "./connection/lifecycle/useWebSocketReconnect";
910
import { updateCalendars } from "./messaging/updateCalendars";
1011
import { syncCalendarRegistrations } from "./operations";
12+
import { WebSocketStatusSnackbar } from "./WebSocketStatusSnackbar";
13+
import {
14+
setupWebSocketPing,
15+
type PingCleanup,
16+
} from "./connection/lifecycle/pingWebSocket";
1117

1218
export function WebSocketGate() {
1319
const socketRef = useRef<WebSocketWithCleanup | null>(null);
@@ -16,12 +22,19 @@ export function WebSocketGate() {
1622
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
1723
const reconnectAttemptsRef = useRef(0);
1824
const isConnectingRef = useRef(false);
25+
const pingCleanupRef = useRef<PingCleanup | null>(null);
1926

2027
const connectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
2128
const CONNECT_TIMEOUT_MS = 10_000;
2229

2330
const hadSocketBeforeRef = useRef(false);
2431
const justReconnectedRef = useRef(false);
32+
const [websocketStatus, setWebSocketStatus] = useState("");
33+
const [websocketStatusSerity, setWebSocketStatusSerity] = useState<
34+
"success" | "info" | "warning" | "error" | undefined
35+
>();
36+
37+
const { t } = useI18n();
2538

2639
const dispatch = useAppDispatch();
2740
const isAuthenticated = useAppSelector((state) =>
@@ -81,6 +94,8 @@ export function WebSocketGate() {
8194
`WebSocket closed unexpectedly (code: ${event.code}, reason: ${event.reason || "none"}). ` +
8295
`Attempting to reconnect...`
8396
);
97+
setWebSocketStatus(t("websocket.closedUnexpectedly"));
98+
setWebSocketStatusSerity("warning");
8499
scheduleReconnect();
85100
} else {
86101
reconnectAttemptsRef.current = 0;
@@ -92,6 +107,10 @@ export function WebSocketGate() {
92107

93108
const onError = useCallback((error: Event) => {
94109
console.error("WebSocket error:", error);
110+
const errorMessage =
111+
(error as ErrorEvent)?.message ?? error.type ?? "unknown";
112+
setWebSocketStatus(t("websocket.error", { error: errorMessage }));
113+
setWebSocketStatusSerity("error");
95114
}, []);
96115

97116
const callBacks = useMemo(
@@ -110,15 +129,15 @@ export function WebSocketGate() {
110129
// Reset reconnection state on successful connection and mark for calendar re-sync
111130
useEffect(() => {
112131
if (isSocketOpen) {
113-
console.log("WebSocket connected successfully");
114-
115132
if (connectTimeoutRef.current) {
116133
clearTimeout(connectTimeoutRef.current);
117134
connectTimeoutRef.current = null;
118135
}
119136

120137
if (hadSocketBeforeRef.current) {
121138
justReconnectedRef.current = true;
139+
setWebSocketStatus(t("websocket.reconnected"));
140+
setWebSocketStatusSerity("success");
122141
}
123142

124143
hadSocketBeforeRef.current = true;
@@ -227,7 +246,8 @@ export function WebSocketGate() {
227246
// Handle browser online/offline events
228247
useEffect(() => {
229248
const handleOnline = () => {
230-
console.log("Browser is online, attempting WebSocket reconnection");
249+
setWebSocketStatus(t("websocket.browserOnline"));
250+
setWebSocketStatusSerity("success");
231251
if (!isSocketOpen && isAuthenticatedRef.current) {
232252
reconnectAttemptsRef.current = 0;
233253
clearReconnectTimeout();
@@ -236,9 +256,8 @@ export function WebSocketGate() {
236256
};
237257

238258
const handleOffline = () => {
239-
console.log(
240-
"Browser is offline, pausing WebSocket reconnection attempts"
241-
);
259+
setWebSocketStatus(t("websocket.browserOffline"));
260+
setWebSocketStatusSerity("warning");
242261
cleanupConnection();
243262
};
244263

@@ -260,5 +279,52 @@ export function WebSocketGate() {
260279
};
261280
}, [isSocketOpen, isAuthenticated, clearReconnectTimeout]);
262281

263-
return null;
282+
useEffect(() => {
283+
// Only set up ping if socket is open
284+
if (!isSocketOpen || !socketRef.current) {
285+
// Clean up existing ping if socket closed
286+
if (pingCleanupRef.current) {
287+
pingCleanupRef.current.stop();
288+
pingCleanupRef.current = null;
289+
}
290+
return;
291+
}
292+
293+
// Set up ping monitoring
294+
const pingCleanup = setupWebSocketPing(socketRef.current, {
295+
onConnectionDead: () => {
296+
console.warn("WebSocket connection appears dead (no pong received)");
297+
setWebSocketStatus(t("websocket.browserOffline"));
298+
setWebSocketStatusSerity("warning");
299+
300+
// Trigger reconnection
301+
if (socketRef.current) {
302+
socketRef.current.close();
303+
}
304+
},
305+
onPingFail: () => {
306+
console.warn("Failed to send ping");
307+
},
308+
});
309+
310+
pingCleanupRef.current = pingCleanup;
311+
312+
return () => {
313+
if (pingCleanupRef.current) {
314+
pingCleanupRef.current.stop();
315+
pingCleanupRef.current = null;
316+
}
317+
};
318+
}, [isSocketOpen]);
319+
320+
return websocketStatus ? (
321+
<WebSocketStatusSnackbar
322+
message={websocketStatus}
323+
severity={websocketStatusSerity}
324+
onClose={() => {
325+
setWebSocketStatus("");
326+
setWebSocketStatusSerity(undefined);
327+
}}
328+
/>
329+
) : null;
264330
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { Alert, Button, Snackbar } from "@linagora/twake-mui";
2+
import { useI18n } from "twake-i18n";
3+
4+
export function WebSocketStatusSnackbar({
5+
message,
6+
severity,
7+
onClose,
8+
}: {
9+
message: string;
10+
severity: "success" | "info" | "warning" | "error" | undefined;
11+
onClose: () => void;
12+
}) {
13+
const { t } = useI18n();
14+
return (
15+
<Snackbar
16+
open={!!message}
17+
onClose={onClose}
18+
autoHideDuration={
19+
severity === "warning" || severity === "error" ? null : 6000
20+
}
21+
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
22+
>
23+
<Alert
24+
severity={severity}
25+
onClose={onClose}
26+
sx={{ width: "100%" }}
27+
action={
28+
<Button color="inherit" size="small" onClick={onClose}>
29+
{t("common.ok")}
30+
</Button>
31+
}
32+
>
33+
{message}
34+
</Alert>
35+
</Snackbar>
36+
);
37+
}

0 commit comments

Comments
 (0)