444 use websocket to trigger reloads (#458)

* [#444] added refresh on sync token calendar update

* [#444] added refresh on register and tests

* [#444] extracted logic for useEffects and updates

* [#444] refactored code structure to use absolute paths
This commit is contained in:
Camille Moussu
2026-01-19 09:09:23 +01:00
committed by GitHub
parent 80110bdf52
commit 00c3c0d6f0
32 changed files with 849 additions and 163 deletions
@@ -1,15 +1,15 @@
import { cleanup, render, waitFor, act } from "@testing-library/react";
import { Provider } from "react-redux";
import { configureStore } from "@reduxjs/toolkit";
import { createWebSocketConnection } from "../../../src/websocket/createWebSocketConnection";
import { registerToCalendars } from "../../../src/websocket/ws/registerToCalendars";
import { unregisterToCalendars } from "../../../src/websocket/ws/unregisterToCalendars";
import { WebSocketGate } from "../../../src/websocket/WebSocketGate";
import { setSelectedCalendars } from "../../../src/utils/storage/setSelectedCalendars";
import { createWebSocketConnection } from "@/websocket/connection/createConnection";
import { registerToCalendars } from "@/websocket/operations/registerToCalendars";
import { unregisterToCalendars } from "@/websocket/operations/unregisterToCalendars";
import { WebSocketGate } from "@/websocket/WebSocketGate";
import { setSelectedCalendars } from "@/utils/storage/setSelectedCalendars";
jest.mock("../../../src/websocket/createWebSocketConnection");
jest.mock("../../../src/websocket/ws/registerToCalendars");
jest.mock("../../../src/websocket/ws/unregisterToCalendars");
jest.mock("@/websocket/connection/createConnection");
jest.mock("@/websocket/operations/registerToCalendars");
jest.mock("@/websocket/operations/unregisterToCalendars");
describe("WebSocketGate", () => {
let store: any;
@@ -101,7 +101,7 @@ describe("WebSocketGate", () => {
});
describe("Socket Connection Management", () => {
it("should add close event listener on socket connection", async () => {
it("should create connection with callbacks", async () => {
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
render(
@@ -111,22 +111,25 @@ describe("WebSocketGate", () => {
);
await waitFor(() => {
expect(mockSocket.addEventListener).toHaveBeenCalledWith(
"close",
expect.any(Function)
expect(createWebSocketConnection).toHaveBeenCalledWith(
expect.objectContaining({
onMessage: expect.any(Function),
onClose: expect.any(Function),
onError: expect.any(Function),
})
);
});
});
it("should handle socket close event", async () => {
let closeHandler: Function;
mockSocket.addEventListener = jest.fn((event, handler) => {
if (event === "close") {
closeHandler = handler;
}
});
it("should handle socket close via callback", async () => {
let onCloseCallback: Function | undefined;
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
(createWebSocketConnection as jest.Mock).mockImplementation(
(callbacks) => {
onCloseCallback = callbacks.onClose;
return Promise.resolve(mockSocket);
}
);
render(
<Provider store={store}>
@@ -135,11 +138,12 @@ describe("WebSocketGate", () => {
);
await waitFor(() => {
expect(mockSocket.addEventListener).toHaveBeenCalled();
expect(createWebSocketConnection).toHaveBeenCalled();
});
// Simulate close event
await act(async () => {
closeHandler!();
onCloseCallback?.(new CloseEvent("close"));
});
// Verify that subsequent calendar changes don't try to register
@@ -349,7 +353,7 @@ describe("WebSocketGate", () => {
await waitFor(() => {
expect(consoleError).toHaveBeenCalledWith(
"Failed to update calendar registrations:",
"Failed to register calendar:",
expect.any(Error)
);
});
@@ -443,8 +447,9 @@ describe("WebSocketGate", () => {
});
// Wait a bit to ensure the effect would have run if it was going to
await new Promise((resolve) => setTimeout(resolve, 100));
jest.useFakeTimers();
jest.advanceTimersByTime(100);
jest.useRealTimers();
expect(registerToCalendars).not.toHaveBeenCalled();
});
@@ -477,7 +482,7 @@ describe("WebSocketGate", () => {
await waitFor(() => {
expect(consoleError).toHaveBeenCalledWith(
"Failed to update calendar registrations:",
"Failed to unregister calendar:",
expect.any(Error)
);
});
@@ -1,7 +1,7 @@
import { api } from "../../../../src/utils/apiUtils";
import { fetchWebSocketTicket } from "../../../../src/websocket/api/fetchWebSocketTicket";
import { api } from "@/utils/apiUtils";
import { fetchWebSocketTicket } from "@/websocket/api/fetchWebSocketTicket";
jest.mock("../../../../src/utils/apiUtils");
jest.mock("@/utils/apiUtils");
describe("fetchWebSocketTicket", () => {
const mockTicket = {
@@ -1,10 +1,10 @@
import { waitFor } from "@testing-library/dom";
import { fetchWebSocketTicket } from "../../../src/websocket/api/fetchWebSocketTicket";
import { createWebSocketConnection } from "../../../src/websocket/createWebSocketConnection";
import { WS_INBOUND_EVENTS } from "../../../src/websocket/protocols";
import { fetchWebSocketTicket } from "@/websocket/api/fetchWebSocketTicket";
import { createWebSocketConnection } from "@/websocket/connection/createConnection";
import { WS_INBOUND_EVENTS } from "@/websocket/protocols";
import { setupWebsocket } from "./utils/setupWebsocket";
jest.mock("../../../src/websocket/api/fetchWebSocketTicket");
jest.mock("@/websocket/api/fetchWebSocketTicket");
describe("createWebSocketConnection", () => {
let mockWebSocket: jest.Mock;
@@ -28,7 +28,12 @@ describe("createWebSocketConnection", () => {
};
const createAndOpenConnection = async () => {
const promise = createWebSocketConnection();
const mockCallbacks = {
onMessage: jest.fn(),
onClose: jest.fn(),
onError: jest.fn(),
};
const promise = createWebSocketConnection(mockCallbacks);
await waitFor(() => {
expect(webSocketInstances.length).toBe(1);
@@ -37,7 +42,7 @@ describe("createWebSocketConnection", () => {
triggerEvent(getWs(), WS_INBOUND_EVENTS.CONNECTION_OPENED);
const socket = await promise;
return { socket, ws: getWs(), promise };
return { socket, ws: getWs(), promise, mockCallbacks };
};
/** ---------- Setup ---------- */
@@ -60,8 +65,11 @@ describe("createWebSocketConnection", () => {
it("throws when WEBSOCKET_URL is not defined", async () => {
delete (window as any).WEBSOCKET_URL;
const mockCallbacks = {
onMessage: jest.fn(),
};
await expect(createWebSocketConnection()).rejects.toThrow(
await expect(createWebSocketConnection(mockCallbacks)).rejects.toThrow(
"WEBSOCKET_URL is not defined"
);
});
@@ -95,7 +103,10 @@ describe("createWebSocketConnection", () => {
});
it("rejects when connection fails", async () => {
const promise = createWebSocketConnection();
const mockCallbacks = {
onMessage: jest.fn(),
};
const promise = createWebSocketConnection(mockCallbacks);
await waitFor(() => {
expect(webSocketInstances.length).toBe(1);
@@ -118,23 +129,6 @@ describe("createWebSocketConnection", () => {
expect(ws._listeners[WS_INBOUND_EVENTS.CONNECTION_CLOSED]).toBeDefined();
});
it("parses and logs incoming messages", async () => {
const logSpy = jest.spyOn(console, "log").mockImplementation();
const { ws } = await createAndOpenConnection();
triggerEvent(ws, WS_INBOUND_EVENTS.MESSAGE, {
data: JSON.stringify({ type: "test", payload: "data" }),
});
expect(logSpy).toHaveBeenCalledWith("WebSocket message received:", {
type: "test",
payload: "data",
});
logSpy.mockRestore();
});
it("handles invalid JSON messages", async () => {
const errorSpy = jest.spyOn(console, "error").mockImplementation();
@@ -149,4 +143,69 @@ describe("createWebSocketConnection", () => {
errorSpy.mockRestore();
});
it("rejects on timeout", async () => {
jest.useFakeTimers();
const mockCallbacks = {
onMessage: jest.fn(),
};
const promise = createWebSocketConnection(mockCallbacks);
await waitFor(() => {
expect(webSocketInstances.length).toBe(1);
});
jest.advanceTimersByTime(10000);
await expect(promise).rejects.toThrow("WebSocket connection timed out");
jest.useRealTimers();
});
it("calls onMessage callback when message received", async () => {
const { ws, mockCallbacks } = await createAndOpenConnection();
const testMessage = { type: "test", payload: "data" };
triggerEvent(ws, WS_INBOUND_EVENTS.MESSAGE, {
data: JSON.stringify(testMessage),
});
expect(mockCallbacks.onMessage).toHaveBeenCalledWith(testMessage);
});
it("does not call onMessage when JSON parsing fails", async () => {
const errorSpy = jest.spyOn(console, "error").mockImplementation();
const { ws, mockCallbacks } = await createAndOpenConnection();
triggerEvent(ws, WS_INBOUND_EVENTS.MESSAGE, { data: "invalid json" });
expect(mockCallbacks.onMessage).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith(
"Failed to parse WebSocket message:",
expect.any(Error)
);
errorSpy.mockRestore();
});
it("calls onClose callback when connection closes", async () => {
const { ws, mockCallbacks } = await createAndOpenConnection();
const closeEvent = new CloseEvent("close", {
code: 1000,
reason: "Normal closure",
});
triggerEvent(ws, WS_INBOUND_EVENTS.CONNECTION_CLOSED, closeEvent);
expect(mockCallbacks.onClose).toHaveBeenCalledWith(closeEvent);
});
it("calls onError callback when error occurs", async () => {
const { ws, mockCallbacks } = await createAndOpenConnection();
const errorEvent = new Event("error");
triggerEvent(ws, WS_INBOUND_EVENTS.ERROR, errorEvent);
expect(mockCallbacks.onError).toHaveBeenCalledWith(errorEvent);
});
});
@@ -0,0 +1,127 @@
import { updateCalendars } from "@/websocket/messaging/updateCalendars";
import { refreshCalendarWithSyncToken } from "@/features/Calendars/services/refreshCalendar";
import { RootState, store } from "@/app/store";
import { WS_INBOUND_EVENTS } from "@/websocket/protocols";
import { getDisplayedCalendarRange } from "@/utils/CalendarRangeManager";
import { waitFor } from "@testing-library/dom";
jest.mock("@/features/Calendars/services/refreshCalendar");
jest.mock("@/utils/CalendarRangeManager");
jest.mock("@/app/store", () => ({
store: {
getState: jest.fn(),
},
}));
describe("updateCalendars", () => {
let mockDispatch: jest.Mock;
const mockRange = {
start: new Date("2025-01-15T10:00:00Z"),
end: new Date("2025-01-16T10:00:00Z"),
};
const mockState = {
calendars: {
list: {
"cal1/entry1": { id: "cal1/entry1", name: "Calendar 1", syncToken: 1 },
"cal2/entry2": { id: "cal2/entry2", name: "Calendar 2", syncToken: 1 },
},
templist: {},
},
} as unknown as RootState;
beforeEach(() => {
jest.clearAllMocks();
mockDispatch = jest.fn();
(getDisplayedCalendarRange as jest.Mock).mockReturnValue(mockRange);
(store.getState as jest.Mock).mockReturnValue(mockState);
});
it("should not dispatch for non-object messages", () => {
updateCalendars(null, mockDispatch);
updateCalendars("string", mockDispatch);
updateCalendars(123, mockDispatch);
expect(refreshCalendarWithSyncToken).not.toHaveBeenCalled();
});
it("should dispatch for registered calendars", () => {
const message = {
[WS_INBOUND_EVENTS.CLIENT_REGISTERED]: [
"/calendars/cal1/entry1",
"/calendars/cal2/entry2",
],
};
updateCalendars(message, mockDispatch);
expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(2);
});
it("should dispatch for calendar path updates", () => {
const message = {
"/calendars/cal1/entry1": { updated: true },
};
updateCalendars(message, mockDispatch);
expect(refreshCalendarWithSyncToken).toHaveBeenCalled();
expect(refreshCalendarWithSyncToken).toHaveBeenCalledWith({
calendar: mockState.calendars.list["cal1/entry1"],
calType: undefined,
calendarRange: mockRange,
});
});
it("should use current displayed calendar range", () => {
const message = {
"/calendars/cal1/entry1": {},
};
updateCalendars(message, mockDispatch);
expect(getDisplayedCalendarRange).toHaveBeenCalled();
});
it("should handle temp calendars", async () => {
const stateWithTemp = {
calendars: {
list: {},
templist: {
"temp1/entry1": {
id: "temp1/entry1",
name: "Temp Calendar",
syncToken: 1,
},
},
},
};
(store.getState as jest.Mock).mockReturnValue(stateWithTemp);
const message = {
"/calendars/temp1/entry1": {},
};
updateCalendars(message, mockDispatch);
await waitFor(() =>
expect(refreshCalendarWithSyncToken).toHaveBeenCalledWith({
calendar: stateWithTemp.calendars.templist["temp1/entry1"],
calType: "temp",
calendarRange: mockRange,
})
);
});
it("should handle invalid calendar paths gracefully", () => {
const message = {
"/invalid/path": {},
"not-a-path": {},
};
updateCalendars(message, mockDispatch);
expect(refreshCalendarWithSyncToken).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,76 @@
import { WS_INBOUND_EVENTS } from "@/websocket/protocols";
import { parseMessage } from "@/websocket/messaging/parseMessage";
describe("parseMessage", () => {
it("should return empty set for non-object messages", () => {
const result1 = parseMessage(null);
const result2 = parseMessage("string");
const result3 = parseMessage(123);
expect(result1.calendarsToRefresh).toEqual(new Set<string>());
expect(result2.calendarsToRefresh).toEqual(new Set<string>());
expect(result3.calendarsToRefresh).toEqual(new Set<string>());
});
it("should handle registered event", () => {
const message = {
[WS_INBOUND_EVENTS.CLIENT_REGISTERED]: [
"/calendars/cal1/entry1",
"/calendars/cal2/entry2",
],
};
const result = parseMessage(message);
expect(result.calendarsToRefresh).toContain("/calendars/cal1/entry1");
expect(result.calendarsToRefresh).toContain("/calendars/cal2/entry2");
expect(result.calendarsToRefresh.size).toBe(2);
});
it("should handle unregistered event", () => {
const message = {
[WS_INBOUND_EVENTS.CLIENT_UNREGISTERED]: ["/calendars/cal1/entry1"],
};
const result = parseMessage(message);
expect(result.calendarsToHide).toContain("/calendars/cal1/entry1");
});
it("should handle calendar path updates", () => {
const message = {
"/calendars/cal1/entry1": { updated: true },
};
const result = parseMessage(message);
expect(result.calendarsToRefresh).toContain("/calendars/cal1/entry1");
expect(result.calendarsToRefresh.size).toBe(1);
});
it("should parse multiple calendar paths", () => {
const message = {
"/calendars/cal1/entry1": {},
"/calendars/cal2/entry2": {},
};
const result = parseMessage(message);
expect(result.calendarsToRefresh.size).toBe(2);
expect(result.calendarsToRefresh).toContain("/calendars/cal1/entry1");
expect(result.calendarsToRefresh).toContain("/calendars/cal2/entry2");
});
it("should handle multiple event types in single message", () => {
const message = {
[WS_INBOUND_EVENTS.CLIENT_REGISTERED]: ["/calendars/cal1/entry1"],
[WS_INBOUND_EVENTS.CLIENT_UNREGISTERED]: ["/calendars/cal2/entry2"],
"/calendars/cal1/entry1": {},
};
const result = parseMessage(message);
expect(result.calendarsToRefresh.size).toBe(1);
expect(result.calendarsToRefresh).toContain("/calendars/cal1/entry1");
});
});
@@ -1,4 +1,4 @@
import { registerToCalendars } from "../../../../src/websocket/ws/registerToCalendars";
import { registerToCalendars } from "@/websocket/operations/registerToCalendars";
describe("registerToCalendars", () => {
let mockSocket: any;
@@ -1,6 +1,6 @@
// src/websocket/__tests__/unregisterToCalendars.test.ts
import { unregisterToCalendars } from "../../../../src/websocket/ws/unregisterToCalendars";
import { unregisterToCalendars } from "@/websocket/operations/unregisterToCalendars";
describe("unregisterToCalendars", () => {
let mockSocket: any;
@@ -0,0 +1,73 @@
import {
getDisplayedDate,
setDisplayedDateAndRange,
calendarRangeManager,
getDisplayedCalendarRange,
} from "../../src/utils/CalendarRangeManager";
describe("CalendarRangeManager", () => {
beforeEach(() => {
setDisplayedDateAndRange(new Date());
});
it("should return a singleton instance", () => {
const instance1 = calendarRangeManager;
const instance2 = calendarRangeManager;
expect(instance1).toStrictEqual(instance2);
});
it("should compute and return calendar range when date is set", () => {
const testDate = new Date("2025-06-15T10:00:00Z");
setDisplayedDateAndRange(testDate);
const range = getDisplayedCalendarRange();
expect(range.start).toBeInstanceOf(Date);
expect(range.end).toBeInstanceOf(Date);
expect(range.start.getTime()).toBeLessThanOrEqual(testDate.getTime());
expect(range.end.getTime()).toBeGreaterThanOrEqual(testDate.getTime());
});
it("should get the default date", () => {
const date = getDisplayedDate();
expect(date).toBeInstanceOf(Date);
});
it("should set and get a date", () => {
const testDate = new Date("2025-01-15T10:00:00Z");
setDisplayedDateAndRange(testDate);
const retrievedDate = getDisplayedDate();
expect(retrievedDate).toStrictEqual(testDate);
});
it("should persist date across multiple calls", () => {
const testDate = new Date("2025-06-20T15:30:00Z");
setDisplayedDateAndRange(testDate);
const date1 = getDisplayedDate();
const date2 = getDisplayedDate();
expect(date1).toStrictEqual(date2);
expect(date1).toStrictEqual(testDate);
});
it("should update date when set multiple times", () => {
const date1 = new Date("2025-01-01T00:00:00Z");
const date2 = new Date("2025-12-31T23:59:59Z");
setDisplayedDateAndRange(date1);
expect(getDisplayedDate()).toStrictEqual(date1);
setDisplayedDateAndRange(date2);
expect(getDisplayedDate()).toStrictEqual(date2);
});
it("should maintain shared state (singleton behavior)", () => {
const testDate = new Date("2025-03-01");
setDisplayedDateAndRange(testDate);
// Verify CalendarRangeManager reflects the same state
expect(calendarRangeManager.getDate()).toStrictEqual(testDate);
});
});
+99
View File
@@ -0,0 +1,99 @@
import { findCalendarById } from "../../src/utils/findCalendarById";
import { Calendar } from "../../src/features/Calendars/CalendarTypes";
import { RootState } from "../../src/app/store";
describe("findCalendarById", () => {
const mockCalendar1: Calendar = {
id: "cal1",
name: "Personal",
color: { light: "#FF0000" },
} as unknown as Calendar;
const mockCalendar2: Calendar = {
id: "cal2",
name: "Work",
color: { light: "#0000FF" },
} as unknown as Calendar;
const mockTempCalendar: Calendar = {
id: "temp1",
name: "Temporary",
color: { light: "#00FF00" },
} as unknown as Calendar;
const mockState = {
calendars: {
list: {
cal1: mockCalendar1,
cal2: mockCalendar2,
},
templist: {
temp1: mockTempCalendar,
},
},
} as unknown as Partial<RootState>;
it("should find calendar in main list", () => {
const result = findCalendarById(mockState, "cal1");
expect(result?.calendar).toEqual(mockCalendar1);
expect(result?.type).toBeUndefined();
});
it("should find calendar in temp list", () => {
const result = findCalendarById(mockState, "temp1");
expect(result?.calendar).toEqual(mockTempCalendar);
expect(result?.type).toBe("temp");
});
it("should not return calendar for non-existent id", () => {
const result = findCalendarById(mockState, "nonexistent");
expect(result).toBeUndefined();
});
it("should not return calendar for empty string", () => {
const result = findCalendarById(mockState, "");
expect(result).toBeUndefined();
});
it("should prioritize main list over temp list", () => {
const stateWithDuplicate = {
calendars: {
list: {
dup1: mockCalendar1,
},
templist: {
dup1: mockTempCalendar,
},
},
} as unknown as Partial<RootState>;
const result = findCalendarById(stateWithDuplicate, "dup1");
expect(result?.calendar).toEqual(mockCalendar1);
expect(result?.type).toBeUndefined();
});
it("should handle undefined list or templist", () => {
const stateWithPartialCalendars = {
calendars: {
list: undefined,
templist: { temp1: mockTempCalendar },
},
} as unknown as Partial<RootState>;
const result = findCalendarById(stateWithPartialCalendars, "temp1");
expect(result?.calendar).toEqual(mockTempCalendar);
});
it("should handle missing calendars state", () => {
const emptyState = {};
const result = findCalendarById(emptyState, "cal1");
expect(result).toBeUndefined();
});
});
+4
View File
@@ -42,6 +42,7 @@ const config: Config = {
"^preact(/(.*)|$)": "preact$1",
"^react$": "<rootDir>/node_modules/react",
"^react-dom$": "<rootDir>/node_modules/react-dom",
"^@/(.*)$": "<rootDir>/src/$1",
},
setupFilesAfterEnv: ["<rootDir>/src/setupTests.ts"],
},
@@ -67,6 +68,9 @@ const config: Config = {
},
transformIgnorePatterns: ["/node_modules/(?!(ky|@linagora/twake-mui)/)"],
setupFilesAfterEnv: ["<rootDir>/src/setupTests.ts"],
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
},
},
],
};
+6 -4
View File
@@ -15,7 +15,7 @@ import ImportAlert from "../../features/Events/ImportAlert";
import {
formatDateToYYYYMMDDTHHMMSS,
getCalendarRange,
} from "../../utils/dateUtils";
} from "@/utils/dateUtils";
import { push } from "redux-first-history";
import EventPreviewModal from "../../features/Events/EventDisplayPreview";
import AddIcon from "@mui/icons-material/Add";
@@ -43,9 +43,10 @@ import ruLocale from "@fullcalendar/core/locales/ru";
import viLocale from "@fullcalendar/core/locales/vi";
import SearchResultsPage from "../../features/Search/SearchResultsPage";
import { setTimeZone } from "../../features/Settings/SettingsSlice";
import { browserDefaultTimeZone } from "../../utils/timezone";
import { extractEventBaseUuid } from "../../utils/extractEventBaseUuid";
import { setSelectedCalendars as setSelectedCalendarsToStorage } from "../../utils/storage/setSelectedCalendars";
import { browserDefaultTimeZone } from "@/utils/timezone";
import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid";
import { setSelectedCalendars as setSelectedCalendarsToStorage } from "@/utils/storage/setSelectedCalendars";
import { setDisplayedDateAndRange } from "@/utils/CalendarRangeManager";
const localeMap: Record<string, any> = {
fr: frLocale,
@@ -741,6 +742,7 @@ export default function CalendarApp({
setCurrentView(arg.view.type);
const calendarCurrentDate =
calendarRef.current?.getDate() || new Date(arg.start);
setDisplayedDateAndRange(calendarCurrentDate);
if (arg.view.type === "dayGridMonth") {
const start = new Date(arg.start).getTime();
+44
View File
@@ -0,0 +1,44 @@
import { getCalendarRange } from "./dateUtils";
class CalendarRangeManager {
private static instance: CalendarRangeManager;
private displayedDate: Date = new Date();
private displayedCalendarRange: { start: Date; end: Date } = getCalendarRange(
new Date()
);
private constructor() {}
public static getInstance(): CalendarRangeManager {
if (!CalendarRangeManager.instance) {
CalendarRangeManager.instance = new CalendarRangeManager();
}
return CalendarRangeManager.instance;
}
public getDate(): Date {
return new Date(this.displayedDate);
}
public setDate(date: Date) {
this.displayedDate = new Date(date);
this.displayedCalendarRange = getCalendarRange(new Date(date));
}
public getRange(): { start: Date; end: Date } {
return {
start: this.displayedCalendarRange.start,
end: this.displayedCalendarRange.end,
};
}
}
export const calendarRangeManager = CalendarRangeManager.getInstance();
export const getDisplayedDate = (): Date => calendarRangeManager.getDate();
export const setDisplayedDateAndRange = (date: Date) =>
calendarRangeManager.setDate(date);
export const getDisplayedCalendarRange = (): { start: Date; end: Date } =>
calendarRangeManager.getRange();
+17
View File
@@ -0,0 +1,17 @@
import { RootState } from "../app/store";
import { Calendar } from "../features/Calendars/CalendarTypes";
export function findCalendarById(
state: Partial<RootState>,
calendarId: string
): { calendar: Calendar; type?: "temp" } | undefined {
if (calendarId.length === 0) {
return;
}
if (state.calendars?.list?.[calendarId]) {
return { calendar: state.calendars.list[calendarId] };
}
if (state.calendars?.templist?.[calendarId]) {
return { calendar: state.calendars.templist[calendarId], type: "temp" };
}
}
+8
View File
@@ -0,0 +1,8 @@
export {
getDisplayedDate,
setDisplayedDateAndRange,
getDisplayedCalendarRange,
calendarRangeManager,
} from "./CalendarRangeManager";
export { findCalendarById } from "./findCalendarById";
+52 -76
View File
@@ -1,103 +1,79 @@
import { useEffect, useRef, useState } from "react";
import { useAppSelector } from "../app/hooks";
import { useSelectedCalendars } from "../utils/storage/useSelectedCalendars";
import {
createWebSocketConnection,
WebSocketWithCleanup,
} from "./createWebSocketConnection";
import { registerToCalendars } from "./ws/registerToCalendars";
import { unregisterToCalendars } from "./ws/unregisterToCalendars";
import { useAppDispatch, useAppSelector } from "@/app/hooks";
import { useSelectedCalendars } from "@/utils/storage/useSelectedCalendars";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { WebSocketWithCleanup } from "./connection";
import { closeWebSocketConnection } from "./connection/lifecycle/closeWebSocketConnection";
import { establishWebSocketConnection } from "./connection/lifecycle/establishWebSocketConnection";
import { updateCalendars } from "./messaging";
import { syncCalendarRegistrations } from "./operations";
export function WebSocketGate() {
const socketRef = useRef<WebSocketWithCleanup | null>(null);
const previousCalendarListRef = useRef<string[]>([]);
const dispatch = useAppDispatch();
const isAuthenticated = useAppSelector((state) =>
Boolean(state.user.userData && state.user.tokens)
);
const [isSocketOpen, setIsSocketOpen] = useState(false);
const calendarList = useSelectedCalendars();
const onMessage = useCallback(
(message: unknown) => {
updateCalendars(message, dispatch);
},
[dispatch]
);
const onClose = useCallback((event: CloseEvent) => {
// Socket already cleaned up by internal handler before this callback fires
socketRef.current = null;
setIsSocketOpen(false);
// TODO: Add reconnection logic here
}, []);
const onError = useCallback((error: Event) => {
console.error("WebSocket error:", error);
}, []);
const callBacks = useMemo(
() => ({
onMessage,
onClose,
onError,
}),
[onMessage, onClose, onError]
);
// Manage WebSocket connection
useEffect(() => {
const abortController = new AbortController();
if (!isAuthenticated) {
if (socketRef.current) {
socketRef.current.cleanup();
socketRef.current.close();
socketRef.current = null;
setIsSocketOpen(false);
}
closeWebSocketConnection(socketRef, setIsSocketOpen);
return;
}
const connect = async () => {
try {
const socket = await createWebSocketConnection();
socketRef.current = socket;
socket.addEventListener("close", () => {
setIsSocketOpen(false);
socketRef.current = null;
});
// Check if socket closed during setup
if (socket.readyState === WebSocket.OPEN) {
setIsSocketOpen(true);
}
} catch (error) {
console.error("Failed to create WebSocket connection:", error);
}
};
connect();
establishWebSocketConnection(
callBacks,
socketRef,
setIsSocketOpen,
abortController.signal
);
return () => {
if (socketRef.current) {
socketRef.current.cleanup();
socketRef.current.close();
socketRef.current = null;
setIsSocketOpen(false);
}
abortController.abort();
closeWebSocketConnection(socketRef, setIsSocketOpen);
};
}, [isAuthenticated]);
}, [isAuthenticated, callBacks]);
// Register using a diff with previous calendars
useEffect(() => {
if (
!isSocketOpen ||
!socketRef.current ||
socketRef.current.readyState !== WebSocket.OPEN
)
return;
const currentPaths = calendarList.map((cal) => `/calendars/${cal}`);
const previousPaths = previousCalendarListRef.current.map(
(cal) => `/calendars/${cal}`
syncCalendarRegistrations(
isSocketOpen,
socketRef,
calendarList,
previousCalendarListRef
);
// calendars to register
const toRegister = currentPaths.filter(
(path) => !previousPaths.includes(path)
);
// calendars to unregister
const toUnregister = previousPaths.filter(
(path) => !currentPaths.includes(path)
);
try {
if (toRegister.length > 0) {
registerToCalendars(socketRef.current, toRegister);
}
if (toUnregister.length > 0) {
unregisterToCalendars(socketRef.current, toUnregister);
}
// Only update the ref if operations succeeded
previousCalendarListRef.current = calendarList;
} catch (error) {
console.error("Failed to update calendar registrations:", error);
}
}, [isSocketOpen, calendarList]);
return null;
@@ -1,16 +1,15 @@
import { fetchWebSocketTicket } from "./api/fetchWebSocketTicket";
import { WS_INBOUND_EVENTS } from "./protocols";
import { fetchWebSocketTicket } from "../api/fetchWebSocketTicket";
import { WS_INBOUND_EVENTS } from "../protocols";
import { WebSocketCallbacks, WebSocketWithCleanup } from "./types";
export interface WebSocketWithCleanup extends WebSocket {
cleanup: () => void;
}
export async function createWebSocketConnection(): Promise<WebSocketWithCleanup> {
export async function createWebSocketConnection(
callbacks: WebSocketCallbacks
): Promise<WebSocketWithCleanup> {
const wsBaseUrl =
(window as any).WEBSOCKET_URL ??
(window as any).CALENDAR_BASE_URL?.replace(
/^http(s)?:/,
(_: boolean, s: boolean) => (s ? "wss:" : "ws:")
(_: string, s: string | undefined) => (s ? "wss:" : "ws:")
) ??
"";
@@ -36,6 +35,7 @@ export async function createWebSocketConnection(): Promise<WebSocketWithCleanup>
socket.close();
reject(new Error("WebSocket connection timed out"));
}, CONNECTION_TIMEOUT_MS);
const openHandler = () => {
console.log("WebSocket connection opened");
clearTimeout(timeoutId);
@@ -66,9 +66,7 @@ export async function createWebSocketConnection(): Promise<WebSocketWithCleanup>
const messageHandler = (event: MessageEvent) => {
try {
const message = JSON.parse(event.data);
console.log("WebSocket message received:", message);
// TODO: Handle different message types
callbacks.onMessage(message);
} catch (error) {
console.error("Failed to parse WebSocket message:", error);
}
@@ -76,13 +74,13 @@ export async function createWebSocketConnection(): Promise<WebSocketWithCleanup>
const closeHandler = (event: CloseEvent) => {
console.log("WebSocket closed:", event.code, event.reason);
// Clean up all event listeners when socket closes
cleanup();
// TODO: Add reconnection logic
callbacks.onClose?.(event);
};
const errorHandler = (error: Event) => {
console.error("WebSocket error:", error);
callbacks.onError?.(error);
};
// Cleanup function to remove all event listeners
+2
View File
@@ -0,0 +1,2 @@
export { createWebSocketConnection } from "./createConnection";
export type { WebSocketWithCleanup, WebSocketCallbacks } from "./types";
@@ -0,0 +1,13 @@
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);
}
}
@@ -0,0 +1,28 @@
import { createWebSocketConnection } from "../createConnection";
import { WebSocketCallbacks, WebSocketWithCleanup } from "../types";
export async function establishWebSocketConnection(
callbacks: WebSocketCallbacks,
socketRef: React.MutableRefObject<WebSocketWithCleanup | null>,
setIsSocketOpen: (value: boolean) => void,
signal?: AbortSignal
) {
try {
const socket = await createWebSocketConnection(callbacks);
if (signal?.aborted) {
socket.cleanup();
socket.close();
return;
}
socketRef.current = socket;
if (socket.readyState === WebSocket.OPEN) {
setIsSocketOpen(true);
}
} catch (error) {
console.error("Failed to create WebSocket connection:", error);
setIsSocketOpen(false);
}
}
+9
View File
@@ -0,0 +1,9 @@
export interface WebSocketWithCleanup extends WebSocket {
cleanup: () => void;
}
export interface WebSocketCallbacks {
onMessage: (data: unknown) => void;
onClose?: (event: CloseEvent) => void;
onError?: (error: Event) => void;
}
+2
View File
@@ -0,0 +1,2 @@
export { WebSocketGate } from "./WebSocketGate";
export type { WebSocketWithCleanup, WebSocketCallbacks } from "./connection";
+3
View File
@@ -0,0 +1,3 @@
export { parseMessage } from "./parseMessage";
export { updateCalendars } from "./updateCalendars";
export { parseCalendarPath } from "./parseCalendarPath";
@@ -0,0 +1,11 @@
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;
}
const [, , userId, calendarId] = key.split("/");
return `${userId}/${calendarId}`;
}
+30
View File
@@ -0,0 +1,30 @@
import { WS_INBOUND_EVENTS } from "../protocols";
export function parseMessage(message: unknown) {
console.log("WebSocket message received:", message);
const calendarsToRefresh = new Set<string>();
const calendarsToHide = new Set<string>();
if (typeof message !== "object" || message === null) {
return { calendarsToRefresh, calendarsToHide };
}
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));
}
break;
case WS_INBOUND_EVENTS.CLIENT_UNREGISTERED:
if (Array.isArray(value)) {
value.forEach((cal: string) => calendarsToHide.add(cal));
}
break;
default: {
calendarsToRefresh.add(key);
}
}
}
return { calendarsToRefresh, calendarsToHide };
}
@@ -0,0 +1,45 @@
import type { AppDispatch } from "@/app/store";
import { store } from "@/app/store";
import { refreshCalendarWithSyncToken } from "@/features/Calendars/services/refreshCalendar";
import { findCalendarById, getDisplayedCalendarRange } from "@/utils";
import { setSelectedCalendars } from "@/utils/storage/setSelectedCalendars";
import { parseCalendarPath } from "./parseCalendarPath";
import { parseMessage } from "./parseMessage";
export function updateCalendars(message: unknown, dispatch: AppDispatch) {
const currentRange = getDisplayedCalendarRange();
const state = store.getState();
const { calendarsToRefresh, calendarsToHide } = parseMessage(message);
calendarsToRefresh.forEach((calendarPath) => {
const calendarId = parseCalendarPath(calendarPath);
if (!calendarId) {
console.warn("Invalid calendar path received:", calendarPath);
return;
}
const calendar = findCalendarById(state, calendarId);
if (calendar) {
dispatch(
refreshCalendarWithSyncToken({
calendar: calendar.calendar,
calType: calendar.type,
calendarRange: currentRange,
})
);
} else {
console.warn("Calendar not found for id:", calendarId);
}
});
const currentSelectedCalendars = JSON.parse(
localStorage.getItem("selectedCalendars") ?? "[]"
) as string[];
const calendarIdsToHide = [...calendarsToHide]
.map(parseCalendarPath)
.filter((id): id is string => Boolean(id));
const updatedSelectedCalendars = currentSelectedCalendars.filter(
(id) => !calendarIdsToHide.includes(id)
);
setSelectedCalendars(updatedSelectedCalendars);
}
+3
View File
@@ -0,0 +1,3 @@
export { registerToCalendars } from "./registerToCalendars";
export { unregisterToCalendars } from "./unregisterToCalendars";
export { syncCalendarRegistrations } from "./syncCalendarRegistrations";
@@ -0,0 +1,48 @@
import { WebSocketWithCleanup } from "../connection";
import { registerToCalendars } from "./registerToCalendars";
import { unregisterToCalendars } from "./unregisterToCalendars";
export function syncCalendarRegistrations(
isSocketOpen: boolean,
socketRef: React.MutableRefObject<WebSocketWithCleanup | null>,
calendarList: string[],
previousCalendarListRef: React.MutableRefObject<string[]>
) {
if (
!isSocketOpen ||
!socketRef.current ||
socketRef.current.readyState !== WebSocket.OPEN
) {
return;
}
const currentPaths = calendarList.map((cal) => `/calendars/${cal}`);
const previousPaths = previousCalendarListRef.current.map(
(cal) => `/calendars/${cal}`
);
const toRegister = currentPaths.filter(
(path) => !previousPaths.includes(path)
);
const toUnregister = previousPaths.filter(
(path) => !currentPaths.includes(path)
);
try {
if (toRegister.length > 0) {
registerToCalendars(socketRef.current, toRegister);
}
} catch (error) {
console.error("Failed to register calendar:", error);
return;
}
try {
if (toUnregister.length > 0) {
unregisterToCalendars(socketRef.current, toUnregister);
}
} catch (error) {
console.error("Failed to unregister calendar:", error);
return;
}
previousCalendarListRef.current = calendarList;
}
-13
View File
@@ -1,13 +0,0 @@
// WebSocket event listeners (browser events)
export const WS_INBOUND_EVENTS = {
CONNECTION_OPENED: "open",
MESSAGE: "message",
ERROR: "error",
CONNECTION_CLOSED: "close",
} as const;
// WebSocket message types sent to server
export const WS_OUTBOUND_EVENTS = {
REGISTER_CLIENT: "register",
UNREGISTER_CLIENT: "unregister",
} as const;
+8
View File
@@ -0,0 +1,8 @@
export const WS_INBOUND_EVENTS = {
CONNECTION_OPENED: "open",
MESSAGE: "message",
ERROR: "error",
CONNECTION_CLOSED: "close",
CLIENT_REGISTERED: "registered",
CLIENT_UNREGISTERED: "unregistered",
} as const;
+10 -1
View File
@@ -14,7 +14,16 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@/websocket": ["src/websocket/index.ts"],
"@/websocket/*": ["src/websocket/*"],
"@/utils": ["src/utils/index.ts"],
"@/features/*": ["src/features/*"],
"@/app/*": ["src/app/*"]
}
},
"include": ["src", "__test__"]
}