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 { cleanup, render, waitFor, act } from "@testing-library/react";
import { Provider } from "react-redux"; import { Provider } from "react-redux";
import { configureStore } from "@reduxjs/toolkit"; import { configureStore } from "@reduxjs/toolkit";
import { createWebSocketConnection } from "../../../src/websocket/createWebSocketConnection"; import { createWebSocketConnection } from "@/websocket/connection/createConnection";
import { registerToCalendars } from "../../../src/websocket/ws/registerToCalendars"; import { registerToCalendars } from "@/websocket/operations/registerToCalendars";
import { unregisterToCalendars } from "../../../src/websocket/ws/unregisterToCalendars"; import { unregisterToCalendars } from "@/websocket/operations/unregisterToCalendars";
import { WebSocketGate } from "../../../src/websocket/WebSocketGate"; import { WebSocketGate } from "@/websocket/WebSocketGate";
import { setSelectedCalendars } from "../../../src/utils/storage/setSelectedCalendars"; import { setSelectedCalendars } from "@/utils/storage/setSelectedCalendars";
jest.mock("../../../src/websocket/createWebSocketConnection"); jest.mock("@/websocket/connection/createConnection");
jest.mock("../../../src/websocket/ws/registerToCalendars"); jest.mock("@/websocket/operations/registerToCalendars");
jest.mock("../../../src/websocket/ws/unregisterToCalendars"); jest.mock("@/websocket/operations/unregisterToCalendars");
describe("WebSocketGate", () => { describe("WebSocketGate", () => {
let store: any; let store: any;
@@ -101,7 +101,7 @@ describe("WebSocketGate", () => {
}); });
describe("Socket Connection Management", () => { 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); (createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
render( render(
@@ -111,22 +111,25 @@ describe("WebSocketGate", () => {
); );
await waitFor(() => { await waitFor(() => {
expect(mockSocket.addEventListener).toHaveBeenCalledWith( expect(createWebSocketConnection).toHaveBeenCalledWith(
"close", expect.objectContaining({
expect.any(Function) onMessage: expect.any(Function),
onClose: expect.any(Function),
onError: expect.any(Function),
})
); );
}); });
}); });
it("should handle socket close event", async () => { it("should handle socket close via callback", async () => {
let closeHandler: Function; let onCloseCallback: Function | undefined;
mockSocket.addEventListener = jest.fn((event, handler) => {
if (event === "close") {
closeHandler = handler;
}
});
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket); (createWebSocketConnection as jest.Mock).mockImplementation(
(callbacks) => {
onCloseCallback = callbacks.onClose;
return Promise.resolve(mockSocket);
}
);
render( render(
<Provider store={store}> <Provider store={store}>
@@ -135,11 +138,12 @@ describe("WebSocketGate", () => {
); );
await waitFor(() => { await waitFor(() => {
expect(mockSocket.addEventListener).toHaveBeenCalled(); expect(createWebSocketConnection).toHaveBeenCalled();
}); });
// Simulate close event
await act(async () => { await act(async () => {
closeHandler!(); onCloseCallback?.(new CloseEvent("close"));
}); });
// Verify that subsequent calendar changes don't try to register // Verify that subsequent calendar changes don't try to register
@@ -349,7 +353,7 @@ describe("WebSocketGate", () => {
await waitFor(() => { await waitFor(() => {
expect(consoleError).toHaveBeenCalledWith( expect(consoleError).toHaveBeenCalledWith(
"Failed to update calendar registrations:", "Failed to register calendar:",
expect.any(Error) expect.any(Error)
); );
}); });
@@ -443,8 +447,9 @@ describe("WebSocketGate", () => {
}); });
// Wait a bit to ensure the effect would have run if it was going to // 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(); expect(registerToCalendars).not.toHaveBeenCalled();
}); });
@@ -477,7 +482,7 @@ describe("WebSocketGate", () => {
await waitFor(() => { await waitFor(() => {
expect(consoleError).toHaveBeenCalledWith( expect(consoleError).toHaveBeenCalledWith(
"Failed to update calendar registrations:", "Failed to unregister calendar:",
expect.any(Error) expect.any(Error)
); );
}); });
@@ -1,7 +1,7 @@
import { api } from "../../../../src/utils/apiUtils"; import { api } from "@/utils/apiUtils";
import { fetchWebSocketTicket } from "../../../../src/websocket/api/fetchWebSocketTicket"; import { fetchWebSocketTicket } from "@/websocket/api/fetchWebSocketTicket";
jest.mock("../../../../src/utils/apiUtils"); jest.mock("@/utils/apiUtils");
describe("fetchWebSocketTicket", () => { describe("fetchWebSocketTicket", () => {
const mockTicket = { const mockTicket = {
@@ -1,10 +1,10 @@
import { waitFor } from "@testing-library/dom"; import { waitFor } from "@testing-library/dom";
import { fetchWebSocketTicket } from "../../../src/websocket/api/fetchWebSocketTicket"; import { fetchWebSocketTicket } from "@/websocket/api/fetchWebSocketTicket";
import { createWebSocketConnection } from "../../../src/websocket/createWebSocketConnection"; import { createWebSocketConnection } from "@/websocket/connection/createConnection";
import { WS_INBOUND_EVENTS } from "../../../src/websocket/protocols"; import { WS_INBOUND_EVENTS } from "@/websocket/protocols";
import { setupWebsocket } from "./utils/setupWebsocket"; import { setupWebsocket } from "./utils/setupWebsocket";
jest.mock("../../../src/websocket/api/fetchWebSocketTicket"); jest.mock("@/websocket/api/fetchWebSocketTicket");
describe("createWebSocketConnection", () => { describe("createWebSocketConnection", () => {
let mockWebSocket: jest.Mock; let mockWebSocket: jest.Mock;
@@ -28,7 +28,12 @@ describe("createWebSocketConnection", () => {
}; };
const createAndOpenConnection = async () => { const createAndOpenConnection = async () => {
const promise = createWebSocketConnection(); const mockCallbacks = {
onMessage: jest.fn(),
onClose: jest.fn(),
onError: jest.fn(),
};
const promise = createWebSocketConnection(mockCallbacks);
await waitFor(() => { await waitFor(() => {
expect(webSocketInstances.length).toBe(1); expect(webSocketInstances.length).toBe(1);
@@ -37,7 +42,7 @@ describe("createWebSocketConnection", () => {
triggerEvent(getWs(), WS_INBOUND_EVENTS.CONNECTION_OPENED); triggerEvent(getWs(), WS_INBOUND_EVENTS.CONNECTION_OPENED);
const socket = await promise; const socket = await promise;
return { socket, ws: getWs(), promise }; return { socket, ws: getWs(), promise, mockCallbacks };
}; };
/** ---------- Setup ---------- */ /** ---------- Setup ---------- */
@@ -60,8 +65,11 @@ describe("createWebSocketConnection", () => {
it("throws when WEBSOCKET_URL is not defined", async () => { it("throws when WEBSOCKET_URL is not defined", async () => {
delete (window as any).WEBSOCKET_URL; 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" "WEBSOCKET_URL is not defined"
); );
}); });
@@ -95,7 +103,10 @@ describe("createWebSocketConnection", () => {
}); });
it("rejects when connection fails", async () => { it("rejects when connection fails", async () => {
const promise = createWebSocketConnection(); const mockCallbacks = {
onMessage: jest.fn(),
};
const promise = createWebSocketConnection(mockCallbacks);
await waitFor(() => { await waitFor(() => {
expect(webSocketInstances.length).toBe(1); expect(webSocketInstances.length).toBe(1);
@@ -118,23 +129,6 @@ describe("createWebSocketConnection", () => {
expect(ws._listeners[WS_INBOUND_EVENTS.CONNECTION_CLOSED]).toBeDefined(); 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 () => { it("handles invalid JSON messages", async () => {
const errorSpy = jest.spyOn(console, "error").mockImplementation(); const errorSpy = jest.spyOn(console, "error").mockImplementation();
@@ -149,4 +143,69 @@ describe("createWebSocketConnection", () => {
errorSpy.mockRestore(); 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", () => { describe("registerToCalendars", () => {
let mockSocket: any; let mockSocket: any;
@@ -1,6 +1,6 @@
// src/websocket/__tests__/unregisterToCalendars.test.ts // src/websocket/__tests__/unregisterToCalendars.test.ts
import { unregisterToCalendars } from "../../../../src/websocket/ws/unregisterToCalendars"; import { unregisterToCalendars } from "@/websocket/operations/unregisterToCalendars";
describe("unregisterToCalendars", () => { describe("unregisterToCalendars", () => {
let mockSocket: any; 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", "^preact(/(.*)|$)": "preact$1",
"^react$": "<rootDir>/node_modules/react", "^react$": "<rootDir>/node_modules/react",
"^react-dom$": "<rootDir>/node_modules/react-dom", "^react-dom$": "<rootDir>/node_modules/react-dom",
"^@/(.*)$": "<rootDir>/src/$1",
}, },
setupFilesAfterEnv: ["<rootDir>/src/setupTests.ts"], setupFilesAfterEnv: ["<rootDir>/src/setupTests.ts"],
}, },
@@ -67,6 +68,9 @@ const config: Config = {
}, },
transformIgnorePatterns: ["/node_modules/(?!(ky|@linagora/twake-mui)/)"], transformIgnorePatterns: ["/node_modules/(?!(ky|@linagora/twake-mui)/)"],
setupFilesAfterEnv: ["<rootDir>/src/setupTests.ts"], setupFilesAfterEnv: ["<rootDir>/src/setupTests.ts"],
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
},
}, },
], ],
}; };
+6 -4
View File
@@ -15,7 +15,7 @@ import ImportAlert from "../../features/Events/ImportAlert";
import { import {
formatDateToYYYYMMDDTHHMMSS, formatDateToYYYYMMDDTHHMMSS,
getCalendarRange, getCalendarRange,
} from "../../utils/dateUtils"; } from "@/utils/dateUtils";
import { push } from "redux-first-history"; import { push } from "redux-first-history";
import EventPreviewModal from "../../features/Events/EventDisplayPreview"; import EventPreviewModal from "../../features/Events/EventDisplayPreview";
import AddIcon from "@mui/icons-material/Add"; 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 viLocale from "@fullcalendar/core/locales/vi";
import SearchResultsPage from "../../features/Search/SearchResultsPage"; import SearchResultsPage from "../../features/Search/SearchResultsPage";
import { setTimeZone } from "../../features/Settings/SettingsSlice"; import { setTimeZone } from "../../features/Settings/SettingsSlice";
import { browserDefaultTimeZone } from "../../utils/timezone"; import { browserDefaultTimeZone } from "@/utils/timezone";
import { extractEventBaseUuid } from "../../utils/extractEventBaseUuid"; import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid";
import { setSelectedCalendars as setSelectedCalendarsToStorage } from "../../utils/storage/setSelectedCalendars"; import { setSelectedCalendars as setSelectedCalendarsToStorage } from "@/utils/storage/setSelectedCalendars";
import { setDisplayedDateAndRange } from "@/utils/CalendarRangeManager";
const localeMap: Record<string, any> = { const localeMap: Record<string, any> = {
fr: frLocale, fr: frLocale,
@@ -741,6 +742,7 @@ export default function CalendarApp({
setCurrentView(arg.view.type); setCurrentView(arg.view.type);
const calendarCurrentDate = const calendarCurrentDate =
calendarRef.current?.getDate() || new Date(arg.start); calendarRef.current?.getDate() || new Date(arg.start);
setDisplayedDateAndRange(calendarCurrentDate);
if (arg.view.type === "dayGridMonth") { if (arg.view.type === "dayGridMonth") {
const start = new Date(arg.start).getTime(); 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 { useAppDispatch, useAppSelector } from "@/app/hooks";
import { useAppSelector } from "../app/hooks"; import { useSelectedCalendars } from "@/utils/storage/useSelectedCalendars";
import { useSelectedCalendars } from "../utils/storage/useSelectedCalendars"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import type { WebSocketWithCleanup } from "./connection";
createWebSocketConnection, import { closeWebSocketConnection } from "./connection/lifecycle/closeWebSocketConnection";
WebSocketWithCleanup, import { establishWebSocketConnection } from "./connection/lifecycle/establishWebSocketConnection";
} from "./createWebSocketConnection"; import { updateCalendars } from "./messaging";
import { registerToCalendars } from "./ws/registerToCalendars"; import { syncCalendarRegistrations } from "./operations";
import { unregisterToCalendars } from "./ws/unregisterToCalendars";
export function WebSocketGate() { export function WebSocketGate() {
const socketRef = useRef<WebSocketWithCleanup | null>(null); const socketRef = useRef<WebSocketWithCleanup | null>(null);
const previousCalendarListRef = useRef<string[]>([]); const previousCalendarListRef = useRef<string[]>([]);
const dispatch = useAppDispatch();
const isAuthenticated = useAppSelector((state) => const isAuthenticated = useAppSelector((state) =>
Boolean(state.user.userData && state.user.tokens) Boolean(state.user.userData && state.user.tokens)
); );
const [isSocketOpen, setIsSocketOpen] = useState(false); const [isSocketOpen, setIsSocketOpen] = useState(false);
const calendarList = useSelectedCalendars(); 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 // Manage WebSocket connection
useEffect(() => { useEffect(() => {
const abortController = new AbortController();
if (!isAuthenticated) { if (!isAuthenticated) {
if (socketRef.current) { closeWebSocketConnection(socketRef, setIsSocketOpen);
socketRef.current.cleanup();
socketRef.current.close();
socketRef.current = null;
setIsSocketOpen(false);
}
return; return;
} }
const connect = async () => { establishWebSocketConnection(
try { callBacks,
const socket = await createWebSocketConnection(); socketRef,
socketRef.current = socket; setIsSocketOpen,
socket.addEventListener("close", () => { abortController.signal
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();
return () => { return () => {
if (socketRef.current) { abortController.abort();
socketRef.current.cleanup(); closeWebSocketConnection(socketRef, setIsSocketOpen);
socketRef.current.close();
socketRef.current = null;
setIsSocketOpen(false);
}
}; };
}, [isAuthenticated]); }, [isAuthenticated, callBacks]);
// Register using a diff with previous calendars // Register using a diff with previous calendars
useEffect(() => { useEffect(() => {
if ( syncCalendarRegistrations(
!isSocketOpen || isSocketOpen,
!socketRef.current || socketRef,
socketRef.current.readyState !== WebSocket.OPEN calendarList,
) previousCalendarListRef
return;
const currentPaths = calendarList.map((cal) => `/calendars/${cal}`);
const previousPaths = previousCalendarListRef.current.map(
(cal) => `/calendars/${cal}`
); );
// 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]); }, [isSocketOpen, calendarList]);
return null; return null;
@@ -1,16 +1,15 @@
import { fetchWebSocketTicket } from "./api/fetchWebSocketTicket"; import { fetchWebSocketTicket } from "../api/fetchWebSocketTicket";
import { WS_INBOUND_EVENTS } from "./protocols"; import { WS_INBOUND_EVENTS } from "../protocols";
import { WebSocketCallbacks, WebSocketWithCleanup } from "./types";
export interface WebSocketWithCleanup extends WebSocket { export async function createWebSocketConnection(
cleanup: () => void; callbacks: WebSocketCallbacks
} ): Promise<WebSocketWithCleanup> {
export async function createWebSocketConnection(): Promise<WebSocketWithCleanup> {
const wsBaseUrl = const wsBaseUrl =
(window as any).WEBSOCKET_URL ?? (window as any).WEBSOCKET_URL ??
(window as any).CALENDAR_BASE_URL?.replace( (window as any).CALENDAR_BASE_URL?.replace(
/^http(s)?:/, /^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(); socket.close();
reject(new Error("WebSocket connection timed out")); reject(new Error("WebSocket connection timed out"));
}, CONNECTION_TIMEOUT_MS); }, CONNECTION_TIMEOUT_MS);
const openHandler = () => { const openHandler = () => {
console.log("WebSocket connection opened"); console.log("WebSocket connection opened");
clearTimeout(timeoutId); clearTimeout(timeoutId);
@@ -66,9 +66,7 @@ export async function createWebSocketConnection(): Promise<WebSocketWithCleanup>
const messageHandler = (event: MessageEvent) => { const messageHandler = (event: MessageEvent) => {
try { try {
const message = JSON.parse(event.data); const message = JSON.parse(event.data);
console.log("WebSocket message received:", message); callbacks.onMessage(message);
// TODO: Handle different message types
} catch (error) { } catch (error) {
console.error("Failed to parse WebSocket message:", error); console.error("Failed to parse WebSocket message:", error);
} }
@@ -76,13 +74,13 @@ export async function createWebSocketConnection(): Promise<WebSocketWithCleanup>
const closeHandler = (event: CloseEvent) => { const closeHandler = (event: CloseEvent) => {
console.log("WebSocket closed:", event.code, event.reason); console.log("WebSocket closed:", event.code, event.reason);
// Clean up all event listeners when socket closes
cleanup(); cleanup();
// TODO: Add reconnection logic callbacks.onClose?.(event);
}; };
const errorHandler = (error: Event) => { const errorHandler = (error: Event) => {
console.error("WebSocket error:", error); console.error("WebSocket error:", error);
callbacks.onError?.(error);
}; };
// Cleanup function to remove all event listeners // 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, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"noEmit": 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__"] "include": ["src", "__test__"]
} }