#708 apply strictier linting rules (#717)

* #708 apply strictier linting rules and fix simple eslint bugs

* #708 fix eslint errors relate to promise

* #708 fix eslint import/no-extraneous-dependencies

* #708 fix eslint errors of react-hook

* #708 enable eslint check for typescript

---------

Co-authored-by: lethemanh <lethemanh@lethemanhs-MacBook-Pro.local>
This commit is contained in:
lethemanh
2026-04-01 22:15:10 +07:00
committed by GitHub
parent 2bff6aae78
commit cadfa70e60
321 changed files with 27452 additions and 27600 deletions
+176 -176
View File
@@ -1,71 +1,71 @@
import { useAppDispatch, useAppSelector } from "@/app/hooks";
import { AppDispatch } from "@/app/store";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { useSelectedCalendars } from "@/utils/storage/useSelectedCalendars";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "twake-i18n";
import type { WebSocketWithCleanup } from "./connection";
import { closeWebSocketConnection } from "./connection/lifecycle/closeWebSocketConnection";
import { establishWebSocketConnection } from "./connection/lifecycle/establishWebSocketConnection";
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { AppDispatch } from '@/app/store'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import { useSelectedCalendars } from '@/utils/storage/useSelectedCalendars'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useI18n } from 'twake-i18n'
import type { WebSocketWithCleanup } from './connection'
import { closeWebSocketConnection } from './connection/lifecycle/closeWebSocketConnection'
import { establishWebSocketConnection } from './connection/lifecycle/establishWebSocketConnection'
import {
setupWebSocketPing,
type PingCleanup,
} from "./connection/lifecycle/pingWebSocket";
import { useWebSocketReconnect } from "./connection/lifecycle/useWebSocketReconnect";
type PingCleanup
} from './connection/lifecycle/pingWebSocket'
import { useWebSocketReconnect } from './connection/lifecycle/useWebSocketReconnect'
import {
registerWebSocketState,
setWebSocketConnecting,
} from "./connection/webSocketState";
import { updateCalendars } from "./messaging/updateCalendars";
import { syncCalendarRegistrations } from "./operations";
import { WebSocketStatusSnackbar } from "./WebSocketStatusSnackbar";
setWebSocketConnecting
} from './connection/webSocketState'
import { updateCalendars } from './messaging/updateCalendars'
import { syncCalendarRegistrations } from './operations'
import { WebSocketStatusSnackbar } from './WebSocketStatusSnackbar'
export function WebSocketGate() {
const socketRef = useRef<WebSocketWithCleanup | null>(null);
const previousCalendarListRef = useRef<string[]>([]);
const previousTempCalendarListRef = useRef<string[]>([]);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttemptsRef = useRef(0);
const isConnectingRef = useRef(false);
const pingCleanupRef = useRef<PingCleanup | null>(null);
const socketRef = useRef<WebSocketWithCleanup | null>(null)
const previousCalendarListRef = useRef<string[]>([])
const previousTempCalendarListRef = useRef<string[]>([])
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const reconnectAttemptsRef = useRef(0)
const isConnectingRef = useRef(false)
const pingCleanupRef = useRef<PingCleanup | null>(null)
const connectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const didConnectTimeoutRef = useRef(false);
const CONNECT_TIMEOUT_MS = 10_000;
const connectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const didConnectTimeoutRef = useRef(false)
const CONNECT_TIMEOUT_MS = 10_000
const hadSocketBeforeRef = useRef(false);
const justReconnectedRef = useRef(false);
const [websocketStatus, setWebSocketStatus] = useState("");
const hadSocketBeforeRef = useRef(false)
const justReconnectedRef = useRef(false)
const [websocketStatus, setWebSocketStatus] = useState('')
const [websocketStatusSerity, setWebSocketStatusSerity] = useState<
"success" | "info" | "warning" | "error" | undefined
>();
'success' | 'info' | 'warning' | 'error' | undefined
>()
const { t } = useI18n();
const { t } = useI18n()
const dispatch = useAppDispatch();
const isAuthenticated = useAppSelector((state) =>
const dispatch = useAppDispatch()
const isAuthenticated = useAppSelector(state =>
Boolean(state.user.userData && state.user.tokens)
);
const isAuthenticatedRef = useRef(isAuthenticated);
)
const isAuthenticatedRef = useRef(isAuthenticated)
const [isSocketOpen, setIsSocketOpen] = useState(false);
const isPending = useAppSelector((state) => state.calendars.pending);
const [shouldConnect, setShouldConnect] = useState(false);
const [isSocketOpen, setIsSocketOpen] = useState(false)
const isPending = useAppSelector(state => state.calendars.pending)
const [shouldConnect, setShouldConnect] = useState(false)
const calendarList = useSelectedCalendars();
const calendarList = useSelectedCalendars()
const tempCalendarList = Object.keys(
useAppSelector((state) => state?.calendars?.templist) ?? {}
);
useAppSelector(state => state?.calendars?.templist) ?? {}
)
const calendarsToRefreshRef = useRef<
Map<string, { calendar: Calendar; type?: "temp" }>
>(new Map());
const calendarsToHideRef = useRef<Set<string>>(new Set());
const shouldRefreshCalendarListRef = useRef<boolean>(false);
Map<string, { calendar: Calendar; type?: 'temp' }>
>(new Map())
const calendarsToHideRef = useRef<Set<string>>(new Set())
const shouldRefreshCalendarListRef = useRef<boolean>(false)
const debouncedUpdateFnRef = useRef<
((dispatch: AppDispatch) => void) | undefined
>();
const currentDebouncePeriodRef = useRef<number | undefined>();
>()
const currentDebouncePeriodRef = useRef<number | undefined>()
const onMessage = useCallback(
(message: unknown) => {
@@ -74,133 +74,133 @@ export function WebSocketGate() {
calendarsToHide: calendarsToHideRef.current,
shouldRefreshCalendarListRef,
debouncedUpdateFn: debouncedUpdateFnRef.current,
currentDebouncePeriod: currentDebouncePeriodRef.current,
};
updateCalendars(message, dispatch, accumulators);
currentDebouncePeriod: currentDebouncePeriodRef.current
}
updateCalendars(message, dispatch, accumulators)
// Persist any mutations back to refs
debouncedUpdateFnRef.current = accumulators.debouncedUpdateFn;
currentDebouncePeriodRef.current = accumulators.currentDebouncePeriod;
debouncedUpdateFnRef.current = accumulators.debouncedUpdateFn
currentDebouncePeriodRef.current = accumulators.currentDebouncePeriod
},
[dispatch]
);
)
const { scheduleReconnect, clearReconnectTimeout } = useWebSocketReconnect(
reconnectTimeoutRef,
isAuthenticatedRef,
reconnectAttemptsRef,
setShouldConnect
);
)
const onClose = useCallback(
(event: CloseEvent) => {
// Socket already cleaned up by internal handler before this callback fires
socketRef.current = null;
setIsSocketOpen(false);
socketRef.current = null
setIsSocketOpen(false)
// Only attempt reconnection if it wasn't a normal closure
// Code 1000 = normal closure, 1001 = going away (e.g., page unload)
if (event.code !== 1000 && event.code !== 1001) {
console.warn(
`WebSocket closed unexpectedly (code: ${event.code}, reason: ${event.reason || "none"}). ` +
`WebSocket closed unexpectedly (code: ${event.code}, reason: ${event.reason || 'none'}). ` +
`Attempting to reconnect...`
);
setWebSocketStatus(t("websocket.closedUnexpectedly"));
setWebSocketStatusSerity("warning");
scheduleReconnect();
)
setWebSocketStatus(t('websocket.closedUnexpectedly'))
setWebSocketStatusSerity('warning')
scheduleReconnect()
} else {
reconnectAttemptsRef.current = 0;
clearReconnectTimeout();
reconnectAttemptsRef.current = 0
clearReconnectTimeout()
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[scheduleReconnect, clearReconnectTimeout]
);
)
const onError = useCallback((error: Event) => {
console.error("WebSocket error:", error);
console.error('WebSocket error:', error)
const errorMessage =
(error as ErrorEvent)?.message ?? error.type ?? "unknown";
setWebSocketStatus(t("websocket.error", { error: errorMessage }));
setWebSocketStatusSerity("error");
(error as ErrorEvent)?.message ?? error.type ?? 'unknown'
setWebSocketStatus(t('websocket.error', { error: errorMessage }))
setWebSocketStatusSerity('error')
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [])
const callBacks = useMemo(
() => ({
onMessage,
onClose,
onError,
onError
}),
[onMessage, onClose, onError]
);
)
useEffect(() => {
isAuthenticatedRef.current = isAuthenticated;
}, [isAuthenticated]);
isAuthenticatedRef.current = isAuthenticated
}, [isAuthenticated])
// Reset reconnection state on successful connection and mark for calendar re-sync
useEffect(() => {
if (isSocketOpen) {
if (connectTimeoutRef.current) {
clearTimeout(connectTimeoutRef.current);
connectTimeoutRef.current = null;
clearTimeout(connectTimeoutRef.current)
connectTimeoutRef.current = null
}
// Reset timeout marker on successful connection
didConnectTimeoutRef.current = false;
didConnectTimeoutRef.current = false
if (hadSocketBeforeRef.current) {
justReconnectedRef.current = true;
setWebSocketStatus(t("websocket.reconnected"));
setWebSocketStatusSerity("success");
justReconnectedRef.current = true
setWebSocketStatus(t('websocket.reconnected'))
setWebSocketStatusSerity('success')
}
hadSocketBeforeRef.current = true;
reconnectAttemptsRef.current = 0;
hadSocketBeforeRef.current = true
reconnectAttemptsRef.current = 0
clearReconnectTimeout();
clearReconnectTimeout()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSocketOpen, clearReconnectTimeout]);
}, [isSocketOpen, clearReconnectTimeout])
// Manage WebSocket connection
useEffect(() => {
const abortController = new AbortController();
const abortController = new AbortController()
const cleanup = () => {
if (connectTimeoutRef.current) {
clearTimeout(connectTimeoutRef.current);
connectTimeoutRef.current = null;
clearTimeout(connectTimeoutRef.current)
connectTimeoutRef.current = null
}
closeWebSocketConnection(socketRef, setIsSocketOpen);
clearReconnectTimeout();
};
closeWebSocketConnection(socketRef, setIsSocketOpen)
clearReconnectTimeout()
}
if (!isAuthenticated) {
cleanup();
reconnectAttemptsRef.current = 0;
cleanup()
reconnectAttemptsRef.current = 0
hadSocketBeforeRef.current = false;
return;
hadSocketBeforeRef.current = false
return
}
const connect = async () => {
if (isConnectingRef.current || isSocketOpen) return;
isConnectingRef.current = true;
setWebSocketConnecting(true);
didConnectTimeoutRef.current = false;
if (isConnectingRef.current || isSocketOpen) return
isConnectingRef.current = true
setWebSocketConnecting(true)
didConnectTimeoutRef.current = false
connectTimeoutRef.current = setTimeout(() => {
console.warn("WebSocket connection attempt timed out");
console.warn('WebSocket connection attempt timed out')
didConnectTimeoutRef.current = true;
abortController.abort();
connectTimeoutRef.current = null;
isConnectingRef.current = false;
setWebSocketConnecting(false);
cleanup();
didConnectTimeoutRef.current = true
abortController.abort()
connectTimeoutRef.current = null
isConnectingRef.current = false
setWebSocketConnecting(false)
cleanup()
scheduleReconnect();
}, CONNECT_TIMEOUT_MS);
scheduleReconnect()
}, CONNECT_TIMEOUT_MS)
try {
await establishWebSocketConnection(
@@ -208,50 +208,50 @@ export function WebSocketGate() {
socketRef,
setIsSocketOpen,
abortController.signal
);
)
} catch (err) {
console.warn("WebSocket establishment failed:", err);
console.warn('WebSocket establishment failed:', err)
if (connectTimeoutRef.current) {
clearTimeout(connectTimeoutRef.current);
connectTimeoutRef.current = null;
clearTimeout(connectTimeoutRef.current)
connectTimeoutRef.current = null
}
// Only schedule reconnect if the timeout handler hasn't already done so
if (!didConnectTimeoutRef.current) {
scheduleReconnect();
scheduleReconnect()
}
} finally {
isConnectingRef.current = false;
setWebSocketConnecting(false);
isConnectingRef.current = false
setWebSocketConnecting(false)
}
};
}
connect();
connect()
return () => {
abortController.abort();
cleanup();
};
abortController.abort()
cleanup()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
isAuthenticated,
callBacks,
clearReconnectTimeout,
shouldConnect,
scheduleReconnect,
]);
scheduleReconnect
])
// Register using a diff with previous calendars
useEffect(() => {
if (isPending) return;
if (isPending) return
// If we just reconnected, force a re-sync
if (justReconnectedRef.current && isSocketOpen) {
console.info("Re-syncing calendars after reconnection");
previousCalendarListRef.current = [];
previousTempCalendarListRef.current = [];
justReconnectedRef.current = false;
console.info('Re-syncing calendars after reconnection')
previousCalendarListRef.current = []
previousTempCalendarListRef.current = []
justReconnectedRef.current = false
}
syncCalendarRegistrations(
@@ -259,8 +259,8 @@ export function WebSocketGate() {
socketRef,
calendarList,
previousCalendarListRef
);
}, [isSocketOpen, calendarList, isPending]);
)
}, [isSocketOpen, calendarList, isPending])
useEffect(() => {
syncCalendarRegistrations(
@@ -268,103 +268,103 @@ export function WebSocketGate() {
socketRef,
tempCalendarList,
previousTempCalendarListRef
);
}, [isSocketOpen, tempCalendarList]);
)
}, [isSocketOpen, tempCalendarList])
// Handle browser online/offline events
useEffect(() => {
const handleOnline = () => {
setWebSocketStatus(t("websocket.browserOnline"));
setWebSocketStatusSerity("success");
setWebSocketStatus(t('websocket.browserOnline'))
setWebSocketStatusSerity('success')
if (!isSocketOpen && isAuthenticatedRef.current) {
reconnectAttemptsRef.current = 0;
clearReconnectTimeout();
setShouldConnect((prev) => !prev);
reconnectAttemptsRef.current = 0
clearReconnectTimeout()
setShouldConnect(prev => !prev)
}
};
}
const handleOffline = () => {
setWebSocketStatus(t("websocket.browserOffline"));
setWebSocketStatusSerity("warning");
cleanupConnection();
};
setWebSocketStatus(t('websocket.browserOffline'))
setWebSocketStatusSerity('warning')
cleanupConnection()
}
const cleanupConnection = () => {
closeWebSocketConnection(socketRef, setIsSocketOpen);
clearReconnectTimeout();
closeWebSocketConnection(socketRef, setIsSocketOpen)
clearReconnectTimeout()
if (connectTimeoutRef.current) {
clearTimeout(connectTimeoutRef.current);
connectTimeoutRef.current = null;
clearTimeout(connectTimeoutRef.current)
connectTimeoutRef.current = null
}
};
}
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
};
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSocketOpen, isAuthenticated, clearReconnectTimeout]);
}, [isSocketOpen, isAuthenticated, clearReconnectTimeout])
useEffect(() => {
// Only set up ping if socket is open
if (!isSocketOpen || !socketRef.current) {
// Clean up existing ping if socket closed
if (pingCleanupRef.current) {
pingCleanupRef.current.stop();
pingCleanupRef.current = null;
pingCleanupRef.current.stop()
pingCleanupRef.current = null
}
return;
return
}
// Set up ping monitoring
const pingCleanup = setupWebSocketPing(socketRef.current, {
onConnectionDead: () => {
console.warn("WebSocket connection appears dead (no pong received)");
setWebSocketStatus(t("websocket.browserOffline"));
setWebSocketStatusSerity("warning");
console.warn('WebSocket connection appears dead (no pong received)')
setWebSocketStatus(t('websocket.browserOffline'))
setWebSocketStatusSerity('warning')
// Trigger reconnection
if (socketRef.current) {
socketRef.current.close();
socketRef.current.close()
}
},
onPingFail: () => {
console.warn("Failed to send ping");
},
});
console.warn('Failed to send ping')
}
})
pingCleanupRef.current = pingCleanup;
pingCleanupRef.current = pingCleanup
return () => {
if (pingCleanupRef.current) {
pingCleanupRef.current.stop();
pingCleanupRef.current = null;
pingCleanupRef.current.stop()
pingCleanupRef.current = null
}
};
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSocketOpen]);
}, [isSocketOpen])
const triggerReconnect = useCallback(() => {
reconnectAttemptsRef.current = 0;
clearReconnectTimeout();
setShouldConnect((prev) => !prev);
}, [clearReconnectTimeout]);
reconnectAttemptsRef.current = 0
clearReconnectTimeout()
setShouldConnect(prev => !prev)
}, [clearReconnectTimeout])
useEffect(() => {
registerWebSocketState(socketRef, triggerReconnect);
}, [triggerReconnect]);
registerWebSocketState(socketRef, triggerReconnect)
}, [triggerReconnect])
return websocketStatus ? (
<WebSocketStatusSnackbar
message={websocketStatus}
severity={websocketStatusSerity}
onClose={() => {
setWebSocketStatus("");
setWebSocketStatusSerity(undefined);
setWebSocketStatus('')
setWebSocketStatusSerity(undefined)
}}
/>
) : null;
) : null
}
+12 -12
View File
@@ -1,37 +1,37 @@
import { Alert, Button, Snackbar } from "@linagora/twake-mui";
import { useI18n } from "twake-i18n";
import { Alert, Button, Snackbar } from '@linagora/twake-mui'
import { useI18n } from 'twake-i18n'
export function WebSocketStatusSnackbar({
message,
severity,
onClose,
onClose
}: {
message: string;
severity: "success" | "info" | "warning" | "error" | undefined;
onClose: () => void;
message: string
severity: 'success' | 'info' | 'warning' | 'error' | undefined
onClose: () => void
}) {
const { t } = useI18n();
const { t } = useI18n()
return (
<Snackbar
open={!!message}
onClose={onClose}
autoHideDuration={
severity === "warning" || severity === "error" ? null : 6000
severity === 'warning' || severity === 'error' ? null : 6000
}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
severity={severity}
onClose={onClose}
sx={{ width: "100%" }}
sx={{ width: '100%' }}
action={
<Button color="inherit" size="small" onClick={onClose}>
{t("common.ok")}
{t('common.ok')}
</Button>
}
>
{message}
</Alert>
</Snackbar>
);
)
}
+5 -5
View File
@@ -1,12 +1,12 @@
import { api } from "@/utils/apiUtils";
import { WebSocketTicket } from "./types";
import { api } from '@/utils/apiUtils'
import { WebSocketTicket } from './types'
export async function fetchWebSocketTicket(): Promise<WebSocketTicket> {
const response = await api.post("ws/ticket");
const response = await api.post('ws/ticket')
if (!response.ok) {
throw new Error("Failed to fetch WebSocket ticket");
throw new Error('Failed to fetch WebSocket ticket')
}
return response.json();
return response.json()
}
+5 -5
View File
@@ -1,7 +1,7 @@
export interface WebSocketTicket {
clientAddress: string;
value: string;
generatedOn: string;
validUntil: string;
username: string;
clientAddress: string
value: string
generatedOn: string
validUntil: string
username: string
}
+50 -50
View File
@@ -1,6 +1,6 @@
import { fetchWebSocketTicket } from "../api/fetchWebSocketTicket";
import { WS_INBOUND_EVENTS } from "../protocols";
import { WebSocketCallbacks, WebSocketWithCleanup } from "./types";
import { fetchWebSocketTicket } from '../api/fetchWebSocketTicket'
import { WS_INBOUND_EVENTS } from '../protocols'
import { WebSocketCallbacks, WebSocketWithCleanup } from './types'
export async function createWebSocketConnection(
callbacks: WebSocketCallbacks
@@ -9,97 +9,97 @@ export async function createWebSocketConnection(
window.WEBSOCKET_URL ??
window.CALENDAR_BASE_URL?.replace(
/^http(s)?:/,
(_: string, s: string | undefined) => (s ? "wss:" : "ws:")
(_: string, s: string | undefined) => (s ? 'wss:' : 'ws:')
) ??
"";
''
if (!wsBaseUrl) {
throw new Error("WEBSOCKET_URL is not defined");
throw new Error('WEBSOCKET_URL is not defined')
}
const ticket = await fetchWebSocketTicket();
const ticket = await fetchWebSocketTicket()
const socket = new WebSocket(
`${wsBaseUrl}/ws?ticket=${encodeURIComponent(ticket.value)}`
);
)
const CONNECTION_TIMEOUT_MS = 10_000;
const CONNECTION_TIMEOUT_MS = 10_000
await new Promise<void>((resolve, reject) => {
const timeoutId = setTimeout(() => {
socket.removeEventListener(
WS_INBOUND_EVENTS.CONNECTION_OPENED,
openHandler
);
socket.removeEventListener(WS_INBOUND_EVENTS.ERROR, errorHandler);
socket.close();
reject(new Error("WebSocket connection timed out"));
}, CONNECTION_TIMEOUT_MS);
)
socket.removeEventListener(WS_INBOUND_EVENTS.ERROR, errorHandler)
socket.close()
reject(new Error('WebSocket connection timed out'))
}, CONNECTION_TIMEOUT_MS)
const openHandler = () => {
console.info("WebSocket connection opened");
clearTimeout(timeoutId);
console.info('WebSocket connection opened')
clearTimeout(timeoutId)
socket.removeEventListener(
WS_INBOUND_EVENTS.CONNECTION_OPENED,
openHandler
);
socket.removeEventListener(WS_INBOUND_EVENTS.ERROR, errorHandler);
resolve();
};
)
socket.removeEventListener(WS_INBOUND_EVENTS.ERROR, errorHandler)
resolve()
}
const errorHandler = (error: Event) => {
console.error("WebSocket connection failed:", error);
clearTimeout(timeoutId);
console.error('WebSocket connection failed:', error)
clearTimeout(timeoutId)
socket.removeEventListener(
WS_INBOUND_EVENTS.CONNECTION_OPENED,
openHandler
);
socket.removeEventListener(WS_INBOUND_EVENTS.ERROR, errorHandler);
reject(new Error("WebSocket connection failed"));
};
)
socket.removeEventListener(WS_INBOUND_EVENTS.ERROR, errorHandler)
reject(new Error('WebSocket connection failed'))
}
socket.addEventListener(WS_INBOUND_EVENTS.CONNECTION_OPENED, openHandler);
socket.addEventListener(WS_INBOUND_EVENTS.ERROR, errorHandler);
});
socket.addEventListener(WS_INBOUND_EVENTS.CONNECTION_OPENED, openHandler)
socket.addEventListener(WS_INBOUND_EVENTS.ERROR, errorHandler)
})
// Store references to event handlers so they can be cleaned up later
const messageHandler = (event: MessageEvent) => {
try {
const message = JSON.parse(event.data);
callbacks.onMessage(message);
const message = JSON.parse(event.data)
callbacks.onMessage(message)
} catch (error) {
console.error("Failed to parse WebSocket message:", error);
console.error('Failed to parse WebSocket message:', error)
}
};
}
const closeHandler = (event: CloseEvent) => {
console.info("WebSocket closed:", event.code, event.reason);
cleanup();
callbacks.onClose?.(event);
};
console.info('WebSocket closed:', event.code, event.reason)
cleanup()
callbacks.onClose?.(event)
}
const errorHandler = (error: Event) => {
console.error("WebSocket error:", error);
callbacks.onError?.(error);
};
console.error('WebSocket error:', error)
callbacks.onError?.(error)
}
// Cleanup function to remove all event listeners
const cleanup = () => {
socket.removeEventListener(WS_INBOUND_EVENTS.MESSAGE, messageHandler);
socket.removeEventListener(WS_INBOUND_EVENTS.MESSAGE, messageHandler)
socket.removeEventListener(
WS_INBOUND_EVENTS.CONNECTION_CLOSED,
closeHandler
);
socket.removeEventListener(WS_INBOUND_EVENTS.ERROR, errorHandler);
};
)
socket.removeEventListener(WS_INBOUND_EVENTS.ERROR, errorHandler)
}
socket.addEventListener(WS_INBOUND_EVENTS.MESSAGE, messageHandler);
socket.addEventListener(WS_INBOUND_EVENTS.CONNECTION_CLOSED, closeHandler);
socket.addEventListener(WS_INBOUND_EVENTS.ERROR, errorHandler);
socket.addEventListener(WS_INBOUND_EVENTS.MESSAGE, messageHandler)
socket.addEventListener(WS_INBOUND_EVENTS.CONNECTION_CLOSED, closeHandler)
socket.addEventListener(WS_INBOUND_EVENTS.ERROR, errorHandler)
// Attach cleanup method to socket
const socketWithCleanup = socket as WebSocketWithCleanup;
socketWithCleanup.cleanup = cleanup;
const socketWithCleanup = socket as WebSocketWithCleanup
socketWithCleanup.cleanup = cleanup
return socketWithCleanup;
return socketWithCleanup
}
+2 -2
View File
@@ -1,2 +1,2 @@
export { createWebSocketConnection } from "./createConnection";
export type { WebSocketWithCleanup, WebSocketCallbacks } from "./types";
export { createWebSocketConnection } from './createConnection'
export type { WebSocketWithCleanup, WebSocketCallbacks } from './types'
@@ -1,71 +1,71 @@
import { type MutableRefObject } from "react";
import type { WebSocketWithCleanup } from "../types";
import { getWebSocketState } from "../webSocketState";
import { type MutableRefObject } from 'react'
import type { WebSocketWithCleanup } from '../types'
import { getWebSocketState } from '../webSocketState'
const TIMEOUT_MS = window.WS_PING_TIMEOUT_PERIOD_MS ?? 10_000;
let inFlightCheck: Promise<void> | null = null;
const TIMEOUT_MS = window.WS_PING_TIMEOUT_PERIOD_MS ?? 10_000
let inFlightCheck: Promise<void> | null = null
function waitForSocketOpen(
socketRef: MutableRefObject<WebSocketWithCleanup | null>
): Promise<void> {
return new Promise((resolve, reject) => {
const deadline = Date.now() + TIMEOUT_MS;
const deadline = Date.now() + TIMEOUT_MS
const poll = () => {
if (socketRef.current?.readyState === WebSocket.OPEN) return resolve();
if (socketRef.current?.readyState === WebSocket.OPEN) return resolve()
if (Date.now() >= deadline)
return reject(new Error("[WS] Timed out waiting for reconnection"));
setTimeout(poll, 100);
};
return reject(new Error('[WS] Timed out waiting for reconnection'))
setTimeout(poll, 100)
}
poll();
});
poll()
})
}
export function assertWebSocketAlive(): Promise<void> {
if (inFlightCheck) return inFlightCheck;
const { socketRef, triggerReconnect, isConnecting } = getWebSocketState();
if (inFlightCheck) return inFlightCheck
const { socketRef, triggerReconnect, isConnecting } = getWebSocketState()
// Not registered yet or mid-bootstrap — don't interfere, don't block
if (!socketRef || isConnecting || !triggerReconnect) return Promise.resolve();
if (!socketRef || isConnecting || !triggerReconnect) return Promise.resolve()
const socket = socketRef.current;
const socket = socketRef.current
if (!socket || socket.readyState !== WebSocket.OPEN) {
triggerReconnect();
return waitForSocketOpen(socketRef);
triggerReconnect()
return waitForSocketOpen(socketRef)
}
const promise = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
console.warn("[WS] Pong not received — triggering reconnect");
triggerReconnect();
waitForSocketOpen(socketRef).then(resolve, reject);
}, TIMEOUT_MS);
cleanup()
console.warn('[WS] Pong not received — triggering reconnect')
triggerReconnect()
waitForSocketOpen(socketRef).then(resolve, reject).catch(reject)
}, TIMEOUT_MS)
const handlePong = (event: MessageEvent) => {
try {
const msg = JSON.parse(event.data);
const msg = JSON.parse(event.data)
if (msg) {
cleanup();
resolve();
cleanup()
resolve()
}
} catch {
// not parseable, keep waiting
}
};
}
const cleanup = () => {
clearTimeout(timeout);
socket.removeEventListener("message", handlePong);
};
clearTimeout(timeout)
socket.removeEventListener('message', handlePong)
}
socket.addEventListener("message", handlePong);
socket.send(JSON.stringify({ type: "ping" }));
socket.addEventListener('message', handlePong)
socket.send(JSON.stringify({ type: 'ping' }))
}).finally(() => {
inFlightCheck = null;
});
inFlightCheck = promise;
return promise;
inFlightCheck = null
})
inFlightCheck = promise
return promise
}
@@ -1,13 +1,13 @@
import { WebSocketWithCleanup } from "../types";
import { WebSocketWithCleanup } from '../types'
export function closeWebSocketConnection(
socketRef: React.MutableRefObject<WebSocketWithCleanup | null>,
setIsSocketOpen: (value: boolean) => void
) {
if (socketRef.current) {
socketRef.current.cleanup();
socketRef.current.close();
socketRef.current = null;
setIsSocketOpen(false);
socketRef.current.cleanup()
socketRef.current.close()
socketRef.current = null
setIsSocketOpen(false)
}
}
@@ -1,5 +1,5 @@
import { createWebSocketConnection } from "../createConnection";
import { WebSocketCallbacks, WebSocketWithCleanup } from "../types";
import { createWebSocketConnection } from '../createConnection'
import { WebSocketCallbacks, WebSocketWithCleanup } from '../types'
export async function establishWebSocketConnection(
callbacks: WebSocketCallbacks,
@@ -8,21 +8,21 @@ export async function establishWebSocketConnection(
signal?: AbortSignal
) {
try {
const socket = await createWebSocketConnection(callbacks);
const socket = await createWebSocketConnection(callbacks)
if (signal?.aborted) {
socket.cleanup();
socket.close();
return;
socket.cleanup()
socket.close()
return
}
socketRef.current = socket;
socketRef.current = socket
if (socket.readyState === WebSocket.OPEN) {
setIsSocketOpen(true);
setIsSocketOpen(true)
}
} catch (error) {
console.error("Failed to create WebSocket connection:", error);
setIsSocketOpen(false);
console.error('Failed to create WebSocket connection:', error)
setIsSocketOpen(false)
}
}
@@ -1,26 +1,26 @@
import { WebSocketWithCleanup } from "../types";
import { WebSocketWithCleanup } from '../types'
export interface PingConfig {
/** Interval between ping attempts in milliseconds */
pingInterval?: number;
pingInterval?: number
/** Timeout for pong response in milliseconds */
pongTimeout?: number;
pongTimeout?: number
/** Callback when connection is deemed dead */
onConnectionDead?: () => void;
onConnectionDead?: () => void
/** Callback when ping fails */
onPingFail?: () => void;
onPingFail?: () => void
/** Callback when pong is received successfully */
onPongReceived?: () => void;
onPongReceived?: () => void
}
export interface PingCleanup {
stop: () => void;
stop: () => void
/** Force send a ping immediately */
sendPing: () => void;
sendPing: () => void
}
const DEFAULT_PING_INTERVAL = window.WS_PING_PERIOD_MS ?? 30000;
const DEFAULT_PONG_TIMEOUT = window.WS_PING_TIMEOUT_PERIOD_MS ?? 35000;
const DEFAULT_PING_INTERVAL = window.WS_PING_PERIOD_MS ?? 30000
const DEFAULT_PONG_TIMEOUT = window.WS_PING_TIMEOUT_PERIOD_MS ?? 35000
/**
* Sets up a ping/pong mechanism to monitor WebSocket connection health
@@ -51,130 +51,130 @@ export function setupWebSocketPing(
pongTimeout = DEFAULT_PONG_TIMEOUT,
onConnectionDead,
onPingFail,
onPongReceived,
} = config;
onPongReceived
} = config
let pingIntervalId: NodeJS.Timeout | null = null;
let pongTimeoutId: NodeJS.Timeout | null = null;
let isWaitingForPong = false;
let isStopped = false;
let pingIntervalId: NodeJS.Timeout | null = null
let pongTimeoutId: NodeJS.Timeout | null = null
let isWaitingForPong = false
let isStopped = false
const cleanup = () => {
if (pingIntervalId) {
clearInterval(pingIntervalId);
pingIntervalId = null;
clearInterval(pingIntervalId)
pingIntervalId = null
}
if (pongTimeoutId) {
clearTimeout(pongTimeoutId);
pongTimeoutId = null;
clearTimeout(pongTimeoutId)
pongTimeoutId = null
}
isWaitingForPong = false;
};
isWaitingForPong = false
}
const sendPing = () => {
if (isStopped || !socket || socket.readyState !== WebSocket.OPEN) {
return;
return
}
// If we're still waiting for a previous pong, connection might be dead
if (isWaitingForPong) {
console.warn(
"Pong not received for previous ping. Connection may be dead."
);
onPingFail?.();
onConnectionDead?.();
isStopped = true;
cleanup();
return;
'Pong not received for previous ping. Connection may be dead.'
)
onPingFail?.()
onConnectionDead?.()
isStopped = true
cleanup()
return
}
try {
socket.send(JSON.stringify({ type: "ping", timestamp: Date.now() }));
socket.send(JSON.stringify({ type: 'ping', timestamp: Date.now() }))
isWaitingForPong = true;
isWaitingForPong = true
// Set timeout for pong response
pongTimeoutId = setTimeout(() => {
if (isWaitingForPong) {
console.warn("Pong timeout exceeded. Connection may be dead.");
onPingFail?.();
onConnectionDead?.();
cleanup();
console.warn('Pong timeout exceeded. Connection may be dead.')
onPingFail?.()
onConnectionDead?.()
cleanup()
}
}, pongTimeout);
}, pongTimeout)
} catch (error) {
console.error("Failed to send ping:", error);
onPingFail?.();
cleanup();
console.error('Failed to send ping:', error)
onPingFail?.()
cleanup()
}
};
}
const handlePong = () => {
if (pongTimeoutId) {
clearTimeout(pongTimeoutId);
pongTimeoutId = null;
clearTimeout(pongTimeoutId)
pongTimeoutId = null
}
isWaitingForPong = false;
onPongReceived?.();
};
isWaitingForPong = false
onPongReceived?.()
}
// Start ping interval
const startPinging = () => {
if (!socket || socket.readyState !== WebSocket.OPEN) {
console.warn("Cannot start pinging: socket is not open");
return;
console.warn('Cannot start pinging: socket is not open')
return
}
// Send first ping immediately
sendPing();
sendPing()
// Set up recurring pings
pingIntervalId = setInterval(() => {
sendPing();
}, pingInterval);
};
sendPing()
}, pingInterval)
}
// Set up message listener for pong responses
const originalOnMessage = socket?.onmessage;
const originalOnMessage = socket?.onmessage
if (socket) {
socket.onmessage = (event) => {
let isPong = false;
if (typeof event.data === "string") {
socket.onmessage = event => {
let isPong = false
if (typeof event.data === 'string') {
try {
const payload = JSON.parse(event.data);
const payload = JSON.parse(event.data)
// Check if it's an empty object {}
isPong =
typeof payload === "object" &&
typeof payload === 'object' &&
payload !== null &&
Object.keys(payload).length === 0;
Object.keys(payload).length === 0
} catch {
// Non-JSON payload
}
}
if (isPong) {
handlePong();
handlePong()
}
// Call original handler
originalOnMessage?.call(socket, event);
};
originalOnMessage?.call(socket, event)
}
}
// Start the ping mechanism
startPinging();
startPinging()
return {
stop: () => {
isStopped = true;
cleanup();
isStopped = true
cleanup()
// Restore original onmessage handler
if (socket) {
socket.onmessage = originalOnMessage ?? null;
socket.onmessage = originalOnMessage ?? null
}
},
sendPing: () => {
if (!isStopped) {
sendPing();
sendPing()
}
},
};
}
}
}
@@ -1,11 +1,11 @@
import { getRetryDelay, RetryBackoffConfig } from "@/utils/getRetryDelay";
import { Dispatch, MutableRefObject, SetStateAction, useCallback } from "react";
import { getRetryDelay, RetryBackoffConfig } from '@/utils/getRetryDelay'
import { Dispatch, MutableRefObject, SetStateAction, useCallback } from 'react'
export const RECONNECT_CONFIG: RetryBackoffConfig = {
initialDelay: 1000, // 1 second
maxDelay: 30000, // 30 seconds
};
export const MAX_RECONNECT_ATTEMPTS = 10;
maxDelay: 30000 // 30 seconds
}
export const MAX_RECONNECT_ATTEMPTS = 10
export function useWebSocketReconnect(
reconnectTimeoutRef: MutableRefObject<NodeJS.Timeout | null>,
@@ -15,44 +15,44 @@ export function useWebSocketReconnect(
) {
const clearReconnectTimeout = useCallback(() => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
clearTimeout(reconnectTimeoutRef.current)
reconnectTimeoutRef.current = null
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [])
const scheduleReconnect = useCallback(() => {
if (!isAuthenticatedRef.current) return;
if (!isAuthenticatedRef.current) return
if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) {
console.error(
`Max WebSocket reconnection attempts (${MAX_RECONNECT_ATTEMPTS}) reached. Giving up.`
);
return;
)
return
}
clearReconnectTimeout();
clearReconnectTimeout()
const delay = getRetryDelay(reconnectAttemptsRef.current, RECONNECT_CONFIG);
reconnectAttemptsRef.current += 1;
const delay = getRetryDelay(reconnectAttemptsRef.current, RECONNECT_CONFIG)
reconnectAttemptsRef.current += 1
console.info(
`Scheduling WebSocket reconnection in ${Math.round(delay)}ms ` +
`(attempt ${reconnectAttemptsRef.current}/${MAX_RECONNECT_ATTEMPTS})`
);
)
reconnectTimeoutRef.current = setTimeout(() => {
if (!isAuthenticatedRef.current) {
reconnectTimeoutRef.current = null;
return;
reconnectTimeoutRef.current = null
return
}
console.info(
`Attempting WebSocket reconnection (attempt ${reconnectAttemptsRef.current}/${MAX_RECONNECT_ATTEMPTS})`
);
setShouldConnect((prev) => !prev);
clearReconnectTimeout();
}, delay);
)
setShouldConnect(prev => !prev)
clearReconnectTimeout()
}, delay)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [clearReconnectTimeout]);
return { scheduleReconnect, clearReconnectTimeout };
}, [clearReconnectTimeout])
return { scheduleReconnect, clearReconnectTimeout }
}
+4 -4
View File
@@ -1,9 +1,9 @@
export interface WebSocketWithCleanup extends WebSocket {
cleanup: () => void;
cleanup: () => void
}
export interface WebSocketCallbacks {
onMessage: (data: unknown) => void;
onClose?: (event: CloseEvent) => void;
onError?: (error: Event) => void;
onMessage: (data: unknown) => void
onClose?: (event: CloseEvent) => void
onError?: (error: Event) => void
}
+11 -11
View File
@@ -1,30 +1,30 @@
import { type MutableRefObject } from "react";
import type { WebSocketWithCleanup } from "./types";
import { type MutableRefObject } from 'react'
import type { WebSocketWithCleanup } from './types'
interface WebSocketState {
socketRef: MutableRefObject<WebSocketWithCleanup | null> | null;
triggerReconnect: (() => void) | null;
isConnecting: boolean;
socketRef: MutableRefObject<WebSocketWithCleanup | null> | null
triggerReconnect: (() => void) | null
isConnecting: boolean
}
const state: WebSocketState = {
socketRef: null,
triggerReconnect: null,
isConnecting: false,
};
isConnecting: false
}
export function registerWebSocketState(
socketRef: React.MutableRefObject<WebSocketWithCleanup | null>,
triggerReconnect: () => void
) {
state.socketRef = socketRef;
state.triggerReconnect = triggerReconnect;
state.socketRef = socketRef
state.triggerReconnect = triggerReconnect
}
export function setWebSocketConnecting(value: boolean) {
state.isConnecting = value;
state.isConnecting = value
}
export function getWebSocketState(): Readonly<WebSocketState> {
return state;
return state
}
+2 -2
View File
@@ -1,2 +1,2 @@
export { WebSocketGate } from "./WebSocketGate";
export type { WebSocketWithCleanup, WebSocketCallbacks } from "./connection";
export { WebSocketGate } from './WebSocketGate'
export type { WebSocketWithCleanup, WebSocketCallbacks } from './connection'
+3 -3
View File
@@ -1,3 +1,3 @@
export { parseMessage } from "./parseMessage";
export { updateCalendars } from "./updateCalendars";
export { parseCalendarPath } from "./parseCalendarPath";
export { parseMessage } from './parseMessage'
export { updateCalendars } from './updateCalendars'
export { parseCalendarPath } from './parseCalendarPath'
+4 -4
View File
@@ -1,11 +1,11 @@
const CALENDAR_PATH_REGEX = /^\/calendars\/[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+$/;
const CALENDAR_PATH_REGEX = /^\/calendars\/[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+$/
export function parseCalendarPath(key: string) {
if (!CALENDAR_PATH_REGEX.test(key)) {
return null;
return null
}
const [, , userId, calendarId] = key.split("/");
const [, , userId, calendarId] = key.split('/')
return `${userId}/${calendarId}`;
return `${userId}/${calendarId}`
}
+21 -21
View File
@@ -1,45 +1,45 @@
import { WS_INBOUND_EVENTS } from "../protocols";
import { WS_INBOUND_EVENTS } from '../protocols'
export function parseMessage(message: unknown) {
const calendarsToRefresh = new Set<string>();
const calendarsToHide = new Set<string>();
let shouldRefreshCalendarList = false;
if (typeof message !== "object" || message === null) {
return { calendarsToRefresh, calendarsToHide, shouldRefreshCalendarList };
const calendarsToRefresh = new Set<string>()
const calendarsToHide = new Set<string>()
let shouldRefreshCalendarList = false
if (typeof message !== 'object' || message === null) {
return { calendarsToRefresh, calendarsToHide, shouldRefreshCalendarList }
}
for (const [key, value] of Object.entries(message)) {
switch (key) {
case WS_INBOUND_EVENTS.CLIENT_REGISTERED:
if (Array.isArray(value)) {
value.forEach((cal: string) => calendarsToRefresh.add(cal));
value.forEach((cal: string) => calendarsToRefresh.add(cal))
}
break;
break
case WS_INBOUND_EVENTS.CLIENT_UNREGISTERED:
if (Array.isArray(value)) {
value.forEach((cal: string) => calendarsToHide.add(cal));
value.forEach((cal: string) => calendarsToHide.add(cal))
}
break;
break
case WS_INBOUND_EVENTS.CALENDAR_CLIENT_REGISTERED:
break;
break
case WS_INBOUND_EVENTS.CALENDAR_LIST:
Object.keys(value).forEach((key) => {
if (key === "subscribed") {
Object.keys(value).forEach(key => {
if (key === 'subscribed') {
if (Array.isArray(value[key])) {
value[key].forEach((cal: string) => calendarsToRefresh.add(cal));
value[key].forEach((cal: string) => calendarsToRefresh.add(cal))
}
} else if (key === "deleted") {
shouldRefreshCalendarList = true;
} else if (key === 'deleted') {
shouldRefreshCalendarList = true
} else {
shouldRefreshCalendarList = true;
shouldRefreshCalendarList = true
}
});
break;
})
break
default: {
calendarsToRefresh.add(key);
calendarsToRefresh.add(key)
}
}
}
return { calendarsToRefresh, calendarsToHide, shouldRefreshCalendarList };
return { calendarsToRefresh, calendarsToHide, shouldRefreshCalendarList }
}
@@ -1,10 +1,10 @@
import { AppDispatch } from "@/app/store";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { AppDispatch } from '@/app/store'
import { Calendar } from '@/features/Calendars/CalendarTypes'
export interface UpdateCalendarsAccumulators {
calendarsToRefresh: Map<string, { calendar: Calendar; type?: "temp" }>;
calendarsToHide: Set<string>;
shouldRefreshCalendarListRef: React.MutableRefObject<boolean>;
debouncedUpdateFn?: (dispatch: AppDispatch) => void;
currentDebouncePeriod?: number;
calendarsToRefresh: Map<string, { calendar: Calendar; type?: 'temp' }>
calendarsToHide: Set<string>
shouldRefreshCalendarListRef: React.MutableRefObject<boolean>
debouncedUpdateFn?: (dispatch: AppDispatch) => void
currentDebouncePeriod?: number
}
+74 -74
View File
@@ -1,24 +1,24 @@
import type { AppDispatch } from "@/app/store";
import { store } from "@/app/store";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import type { AppDispatch } from '@/app/store'
import { store } from '@/app/store'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import {
getCalendarsListAsync,
refreshCalendarWithSyncToken,
} from "@/features/Calendars/services";
import { findCalendarById, getDisplayedCalendarRange } from "@/utils";
import { setSelectedCalendars } from "@/utils/storage/setSelectedCalendars";
import { debounce } from "lodash";
import { parseCalendarPath } from "./parseCalendarPath";
import { parseMessage } from "./parseMessage";
import { UpdateCalendarsAccumulators } from "./type/UpdateCalendarsAccumulators";
refreshCalendarWithSyncToken
} from '@/features/Calendars/services'
import { findCalendarById, getDisplayedCalendarRange } from '@/utils'
import { setSelectedCalendars } from '@/utils/storage/setSelectedCalendars'
import { debounce } from 'lodash'
import { parseCalendarPath } from './parseCalendarPath'
import { parseMessage } from './parseMessage'
import { UpdateCalendarsAccumulators } from './type/UpdateCalendarsAccumulators'
const DEFAULT_DEBOUNCE_MS = 0;
const DEFAULT_DEBOUNCE_MS = 0
function createDebouncedUpdate(
debouncePeriodMs: number,
getCalendarsToRefresh: () => Map<
string,
{ calendar: Calendar; type?: "temp" }
{ calendar: Calendar; type?: 'temp' }
>,
getCalendarsToHide: () => Set<string>,
getShouldRefreshCalendarList: () => boolean,
@@ -26,32 +26,32 @@ function createDebouncedUpdate(
) {
return debounce(
(dispatch: AppDispatch) => {
const currentRange = getDisplayedCalendarRange();
const currentRange = getDisplayedCalendarRange()
// Snapshot state
const calendarsToProcess = new Map(getCalendarsToRefresh());
const calendarsToHideSnapshot = new Set(getCalendarsToHide());
const shouldRefresh = getShouldRefreshCalendarList();
const calendarsToProcess = new Map(getCalendarsToRefresh())
const calendarsToHideSnapshot = new Set(getCalendarsToHide())
const shouldRefresh = getShouldRefreshCalendarList()
// Clear accumulators
getCalendarsToRefresh().clear();
getCalendarsToHide().clear();
resetShouldRefreshCalendarList();
getCalendarsToRefresh().clear()
getCalendarsToHide().clear()
resetShouldRefreshCalendarList()
try {
processCalendarsToRefresh(dispatch, currentRange, calendarsToProcess);
processCalendarsToHide(calendarsToHideSnapshot);
processCalendarsToRefresh(dispatch, currentRange, calendarsToProcess)
processCalendarsToHide(calendarsToHideSnapshot)
if (shouldRefresh) {
dispatch(getCalendarsListAsync());
dispatch(getCalendarsListAsync())
}
} catch (error) {
console.warn("Error processing accumulated calendar updates:", error);
console.warn('Error processing accumulated calendar updates:', error)
}
},
debouncePeriodMs,
{ leading: true, trailing: true }
);
)
}
export function updateCalendars(
@@ -59,22 +59,22 @@ export function updateCalendars(
dispatch: AppDispatch,
accumulators: UpdateCalendarsAccumulators
) {
const state = store.getState();
const state = store.getState()
const { calendarsToRefresh, calendarsToHide, shouldRefreshCalendarList } =
parseMessage(message);
parseMessage(message)
// Accumulate
accumulateCalendarsToRefresh(
state,
calendarsToRefresh,
accumulators.calendarsToRefresh
);
accumulateCalendarsToHide(calendarsToHide, accumulators.calendarsToHide);
)
accumulateCalendarsToHide(calendarsToHide, accumulators.calendarsToHide)
accumulators.shouldRefreshCalendarListRef.current ||=
shouldRefreshCalendarList;
shouldRefreshCalendarList
const debouncePeriod = window.WS_DEBOUNCE_PERIOD_MS ?? DEFAULT_DEBOUNCE_MS;
const debouncePeriod = window.WS_DEBOUNCE_PERIOD_MS ?? DEFAULT_DEBOUNCE_MS
if (debouncePeriod > 0) {
if (
@@ -87,32 +87,32 @@ export function updateCalendars(
() => accumulators.calendarsToHide,
() => accumulators.shouldRefreshCalendarListRef.current,
() => {
accumulators.shouldRefreshCalendarListRef.current = false;
accumulators.shouldRefreshCalendarListRef.current = false
}
);
accumulators.currentDebouncePeriod = debouncePeriod;
)
accumulators.currentDebouncePeriod = debouncePeriod
}
accumulators.debouncedUpdateFn(dispatch);
return;
accumulators.debouncedUpdateFn(dispatch)
return
}
// Immediate processing if debounce disabled
const currentRange = getDisplayedCalendarRange();
const calendarsToProcess = new Map(accumulators.calendarsToRefresh);
const calendarsToHideSnapshot = new Set(accumulators.calendarsToHide);
const currentRange = getDisplayedCalendarRange()
const calendarsToProcess = new Map(accumulators.calendarsToRefresh)
const calendarsToHideSnapshot = new Set(accumulators.calendarsToHide)
accumulators.calendarsToRefresh.clear();
accumulators.calendarsToHide.clear();
accumulators.shouldRefreshCalendarListRef.current = false;
accumulators.calendarsToRefresh.clear()
accumulators.calendarsToHide.clear()
accumulators.shouldRefreshCalendarListRef.current = false
try {
processCalendarsToRefresh(dispatch, currentRange, calendarsToProcess);
processCalendarsToHide(calendarsToHideSnapshot);
processCalendarsToRefresh(dispatch, currentRange, calendarsToProcess)
processCalendarsToHide(calendarsToHideSnapshot)
if (shouldRefreshCalendarList) {
dispatch(getCalendarsListAsync());
dispatch(getCalendarsListAsync())
}
} catch (error) {
console.warn("Error processing calendar updates:", error);
console.warn('Error processing calendar updates:', error)
}
}
@@ -120,67 +120,67 @@ export function updateCalendars(
function accumulateCalendarsToRefresh(
state: ReturnType<typeof store.getState>,
calendarPaths: Set<string>,
calendarsToRefreshMap: Map<string, { calendar: Calendar; type?: "temp" }>
calendarsToRefreshMap: Map<string, { calendar: Calendar; type?: 'temp' }>
) {
calendarPaths.forEach((calendarPath) => {
const calendarId = parseCalendarPath(calendarPath);
calendarPaths.forEach(calendarPath => {
const calendarId = parseCalendarPath(calendarPath)
if (!calendarId) {
console.warn("Invalid calendar path received:", calendarPath);
return;
console.warn('Invalid calendar path received:', calendarPath)
return
}
const calendar = findCalendarById(state, calendarId);
const calendar = findCalendarById(state, calendarId)
if (!calendar) {
console.warn("Calendar not found for id:", calendarId);
return;
console.warn('Calendar not found for id:', calendarId)
return
}
calendarsToRefreshMap.set(calendarId, calendar);
});
calendarsToRefreshMap.set(calendarId, calendar)
})
}
function accumulateCalendarsToHide(
calendarPaths: Set<string>,
calendarsToHideSet: Set<string>
) {
calendarPaths.forEach((calendarPath) => {
const calendarId = parseCalendarPath(calendarPath);
calendarPaths.forEach(calendarPath => {
const calendarId = parseCalendarPath(calendarPath)
if (calendarId) {
calendarsToHideSet.add(calendarId);
calendarsToHideSet.add(calendarId)
}
});
})
}
function processCalendarsToRefresh(
dispatch: AppDispatch,
currentRange: { start: Date; end: Date },
calendarsMap: Map<string, { calendar: Calendar; type?: "temp" }>
calendarsMap: Map<string, { calendar: Calendar; type?: 'temp' }>
) {
calendarsMap.forEach((calendar) => {
calendarsMap.forEach(calendar => {
dispatch(
refreshCalendarWithSyncToken({
calendar: calendar.calendar,
calType: calendar.type,
calendarRange: currentRange,
calendarRange: currentRange
})
);
});
)
})
}
function processCalendarsToHide(calendarsToHideSnapshot: Set<string>) {
if (calendarsToHideSnapshot.size === 0) return;
if (calendarsToHideSnapshot.size === 0) return
let currentSelectedCalendars: string[];
let currentSelectedCalendars: string[]
try {
const stored = localStorage.getItem("selectedCalendars") ?? "[]";
currentSelectedCalendars = JSON.parse(stored);
const stored = localStorage.getItem('selectedCalendars') ?? '[]'
currentSelectedCalendars = JSON.parse(stored)
} catch (error) {
console.warn("Failed to parse selectedCalendars from localStorage:", error);
return;
console.warn('Failed to parse selectedCalendars from localStorage:', error)
return
}
const updatedSelectedCalendars = currentSelectedCalendars.filter(
(id) => !calendarsToHideSnapshot.has(id)
);
id => !calendarsToHideSnapshot.has(id)
)
setSelectedCalendars(updatedSelectedCalendars);
setSelectedCalendars(updatedSelectedCalendars)
}
+3 -3
View File
@@ -1,3 +1,3 @@
export { registerToCalendars } from "./registerToCalendars";
export { unregisterToCalendars } from "./unregisterToCalendars";
export { syncCalendarRegistrations } from "./syncCalendarRegistrations";
export { registerToCalendars } from './registerToCalendars'
export { unregisterToCalendars } from './unregisterToCalendars'
export { syncCalendarRegistrations } from './syncCalendarRegistrations'
@@ -3,14 +3,14 @@ export function registerToCalendars(
calendarURIList: string[]
): void {
if (socket.readyState !== WebSocket.OPEN) {
throw new Error("Cannot register: WebSocket is not open");
throw new Error('Cannot register: WebSocket is not open')
}
socket.send(
JSON.stringify({
register: calendarURIList,
register: calendarURIList
})
);
)
console.info("Registered to calendars", calendarURIList);
console.info('Registered to calendars', calendarURIList)
}
@@ -1,6 +1,6 @@
import { WebSocketWithCleanup } from "../connection";
import { registerToCalendars } from "./registerToCalendars";
import { unregisterToCalendars } from "./unregisterToCalendars";
import { WebSocketWithCleanup } from '../connection'
import { registerToCalendars } from './registerToCalendars'
import { unregisterToCalendars } from './unregisterToCalendars'
export function syncCalendarRegistrations(
isSocketOpen: boolean,
@@ -13,36 +13,34 @@ export function syncCalendarRegistrations(
!socketRef.current ||
socketRef.current.readyState !== WebSocket.OPEN
) {
return;
return
}
const currentPaths = calendarList.map((cal) => `/calendars/${cal}`);
const currentPaths = calendarList.map(cal => `/calendars/${cal}`)
const previousPaths = previousCalendarListRef.current.map(
(cal) => `/calendars/${cal}`
);
cal => `/calendars/${cal}`
)
const toRegister = currentPaths.filter(
(path) => !previousPaths.includes(path)
);
const toRegister = currentPaths.filter(path => !previousPaths.includes(path))
const toUnregister = previousPaths.filter(
(path) => !currentPaths.includes(path)
);
path => !currentPaths.includes(path)
)
try {
if (toRegister.length > 0) {
registerToCalendars(socketRef.current, toRegister);
registerToCalendars(socketRef.current, toRegister)
}
} catch (error) {
console.error("Failed to register calendar:", error);
return;
console.error('Failed to register calendar:', error)
return
}
try {
if (toUnregister.length > 0) {
unregisterToCalendars(socketRef.current, toUnregister);
unregisterToCalendars(socketRef.current, toUnregister)
}
} catch (error) {
console.error("Failed to unregister calendar:", error);
return;
console.error('Failed to unregister calendar:', error)
return
}
previousCalendarListRef.current = calendarList;
previousCalendarListRef.current = calendarList
}
@@ -3,14 +3,14 @@ export function unregisterToCalendars(
calendarURIList: string[]
): void {
if (socket.readyState !== WebSocket.OPEN) {
throw new Error("Cannot unregister: WebSocket is not open");
throw new Error('Cannot unregister: WebSocket is not open')
}
socket.send(
JSON.stringify({
unregister: calendarURIList,
unregister: calendarURIList
})
);
)
console.info("Unregistered to calendars", calendarURIList);
console.info('Unregistered to calendars', calendarURIList)
}
+9 -9
View File
@@ -1,10 +1,10 @@
export const WS_INBOUND_EVENTS = {
CONNECTION_OPENED: "open",
MESSAGE: "message",
ERROR: "error",
CONNECTION_CLOSED: "close",
CLIENT_REGISTERED: "registered",
CLIENT_UNREGISTERED: "unregistered",
CALENDAR_CLIENT_REGISTERED: "calendarListRegistered",
CALENDAR_LIST: "calendarList",
} as const;
CONNECTION_OPENED: 'open',
MESSAGE: 'message',
ERROR: 'error',
CONNECTION_CLOSED: 'close',
CLIENT_REGISTERED: 'registered',
CLIENT_UNREGISTERED: 'unregistered',
CALENDAR_CLIENT_REGISTERED: 'calendarListRegistered',
CALENDAR_LIST: 'calendarList'
} as const