From 2f23a0cc0b2f0979e71526bd2e167d791ae95153 Mon Sep 17 00:00:00 2001 From: Tin Ruiqi Date: Wed, 6 Nov 2024 11:49:53 +0800 Subject: [PATCH 01/10] Fix end session on page refresh and tab closure --- frontend/src/pages/CollabSandbox/index.tsx | 23 +++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/frontend/src/pages/CollabSandbox/index.tsx b/frontend/src/pages/CollabSandbox/index.tsx index c2c96abcc4..7c1da0955d 100644 --- a/frontend/src/pages/CollabSandbox/index.tsx +++ b/frontend/src/pages/CollabSandbox/index.tsx @@ -46,10 +46,9 @@ const CollabSandbox: React.FC = () => { } const { - verifyMatchStatus, + // verifyMatchStatus, getMatchId, matchUser, - partner, matchCriteria, loading, questionId, @@ -75,7 +74,8 @@ const CollabSandbox: React.FC = () => { const [selectedTestcase, setSelectedTestcase] = useState(0); useEffect(() => { - verifyMatchStatus(); + // TODO: Retain session on page refresh + // verifyMatchStatus(); if (!questionId) { return; @@ -107,7 +107,14 @@ const CollabSandbox: React.FC = () => { connectToCollabSession(); - return () => leave(matchUser.id, matchId); + // handle page refresh / tab closure + const handleUnload = () => leave(matchUser.id, matchId); + window.addEventListener("unload", handleUnload); + + return () => { + leave(matchUser.id, matchId); + window.removeEventListener("unload", handleUnload); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -116,13 +123,7 @@ const CollabSandbox: React.FC = () => { return ; } - if ( - !matchUser || - !partner || - !matchCriteria || - !getMatchId() || - !isConnecting - ) { + if (!matchUser || !matchCriteria || !getMatchId() || !isConnecting) { return ; } From 38cc60859d0a0f33dca43a6b1dae11c3dc75569c Mon Sep 17 00:00:00 2001 From: Tin Ruiqi Date: Thu, 7 Nov 2024 18:34:54 +0800 Subject: [PATCH 02/10] Modify alert message for collab page --- frontend/src/contexts/MatchContext.tsx | 74 +++++++--------------- frontend/src/pages/CollabSandbox/index.tsx | 35 ++++------ frontend/src/utils/constants.ts | 10 ++- 3 files changed, 43 insertions(+), 76 deletions(-) diff --git a/frontend/src/contexts/MatchContext.tsx b/frontend/src/contexts/MatchContext.tsx index c9f78dcfed..c699bec693 100644 --- a/frontend/src/contexts/MatchContext.tsx +++ b/frontend/src/contexts/MatchContext.tsx @@ -3,8 +3,10 @@ import React, { createContext, useContext, useEffect, useState } from "react"; import { matchSocket } from "../utils/matchSocket"; import { + ABORT_COLLAB_SESSION_CONFIRMATION_MESSAGE, ABORT_MATCH_PROCESS_CONFIRMATION_MESSAGE, FAILED_MATCH_REQUEST_MESSAGE, + MATCH_ACCEPTANCE_ERROR, MATCH_CONNECTION_ERROR, MATCH_LOGIN_REQUIRED_MESSAGE, MATCH_REQUEST_EXISTS_MESSAGE, @@ -17,9 +19,6 @@ import useAppNavigate from "../hooks/useAppNavigate"; import { UNSAFE_NavigationContext } from "react-router-dom"; import { Action, type History, type Transition } from "history"; -let matchUserId: string; -let partnerUserId: string; - type MatchUser = { id: string; username: string; @@ -81,7 +80,6 @@ type MatchContextType = { retryMatch: () => void; matchingTimeout: () => void; matchOfferTimeout: () => void; - verifyMatchStatus: () => void; getMatchId: () => string | null; matchUser: MatchUser | null; matchCriteria: MatchCriteria | null; @@ -126,20 +124,17 @@ const MatchProvider: React.FC<{ children?: React.ReactNode }> = (props) => { username: user.username, profile: user.profilePictureUrl, }); - matchUserId = user.id; } else { setMatchUser(null); - matchUserId = ""; } }, [user]); useEffect(() => { - if ( - !matchUser?.id || - (location.pathname !== MatchPaths.MATCHING && - location.pathname !== MatchPaths.MATCHED && - location.pathname !== MatchPaths.COLLAB) - ) { + const isMatchPage = + location.pathname === MatchPaths.MATCHING || + location.pathname === MatchPaths.MATCHED; + const isCollabPage = location.pathname == MatchPaths.COLLAB; + if (!matchUser?.id || !(isMatchPage || isCollabPage)) { resetMatchStates(); return; } @@ -147,21 +142,25 @@ const MatchProvider: React.FC<{ children?: React.ReactNode }> = (props) => { openSocketConnection(); matchSocket.emit(MatchEvents.USER_CONNECTED, matchUser?.id); + const message = isMatchPage + ? ABORT_MATCH_PROCESS_CONFIRMATION_MESSAGE + : ABORT_COLLAB_SESSION_CONFIRMATION_MESSAGE; + + // handle page leave (navigate away) const unblock = navigator.block((transition: Transition) => { - if ( - transition.action === Action.Replace || - confirm(ABORT_MATCH_PROCESS_CONFIRMATION_MESSAGE) - ) { + if (transition.action === Action.Replace || confirm(message)) { unblock(); appNavigate(transition.location.pathname); } }); + // handle tab closure / url change const handleBeforeUnload = (e: BeforeUnloadEvent) => { e.preventDefault(); - e.returnValue = ABORT_MATCH_PROCESS_CONFIRMATION_MESSAGE; // for legacy support, does not actually display message + e.returnValue = message; // for legacy support, does not actually display message }; + // handle page refresh / tab closure const handleUnload = () => { closeSocketConnection(); }; @@ -171,6 +170,7 @@ const MatchProvider: React.FC<{ children?: React.ReactNode }> = (props) => { return () => { closeSocketConnection(); + unblock(); window.removeEventListener("beforeunload", handleBeforeUnload); window.removeEventListener("unload", handleUnload); }; @@ -183,7 +183,6 @@ const MatchProvider: React.FC<{ children?: React.ReactNode }> = (props) => { } setMatchId(null); setPartner(null); - partnerUserId = ""; setMatchPending(false); setLoading(false); }; @@ -307,10 +306,8 @@ const MatchProvider: React.FC<{ children?: React.ReactNode }> = (props) => { setMatchId(matchId); if (matchUser?.id === user1.id) { setPartner(user2); - partnerUserId = user2.id; } else { setPartner(user1); - partnerUserId = user1.id; } setMatchPending(true); appNavigate(MatchPaths.MATCHED); @@ -389,11 +386,16 @@ const MatchProvider: React.FC<{ children?: React.ReactNode }> = (props) => { }; const acceptMatch = () => { + if (!matchUser || !partner) { + toast.error(MATCH_ACCEPTANCE_ERROR); + return; + } + matchSocket.emit( MatchEvents.MATCH_ACCEPT_REQUEST, matchId, - matchUserId, - partnerUserId + matchUser.id, + partner.id ); }; @@ -429,7 +431,6 @@ const MatchProvider: React.FC<{ children?: React.ReactNode }> = (props) => { if (requested) { appNavigate(MatchPaths.MATCHING); setPartner(null); - partnerUserId = ""; } } ); @@ -464,32 +465,6 @@ const MatchProvider: React.FC<{ children?: React.ReactNode }> = (props) => { appNavigate(MatchPaths.HOME); }; - const verifyMatchStatus = () => { - const requestTimeout = setTimeout(() => { - setLoading(false); - toast.error(MATCH_CONNECTION_ERROR); - }, requestTimeoutDuration); - - setLoading(true); - matchSocket.emit( - MatchEvents.MATCH_STATUS_REQUEST, - matchUser?.id, - (match: { matchId: string; partner: MatchUser } | null) => { - clearTimeout(requestTimeout); - if (match) { - setMatchId(match.matchId); - setPartner(match.partner); - partnerUserId = match.partner.id; - } else { - setMatchId(null); - setPartner(null); - partnerUserId = ""; - } - setLoading(false); - } - ); - }; - const getMatchId = () => { return matchId; }; @@ -504,7 +479,6 @@ const MatchProvider: React.FC<{ children?: React.ReactNode }> = (props) => { retryMatch, matchingTimeout, matchOfferTimeout, - verifyMatchStatus, getMatchId, matchUser, matchCriteria, diff --git a/frontend/src/pages/CollabSandbox/index.tsx b/frontend/src/pages/CollabSandbox/index.tsx index 7c1da0955d..d10184abd1 100644 --- a/frontend/src/pages/CollabSandbox/index.tsx +++ b/frontend/src/pages/CollabSandbox/index.tsx @@ -35,24 +35,12 @@ import { CollabSessionData, join, leave } from "../../utils/collabSocket"; import { toast } from "react-toastify"; const CollabSandbox: React.FC = () => { - const [editorState, setEditorState] = useState( - null - ); - const [isConnecting, setIsConnecting] = useState(true); - const match = useMatch(); if (!match) { throw new Error(USE_MATCH_ERROR_MESSAGE); } - const { - // verifyMatchStatus, - getMatchId, - matchUser, - matchCriteria, - loading, - questionId, - } = match; + const { getMatchId, matchUser, matchCriteria, questionId } = match; const collab = useCollab(); if (!collab) { @@ -72,11 +60,13 @@ const CollabSandbox: React.FC = () => { const { selectedQuestion } = state; const [selectedTab, setSelectedTab] = useState<"tests" | "chat">("tests"); const [selectedTestcase, setSelectedTestcase] = useState(0); + const [editorState, setEditorState] = useState( + null + ); + const [isConnecting, setIsConnecting] = useState(true); + const matchId = getMatchId(); useEffect(() => { - // TODO: Retain session on page refresh - // verifyMatchStatus(); - if (!questionId) { return; } @@ -84,7 +74,6 @@ const CollabSandbox: React.FC = () => { resetCollab(); - const matchId = getMatchId(); if (!matchUser || !matchId) { return; } @@ -108,7 +97,9 @@ const CollabSandbox: React.FC = () => { connectToCollabSession(); // handle page refresh / tab closure - const handleUnload = () => leave(matchUser.id, matchId); + const handleUnload = () => { + leave(matchUser.id, matchId); + }; window.addEventListener("unload", handleUnload); return () => { @@ -119,11 +110,7 @@ const CollabSandbox: React.FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - if (loading) { - return ; - } - - if (!matchUser || !matchCriteria || !getMatchId() || !isConnecting) { + if (!matchUser || !matchCriteria || !matchId || !isConnecting) { return ; } @@ -219,7 +206,7 @@ const CollabSandbox: React.FC = () => { ? selectedQuestion.cTemplate : "" } - roomId={getMatchId()!} + roomId={matchId} /> Date: Thu, 7 Nov 2024 20:28:40 +0800 Subject: [PATCH 03/10] Handle partner ending or leaving session --- .../src/handlers/websocketHandler.ts | 27 ++++--- .../src/components/CustomDialog/index.tsx | 75 +++++++++++++++++++ frontend/src/contexts/CollabContext.tsx | 26 +++++-- frontend/src/pages/CollabSandbox/index.tsx | 73 ++++-------------- frontend/src/utils/collabSocket.ts | 9 ++- frontend/src/utils/constants.ts | 6 +- 6 files changed, 140 insertions(+), 76 deletions(-) create mode 100644 frontend/src/components/CustomDialog/index.tsx diff --git a/backend/collab-service/src/handlers/websocketHandler.ts b/backend/collab-service/src/handlers/websocketHandler.ts index ee2948d436..184b6a890a 100644 --- a/backend/collab-service/src/handlers/websocketHandler.ts +++ b/backend/collab-service/src/handlers/websocketHandler.ts @@ -15,14 +15,14 @@ enum CollabEvents { // Send ROOM_READY = "room_ready", - DOCUMENT_READY = "document_ready", UPDATE = "updateV2", UPDATE_CURSOR = "update_cursor", PARTNER_LEFT = "partner_left", + PARTNER_DISCONNECTED = "partner_disconnected", } const EXPIRY_TIME = 3600; -const CONNECTION_DELAY = 3000; // time window to allow for page re-renders / refresh +const CONNECTION_DELAY = 3000; // time window to allow for page re-renders const userConnections = new Map(); const collabSessions = new Map(); @@ -63,7 +63,6 @@ export const handleWebsocketCollabEvents = (socket: Socket) => { doc.transact(() => { doc.getText().insert(0, template); }); - io.to(roomId).emit(CollabEvents.DOCUMENT_READY); } else { partnerReadiness.set(roomId, true); } @@ -93,17 +92,17 @@ export const handleWebsocketCollabEvents = (socket: Socket) => { socket.on( CollabEvents.LEAVE, - (uid: string, roomId: string, isImmediate: boolean) => { + (uid: string, roomId: string, isIntentional: boolean) => { const connectionKey = `${uid}:${roomId}`; - if (isImmediate || !userConnections.has(connectionKey)) { - handleUserLeave(uid, roomId, socket); + if (isIntentional || !userConnections.has(connectionKey)) { + handleUserLeave(uid, roomId, socket, isIntentional); return; } clearTimeout(userConnections.get(connectionKey)!); const connectionTimeout = setTimeout(() => { - handleUserLeave(uid, roomId, socket); + handleUserLeave(uid, roomId, socket, isIntentional); }, CONNECTION_DELAY); userConnections.set(connectionKey, connectionTimeout); @@ -141,6 +140,7 @@ const removeCollabSession = (roomId: string) => { collabSessions.get(roomId)?.destroy(); collabSessions.delete(roomId); partnerReadiness.delete(roomId); + redisClient.del(roomId); }; const getDocument = (roomId: string) => { @@ -165,7 +165,12 @@ const saveDocument = async (roomId: string, doc: Doc) => { }); }; -const handleUserLeave = (uid: string, roomId: string, socket: Socket) => { +const handleUserLeave = ( + uid: string, + roomId: string, + socket: Socket, + isIntentional: boolean +) => { const connectionKey = `${uid}:${roomId}`; if (userConnections.has(connectionKey)) { clearTimeout(userConnections.get(connectionKey)!); @@ -179,6 +184,10 @@ const handleUserLeave = (uid: string, roomId: string, socket: Socket) => { if (!room || room.size === 0) { removeCollabSession(roomId); } else { - io.to(roomId).emit(CollabEvents.PARTNER_LEFT); + io.to(roomId).emit( + isIntentional + ? CollabEvents.PARTNER_LEFT + : CollabEvents.PARTNER_DISCONNECTED + ); } }; diff --git a/frontend/src/components/CustomDialog/index.tsx b/frontend/src/components/CustomDialog/index.tsx new file mode 100644 index 0000000000..611f296e62 --- /dev/null +++ b/frontend/src/components/CustomDialog/index.tsx @@ -0,0 +1,75 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, +} from "@mui/material"; + +type CustomDialogProps = { + titleText: string; + bodyText: React.ReactNode; + primaryAction: string; + handlePrimaryAction: () => void; + secondaryAction: string; + open: boolean; + handleClose: () => void; +}; + +const CustomDialog: React.FC = (props) => { + const { + titleText, + bodyText, + primaryAction, + handlePrimaryAction, + secondaryAction, + open, + handleClose, + } = props; + + return ( + ({ + "& .MuiDialog-paper": { + padding: theme.spacing(2.5), + }, + })} + open={open} + onClose={handleClose} + > + + {titleText} + + + + {bodyText} + + + ({ + justifyContent: "center", + paddingBottom: theme.spacing(2.5), + })} + > + + + + + ); +}; + +export default CustomDialog; diff --git a/frontend/src/contexts/CollabContext.tsx b/frontend/src/contexts/CollabContext.tsx index 052eed02b5..00a4a3b657 100644 --- a/frontend/src/contexts/CollabContext.tsx +++ b/frontend/src/contexts/CollabContext.tsx @@ -6,7 +6,9 @@ import { FAILED_TESTCASE_MESSAGE, SUCCESS_TESTCASE_MESSAGE, FAILED_TO_SUBMIT_CODE_MESSAGE, + COLLAB_PARTNER_DISCONNECTED_MESSAGE, COLLAB_ENDED_MESSAGE, + COLLAB_END_ERROR, } from "../utils/constants"; import { toast } from "react-toastify"; @@ -63,7 +65,6 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { const { matchUser, - partner, matchCriteria, getMatchId, stopMatch, @@ -143,8 +144,14 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { const handleConfirmEndSession = async () => { setIsEndSessionModalOpen(false); - // Get queston history - const data = await qnHistoryClient.get(qnHistoryId as string); + const roomId = getMatchId(); + if (!matchUser || !roomId || !qnHistoryId) { + toast.error(COLLAB_END_ERROR); + return; + } + + // Get question history + const data = await qnHistoryClient.get(qnHistoryId); // Only update question history if it has not been submitted before if (!data.data.qnHistory.code) { @@ -161,8 +168,8 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { } // Leave collaboration room - leave(matchUser?.id as string, getMatchId() as string, true); - leave(partner?.id as string, getMatchId() as string, true); + leave(matchUser.id as string, roomId, true); + // TODO: partner leave // Leave chat room communicationSocket.emit(CommunicationEvents.USER_DISCONNECT); @@ -177,7 +184,14 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { const checkPartnerStatus = () => { collabSocket.on(CollabEvents.PARTNER_LEFT, () => { - toast.error(COLLAB_ENDED_MESSAGE); + toast.info(COLLAB_ENDED_MESSAGE); + setIsEndSessionModalOpen(false); + stopMatch(); + appNavigate("/home"); + }); + + collabSocket.on(CollabEvents.PARTNER_DISCONNECTED, () => { + toast.error(COLLAB_PARTNER_DISCONNECTED_MESSAGE); setIsEndSessionModalOpen(false); stopMatch(); appNavigate("/home"); diff --git a/frontend/src/pages/CollabSandbox/index.tsx b/frontend/src/pages/CollabSandbox/index.tsx index d10184abd1..852043fe26 100644 --- a/frontend/src/pages/CollabSandbox/index.tsx +++ b/frontend/src/pages/CollabSandbox/index.tsx @@ -1,16 +1,5 @@ import AppMargin from "../../components/AppMargin"; -import { - Box, - Button, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, - Grid2, - Tab, - Tabs, -} from "@mui/material"; +import { Box, Button, Grid2, Tab, Tabs } from "@mui/material"; import classes from "./index.module.css"; import { CompilerResult, useCollab } from "../../contexts/CollabContext"; import { useMatch } from "../../contexts/MatchContext"; @@ -33,6 +22,7 @@ import TestCase from "../../components/TestCase"; import CodeEditor from "../../components/CodeEditor"; import { CollabSessionData, join, leave } from "../../utils/collabSocket"; import { toast } from "react-toastify"; +import CustomDialog from "../../components/CustomDialog"; const CollabSandbox: React.FC = () => { const match = useMatch(); @@ -120,52 +110,21 @@ const CollabSandbox: React.FC = () => { return ( - - - {"End Session?"} - - - - Are you sure you want to end session? + + Are you sure you want to end the collaboration session?
- You will lose your current progress. -
-
- - - - -
+ You will not be able to rejoin. + + } + primaryAction="Confirm" + handlePrimaryAction={handleConfirmEndSession} + secondaryAction="Cancel" + open={isEndSessionModalOpen} + handleClose={handleRejectEndSession} + /> { }); }; -export const leave = (uid: string, roomId: string, isImmediate?: boolean) => { +export const leave = (uid: string, roomId: string, isIntentional?: boolean) => { collabSocket.removeAllListeners(); collabSocket.io.removeListener(CollabEvents.SOCKET_RECONNECT_SUCCESS); collabSocket.io.removeListener(CollabEvents.SOCKET_RECONNECT_FAILED); - collabSocket.emit(CollabEvents.LEAVE, uid, roomId, isImmediate); - doc.destroy(); + collabSocket.emit(CollabEvents.LEAVE, uid, roomId, isIntentional); + if (doc) { + doc.destroy(); + } }; export const sendCursorUpdate = (roomId: string, cursor: Cursor) => { diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts index 8e7fb44f64..715c3ef3be 100644 --- a/frontend/src/utils/constants.ts +++ b/frontend/src/utils/constants.ts @@ -104,9 +104,13 @@ export const QUESTION_DOES_NOT_EXIST_ERROR = // Collab export const COLLAB_ENDED_MESSAGE = - "Your partner has left the collaboration session."; + "Your partner has ended the collaboration session. The session will be closing soon..."; +export const COLLAB_PARTNER_DISCONNECTED_MESSAGE = + "Your partner has disconnected! The session will be closing soon..."; export const COLLAB_CONNECTION_ERROR = "Error connecting you to the collaboration session! Please try again."; +export const COLLAB_END_ERROR = + "Error ending the collaboration session! Please try again."; // Code execution export const FAILED_TESTCASE_MESSAGE = From 4e1c332399addf514fad1395fe4c2d3439d65d29 Mon Sep 17 00:00:00 2001 From: Tin Ruiqi Date: Fri, 8 Nov 2024 01:56:33 +0800 Subject: [PATCH 04/10] Add pop up before session end --- .../src/handlers/websocketHandler.ts | 53 ++++---- .../src/components/CustomDialog/index.tsx | 45 +++++-- frontend/src/contexts/CollabContext.tsx | 116 ++++++++++++------ frontend/src/pages/CollabSandbox/index.tsx | 25 +++- frontend/src/utils/collabSocket.ts | 15 ++- frontend/src/utils/constants.ts | 4 +- 6 files changed, 169 insertions(+), 89 deletions(-) diff --git a/backend/collab-service/src/handlers/websocketHandler.ts b/backend/collab-service/src/handlers/websocketHandler.ts index 184b6a890a..f30009ffb6 100644 --- a/backend/collab-service/src/handlers/websocketHandler.ts +++ b/backend/collab-service/src/handlers/websocketHandler.ts @@ -12,12 +12,13 @@ enum CollabEvents { UPDATE_REQUEST = "update_request", UPDATE_CURSOR_REQUEST = "update_cursor_request", RECONNECT_REQUEST = "reconnect_request", + END_SESSION_REQUEST = "end_session_request", // Send ROOM_READY = "room_ready", UPDATE = "updateV2", UPDATE_CURSOR = "update_cursor", - PARTNER_LEFT = "partner_left", + END_SESSION = "end_session", PARTNER_DISCONNECTED = "partner_disconnected", } @@ -92,23 +93,38 @@ export const handleWebsocketCollabEvents = (socket: Socket) => { socket.on( CollabEvents.LEAVE, - (uid: string, roomId: string, isIntentional: boolean) => { + (uid: string, roomId: string, isPartnerNotified: boolean) => { + console.log("leave: ", uid); const connectionKey = `${uid}:${roomId}`; - if (isIntentional || !userConnections.has(connectionKey)) { - handleUserLeave(uid, roomId, socket, isIntentional); - return; + if (userConnections.has(connectionKey)) { + console.log("clear disconnect timeout: ", uid); + clearTimeout(userConnections.get(connectionKey)!); } - clearTimeout(userConnections.get(connectionKey)!); + if (isPartnerNotified || !userConnections.has(connectionKey)) { + handleUserLeave(uid, roomId, socket); + return; + } + console.log("set disconnect timeout: ", uid); const connectionTimeout = setTimeout(() => { - handleUserLeave(uid, roomId, socket, isIntentional); + handleUserLeave(uid, roomId, socket); + console.log("notify partner of disconnect: ", uid); + console.log("room: ", roomId); + io.to(roomId).emit(CollabEvents.PARTNER_DISCONNECTED); }, CONNECTION_DELAY); userConnections.set(connectionKey, connectionTimeout); } ); + socket.on( + CollabEvents.END_SESSION_REQUEST, + (roomId: string, timeTaken: number) => { + socket.to(roomId).emit(CollabEvents.END_SESSION, timeTaken); + } + ); + socket.on(CollabEvents.RECONNECT_REQUEST, async (roomId: string) => { // TODO: Handle recconnection socket.join(roomId); @@ -165,29 +181,12 @@ const saveDocument = async (roomId: string, doc: Doc) => { }); }; -const handleUserLeave = ( - uid: string, - roomId: string, - socket: Socket, - isIntentional: boolean -) => { +const handleUserLeave = (uid: string, roomId: string, socket: Socket) => { const connectionKey = `${uid}:${roomId}`; - if (userConnections.has(connectionKey)) { - clearTimeout(userConnections.get(connectionKey)!); - userConnections.delete(connectionKey); - } + userConnections.delete(connectionKey); socket.leave(roomId); socket.disconnect(); - const room = io.sockets.adapter.rooms.get(roomId); - if (!room || room.size === 0) { - removeCollabSession(roomId); - } else { - io.to(roomId).emit( - isIntentional - ? CollabEvents.PARTNER_LEFT - : CollabEvents.PARTNER_DISCONNECTED - ); - } + removeCollabSession(roomId); }; diff --git a/frontend/src/components/CustomDialog/index.tsx b/frontend/src/components/CustomDialog/index.tsx index 611f296e62..9ed355991b 100644 --- a/frontend/src/components/CustomDialog/index.tsx +++ b/frontend/src/components/CustomDialog/index.tsx @@ -12,9 +12,9 @@ type CustomDialogProps = { bodyText: React.ReactNode; primaryAction: string; handlePrimaryAction: () => void; - secondaryAction: string; + secondaryAction?: string; open: boolean; - handleClose: () => void; + handleClose?: () => void; }; const CustomDialog: React.FC = (props) => { @@ -38,11 +38,27 @@ const CustomDialog: React.FC = (props) => { open={open} onClose={handleClose} > - + {titleText} - + {bodyText} @@ -52,18 +68,23 @@ const CustomDialog: React.FC = (props) => { paddingBottom: theme.spacing(2.5), })} > - + {secondaryAction ? ( + + ) : ( + <> + )} diff --git a/frontend/src/contexts/CollabContext.tsx b/frontend/src/contexts/CollabContext.tsx index 00a4a3b657..5bb0e94e14 100644 --- a/frontend/src/contexts/CollabContext.tsx +++ b/frontend/src/contexts/CollabContext.tsx @@ -1,6 +1,12 @@ /* eslint-disable react-refresh/only-export-components */ -import React, { createContext, useContext, useEffect, useState } from "react"; +import React, { + createContext, + useContext, + useEffect, + useRef, + useState, +} from "react"; import { USE_MATCH_ERROR_MESSAGE, FAILED_TESTCASE_MESSAGE, @@ -41,7 +47,10 @@ type CollabContextType = { handleSubmitSessionClick: () => void; handleEndSessionClick: () => void; handleRejectEndSession: () => void; - handleConfirmEndSession: () => void; + handleConfirmEndSession: ( + isInitiatedByPartner: boolean, + time?: number + ) => void; checkPartnerStatus: () => void; setCode: React.Dispatch>; compilerResult: CompilerResult[]; @@ -49,6 +58,8 @@ type CollabContextType = { isEndSessionModalOpen: boolean; time: number; resetCollab: () => void; + handleExitSession: () => void; + isExitSessionModalOpen: boolean; }; const CollabContext = createContext(null); @@ -72,17 +83,6 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { qnHistoryId, } = match; - const [time, setTime] = useState(0); - - useEffect(() => { - const intervalId = setInterval( - () => setTime((prevTime) => prevTime + 1), - 1000 - ); - - return () => clearInterval(intervalId); - }, [time]); - // eslint-disable-next-line const [_qnHistoryState, qnHistoryDispatch] = useReducer( qnHistoryReducer, @@ -92,6 +92,31 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { const [compilerResult, setCompilerResult] = useState([]); const [isEndSessionModalOpen, setIsEndSessionModalOpen] = useState(false); + const [isExitSessionModalOpen, setIsExitSessionModalOpen] = + useState(false); + const [time, setTime] = useState(0); + const [stopTime, setStopTime] = useState(true); + + const timeRef = useRef(time); + const codeRef = useRef(code); + + useEffect(() => { + timeRef.current = time; + codeRef.current = code; + }, [time, code]); + + useEffect(() => { + if (stopTime) { + return; + } + + const intervalId = setInterval( + () => setTime((prevTime) => prevTime + 1), + 1000 + ); + + return () => clearInterval(intervalId); + }, [time, stopTime]); const handleSubmitSessionClick = async () => { try { @@ -141,7 +166,10 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { setIsEndSessionModalOpen(false); }; - const handleConfirmEndSession = async () => { + const handleConfirmEndSession = async ( + isInitiatedByPartner: boolean, + timeTaken?: number + ) => { setIsEndSessionModalOpen(false); const roomId = getMatchId(); @@ -150,29 +178,44 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { return; } - // Get question history - const data = await qnHistoryClient.get(qnHistoryId); + setStopTime(true); + setIsExitSessionModalOpen(true); + + if (isInitiatedByPartner && timeTaken) { + setTime(timeTaken); + } else { + // Get question history + const data = await qnHistoryClient.get(qnHistoryId); + + // Only update question history if it has not been submitted before + if (!data.data.qnHistory.code) { + updateQnHistoryById( + qnHistoryId as string, + { + submissionStatus: "Attempted", + dateAttempted: new Date().toISOString(), + timeTaken: timeRef.current, + code: codeRef.current.replace(/\t/g, " ".repeat(4)), + }, + qnHistoryDispatch + ); + } + } - // Only update question history if it has not been submitted before - if (!data.data.qnHistory.code) { - updateQnHistoryById( - qnHistoryId as string, - { - submissionStatus: "Attempted", - dateAttempted: new Date().toISOString(), - timeTaken: time, - code: code.replace(/\t/g, " ".repeat(4)), - }, - qnHistoryDispatch - ); + if (!isInitiatedByPartner) { + // Notify partner + collabSocket.emit(CollabEvents.END_SESSION_REQUEST, roomId, time); } // Leave collaboration room - leave(matchUser.id as string, roomId, true); - // TODO: partner leave + leave(matchUser.id, roomId, true); // Leave chat room communicationSocket.emit(CommunicationEvents.USER_DISCONNECT); + }; + + const handleExitSession = () => { + setIsExitSessionModalOpen(false); // Delete match data stopMatch(); @@ -183,24 +226,21 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { }; const checkPartnerStatus = () => { - collabSocket.on(CollabEvents.PARTNER_LEFT, () => { + collabSocket.on(CollabEvents.END_SESSION, (timeTaken: number) => { toast.info(COLLAB_ENDED_MESSAGE); - setIsEndSessionModalOpen(false); - stopMatch(); - appNavigate("/home"); + handleConfirmEndSession(true, timeTaken); }); collabSocket.on(CollabEvents.PARTNER_DISCONNECTED, () => { toast.error(COLLAB_PARTNER_DISCONNECTED_MESSAGE); - setIsEndSessionModalOpen(false); - stopMatch(); - appNavigate("/home"); + handleConfirmEndSession(true); }); }; const resetCollab = () => { setCompilerResult([]); setTime(0); + setStopTime(false); }; return ( @@ -217,6 +257,8 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { isEndSessionModalOpen, time, resetCollab, + handleExitSession, + isExitSessionModalOpen, }} > {children} diff --git a/frontend/src/pages/CollabSandbox/index.tsx b/frontend/src/pages/CollabSandbox/index.tsx index 852043fe26..c4c395da39 100644 --- a/frontend/src/pages/CollabSandbox/index.tsx +++ b/frontend/src/pages/CollabSandbox/index.tsx @@ -23,6 +23,10 @@ import CodeEditor from "../../components/CodeEditor"; import { CollabSessionData, join, leave } from "../../utils/collabSocket"; import { toast } from "react-toastify"; import CustomDialog from "../../components/CustomDialog"; +import { + extractMinutesFromTime, + extractSecondsFromTime, +} from "../../utils/sessionTime"; const CollabSandbox: React.FC = () => { const match = useMatch(); @@ -44,6 +48,9 @@ const CollabSandbox: React.FC = () => { checkPartnerStatus, isEndSessionModalOpen, resetCollab, + handleExitSession, + isExitSessionModalOpen, + time, } = collab; const [state, dispatch] = useReducer(reducer, initialState); @@ -57,13 +64,13 @@ const CollabSandbox: React.FC = () => { const matchId = getMatchId(); useEffect(() => { + resetCollab(); + if (!questionId) { return; } getQuestionById(questionId, dispatch); - resetCollab(); - if (!matchUser || !matchId) { return; } @@ -88,12 +95,12 @@ const CollabSandbox: React.FC = () => { // handle page refresh / tab closure const handleUnload = () => { - leave(matchUser.id, matchId); + leave(matchUser.id, matchId, false); }; window.addEventListener("unload", handleUnload); return () => { - leave(matchUser.id, matchId); + leave(matchUser.id, matchId, false); window.removeEventListener("unload", handleUnload); }; @@ -120,11 +127,19 @@ const CollabSandbox: React.FC = () => { } primaryAction="Confirm" - handlePrimaryAction={handleConfirmEndSession} + handlePrimaryAction={() => handleConfirmEndSession(false)} secondaryAction="Cancel" open={isEndSessionModalOpen} handleClose={handleRejectEndSession} /> + { }); }; -export const leave = (uid: string, roomId: string, isIntentional?: boolean) => { +export const leave = ( + uid: string, + roomId: string, + isPartnerNotified: boolean +) => { collabSocket.removeAllListeners(); collabSocket.io.removeListener(CollabEvents.SOCKET_RECONNECT_SUCCESS); collabSocket.io.removeListener(CollabEvents.SOCKET_RECONNECT_FAILED); - collabSocket.emit(CollabEvents.LEAVE, uid, roomId, isIntentional); - if (doc) { - doc.destroy(); - } + collabSocket.emit(CollabEvents.LEAVE, uid, roomId, isPartnerNotified); + doc?.destroy(); }; export const sendCursorUpdate = (roomId: string, cursor: Cursor) => { diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts index 715c3ef3be..87370bb819 100644 --- a/frontend/src/utils/constants.ts +++ b/frontend/src/utils/constants.ts @@ -104,9 +104,9 @@ export const QUESTION_DOES_NOT_EXIST_ERROR = // Collab export const COLLAB_ENDED_MESSAGE = - "Your partner has ended the collaboration session. The session will be closing soon..."; + "Your partner has ended the collaboration session."; export const COLLAB_PARTNER_DISCONNECTED_MESSAGE = - "Your partner has disconnected! The session will be closing soon..."; + "Unfortunately, the collaboration session has ended as your partner has disconnected."; export const COLLAB_CONNECTION_ERROR = "Error connecting you to the collaboration session! Please try again."; export const COLLAB_END_ERROR = From 14293512db56a5c58312ca708bc0ab1203f0d217 Mon Sep 17 00:00:00 2001 From: Tin Ruiqi Date: Fri, 8 Nov 2024 04:01:43 +0800 Subject: [PATCH 05/10] Fix collab page stuck on loading --- .../collab-service/src/handlers/websocketHandler.ts | 7 +------ frontend/src/contexts/CollabContext.tsx | 6 ++++-- frontend/src/pages/CollabSandbox/index.tsx | 12 +++++++----- frontend/src/utils/collabSocket.ts | 5 ++++- 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/backend/collab-service/src/handlers/websocketHandler.ts b/backend/collab-service/src/handlers/websocketHandler.ts index f30009ffb6..402e36131b 100644 --- a/backend/collab-service/src/handlers/websocketHandler.ts +++ b/backend/collab-service/src/handlers/websocketHandler.ts @@ -94,23 +94,18 @@ export const handleWebsocketCollabEvents = (socket: Socket) => { socket.on( CollabEvents.LEAVE, (uid: string, roomId: string, isPartnerNotified: boolean) => { - console.log("leave: ", uid); const connectionKey = `${uid}:${roomId}`; if (userConnections.has(connectionKey)) { - console.log("clear disconnect timeout: ", uid); clearTimeout(userConnections.get(connectionKey)!); } - if (isPartnerNotified || !userConnections.has(connectionKey)) { + if (isPartnerNotified) { handleUserLeave(uid, roomId, socket); return; } - console.log("set disconnect timeout: ", uid); const connectionTimeout = setTimeout(() => { handleUserLeave(uid, roomId, socket); - console.log("notify partner of disconnect: ", uid); - console.log("room: ", roomId); io.to(roomId).emit(CollabEvents.PARTNER_DISCONNECTED); }, CONNECTION_DELAY); diff --git a/frontend/src/contexts/CollabContext.tsx b/frontend/src/contexts/CollabContext.tsx index 5bb0e94e14..d59691fbf9 100644 --- a/frontend/src/contexts/CollabContext.tsx +++ b/frontend/src/contexts/CollabContext.tsx @@ -226,12 +226,14 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { }; const checkPartnerStatus = () => { - collabSocket.on(CollabEvents.END_SESSION, (timeTaken: number) => { + collabSocket.once(CollabEvents.END_SESSION, (timeTaken: number) => { + collabSocket.off(CollabEvents.PARTNER_DISCONNECTED); toast.info(COLLAB_ENDED_MESSAGE); handleConfirmEndSession(true, timeTaken); }); - collabSocket.on(CollabEvents.PARTNER_DISCONNECTED, () => { + collabSocket.once(CollabEvents.PARTNER_DISCONNECTED, () => { + collabSocket.off(CollabEvents.END_SESSION); toast.error(COLLAB_PARTNER_DISCONNECTED_MESSAGE); handleConfirmEndSession(true); }); diff --git a/frontend/src/pages/CollabSandbox/index.tsx b/frontend/src/pages/CollabSandbox/index.tsx index c4c395da39..a334654428 100644 --- a/frontend/src/pages/CollabSandbox/index.tsx +++ b/frontend/src/pages/CollabSandbox/index.tsx @@ -66,11 +66,6 @@ const CollabSandbox: React.FC = () => { useEffect(() => { resetCollab(); - if (!questionId) { - return; - } - getQuestionById(questionId, dispatch); - if (!matchUser || !matchId) { return; } @@ -107,6 +102,13 @@ const CollabSandbox: React.FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + if (!questionId) { + return; + } + getQuestionById(questionId, dispatch); + }, [questionId]); + if (!matchUser || !matchCriteria || !matchId || !isConnecting) { return ; } diff --git a/frontend/src/utils/collabSocket.ts b/frontend/src/utils/collabSocket.ts index 8f0b7595e9..7a7f0b9403 100644 --- a/frontend/src/utils/collabSocket.ts +++ b/frontend/src/utils/collabSocket.ts @@ -93,8 +93,11 @@ export const leave = ( collabSocket.removeAllListeners(); collabSocket.io.removeListener(CollabEvents.SOCKET_RECONNECT_SUCCESS); collabSocket.io.removeListener(CollabEvents.SOCKET_RECONNECT_FAILED); - collabSocket.emit(CollabEvents.LEAVE, uid, roomId, isPartnerNotified); doc?.destroy(); + + if (collabSocket.connected) { + collabSocket.emit(CollabEvents.LEAVE, uid, roomId, isPartnerNotified); + } }; export const sendCursorUpdate = (roomId: string, cursor: Cursor) => { From 5e939fd49ea65ada6bf9b2095304bded8cca6561 Mon Sep 17 00:00:00 2001 From: Tin Ruiqi Date: Fri, 8 Nov 2024 23:33:34 +0800 Subject: [PATCH 06/10] Fix excessive re-rendering of components that use collab context --- .../src/handlers/websocketHandler.ts | 4 +- .../CollabSessionControls/index.tsx | 119 +++++++++++++++++- frontend/src/contexts/CollabContext.tsx | 75 +++-------- frontend/src/pages/CollabSandbox/index.tsx | 41 +----- 4 files changed, 134 insertions(+), 105 deletions(-) diff --git a/backend/collab-service/src/handlers/websocketHandler.ts b/backend/collab-service/src/handlers/websocketHandler.ts index 402e36131b..c1c43d511c 100644 --- a/backend/collab-service/src/handlers/websocketHandler.ts +++ b/backend/collab-service/src/handlers/websocketHandler.ts @@ -115,8 +115,8 @@ export const handleWebsocketCollabEvents = (socket: Socket) => { socket.on( CollabEvents.END_SESSION_REQUEST, - (roomId: string, timeTaken: number) => { - socket.to(roomId).emit(CollabEvents.END_SESSION, timeTaken); + (roomId: string, sessionDuration: number) => { + socket.to(roomId).emit(CollabEvents.END_SESSION, sessionDuration); } ); diff --git a/frontend/src/components/CollabSessionControls/index.tsx b/frontend/src/components/CollabSessionControls/index.tsx index 81b30bdb6a..4fcf0b0671 100644 --- a/frontend/src/components/CollabSessionControls/index.tsx +++ b/frontend/src/components/CollabSessionControls/index.tsx @@ -1,15 +1,99 @@ import { Button, Stack } from "@mui/material"; import Stopwatch from "../Stopwatch"; import { useCollab } from "../../contexts/CollabContext"; -import { USE_COLLAB_ERROR_MESSAGE } from "../../utils/constants"; +import { + COLLAB_ENDED_MESSAGE, + COLLAB_PARTNER_DISCONNECTED_MESSAGE, + USE_COLLAB_ERROR_MESSAGE, + USE_MATCH_ERROR_MESSAGE, +} from "../../utils/constants"; +import { useEffect, useReducer, useState } from "react"; +import CustomDialog from "../CustomDialog"; +import { + extractMinutesFromTime, + extractSecondsFromTime, +} from "../../utils/sessionTime"; +import { CollabEvents, collabSocket } from "../../utils/collabSocket"; +import { toast } from "react-toastify"; +import reducer, { + getQuestionById, + initialState, +} from "../../reducers/questionReducer"; +import { useMatch } from "../../contexts/MatchContext"; const CollabSessionControls: React.FC = () => { + const match = useMatch(); + if (!match) { + throw new Error(USE_MATCH_ERROR_MESSAGE); + } + + const { questionId } = match; + const collab = useCollab(); if (!collab) { throw new Error(USE_COLLAB_ERROR_MESSAGE); } - const { handleSubmitSessionClick, handleEndSessionClick, time } = collab; + const { + handleSubmitSessionClick, + handleEndSessionClick, + handleConfirmEndSession, + isEndSessionModalOpen, + handleRejectEndSession, + handleExitSession, + isExitSessionModalOpen, + } = collab; + + const [time, setTime] = useState(0); + const [stopTime, setStopTime] = useState(false); + + const [state, dispatch] = useReducer(reducer, initialState); + const { selectedQuestion } = state; + + useEffect(() => { + collabSocket.once(CollabEvents.END_SESSION, (sessionDuration: number) => { + collabSocket.off(CollabEvents.PARTNER_DISCONNECTED); + toast.info(COLLAB_ENDED_MESSAGE); + handleConfirmEndSession( + time, + setTime, + setStopTime, + true, + sessionDuration + ); + }); + + collabSocket.once(CollabEvents.PARTNER_DISCONNECTED, () => { + collabSocket.off(CollabEvents.END_SESSION); + toast.error(COLLAB_PARTNER_DISCONNECTED_MESSAGE); + handleConfirmEndSession(time, setTime, setStopTime, true); + }); + + return () => { + collabSocket.off(CollabEvents.END_SESSION); + collabSocket.off(CollabEvents.PARTNER_DISCONNECTED); + }; + }, []); + + useEffect(() => { + if (stopTime) { + return; + } + + const intervalId = setInterval( + () => setTime((prevTime) => prevTime + 1), + 1000 + ); + + return () => clearInterval(intervalId); + }, [time, stopTime]); + + useEffect(() => { + if (!questionId) { + return; + } + getQuestionById(questionId, dispatch); + }, [questionId]); return ( @@ -21,7 +105,7 @@ const CollabSessionControls: React.FC = () => { }} variant="outlined" color="success" - onClick={() => handleSubmitSessionClick()} + onClick={() => handleSubmitSessionClick(time)} > Submit @@ -38,6 +122,35 @@ const CollabSessionControls: React.FC = () => { > End Session + + Are you sure you want to end the collaboration session? +
+ You will not be able to rejoin. + + } + primaryAction="Confirm" + handlePrimaryAction={() => + handleConfirmEndSession(time, setTime, setStopTime, false) + } + secondaryAction="Cancel" + open={isEndSessionModalOpen} + handleClose={handleRejectEndSession} + /> +
); }; diff --git a/frontend/src/contexts/CollabContext.tsx b/frontend/src/contexts/CollabContext.tsx index d59691fbf9..e81487b71a 100644 --- a/frontend/src/contexts/CollabContext.tsx +++ b/frontend/src/contexts/CollabContext.tsx @@ -1,19 +1,11 @@ /* eslint-disable react-refresh/only-export-components */ -import React, { - createContext, - useContext, - useEffect, - useRef, - useState, -} from "react"; +import React, { createContext, useContext, useState } from "react"; import { USE_MATCH_ERROR_MESSAGE, FAILED_TESTCASE_MESSAGE, SUCCESS_TESTCASE_MESSAGE, FAILED_TO_SUBMIT_CODE_MESSAGE, - COLLAB_PARTNER_DISCONNECTED_MESSAGE, - COLLAB_ENDED_MESSAGE, COLLAB_END_ERROR, } from "../utils/constants"; import { toast } from "react-toastify"; @@ -44,19 +36,20 @@ export type CompilerResult = { }; type CollabContextType = { - handleSubmitSessionClick: () => void; + handleSubmitSessionClick: (time: number) => void; handleEndSessionClick: () => void; handleRejectEndSession: () => void; handleConfirmEndSession: ( + time: number, + setTime: React.Dispatch>, + setStopTime: React.Dispatch>, isInitiatedByPartner: boolean, - time?: number + sessionDuration?: number ) => void; - checkPartnerStatus: () => void; setCode: React.Dispatch>; compilerResult: CompilerResult[]; setCompilerResult: React.Dispatch>; isEndSessionModalOpen: boolean; - time: number; resetCollab: () => void; handleExitSession: () => void; isExitSessionModalOpen: boolean; @@ -94,31 +87,8 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { useState(false); const [isExitSessionModalOpen, setIsExitSessionModalOpen] = useState(false); - const [time, setTime] = useState(0); - const [stopTime, setStopTime] = useState(true); - const timeRef = useRef(time); - const codeRef = useRef(code); - - useEffect(() => { - timeRef.current = time; - codeRef.current = code; - }, [time, code]); - - useEffect(() => { - if (stopTime) { - return; - } - - const intervalId = setInterval( - () => setTime((prevTime) => prevTime + 1), - 1000 - ); - - return () => clearInterval(intervalId); - }, [time, stopTime]); - - const handleSubmitSessionClick = async () => { + const handleSubmitSessionClick = async (time: number) => { try { const res = await codeExecutionClient.post("/", { questionId, @@ -167,8 +137,11 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { }; const handleConfirmEndSession = async ( + time: number, + setTime: React.Dispatch>, + setStopTime: React.Dispatch>, isInitiatedByPartner: boolean, - timeTaken?: number + sessionDuration?: number ) => { setIsEndSessionModalOpen(false); @@ -181,8 +154,8 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { setStopTime(true); setIsExitSessionModalOpen(true); - if (isInitiatedByPartner && timeTaken) { - setTime(timeTaken); + if (isInitiatedByPartner && sessionDuration) { + setTime(sessionDuration); } else { // Get question history const data = await qnHistoryClient.get(qnHistoryId); @@ -194,8 +167,8 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { { submissionStatus: "Attempted", dateAttempted: new Date().toISOString(), - timeTaken: timeRef.current, - code: codeRef.current.replace(/\t/g, " ".repeat(4)), + timeTaken: time, + code: code.replace(/\t/g, " ".repeat(4)), }, qnHistoryDispatch ); @@ -225,24 +198,8 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { resetCollab(); }; - const checkPartnerStatus = () => { - collabSocket.once(CollabEvents.END_SESSION, (timeTaken: number) => { - collabSocket.off(CollabEvents.PARTNER_DISCONNECTED); - toast.info(COLLAB_ENDED_MESSAGE); - handleConfirmEndSession(true, timeTaken); - }); - - collabSocket.once(CollabEvents.PARTNER_DISCONNECTED, () => { - collabSocket.off(CollabEvents.END_SESSION); - toast.error(COLLAB_PARTNER_DISCONNECTED_MESSAGE); - handleConfirmEndSession(true); - }); - }; - const resetCollab = () => { setCompilerResult([]); - setTime(0); - setStopTime(false); }; return ( @@ -252,12 +209,10 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { handleEndSessionClick, handleRejectEndSession, handleConfirmEndSession, - checkPartnerStatus, setCode, compilerResult, setCompilerResult, isEndSessionModalOpen, - time, resetCollab, handleExitSession, isExitSessionModalOpen, diff --git a/frontend/src/pages/CollabSandbox/index.tsx b/frontend/src/pages/CollabSandbox/index.tsx index a334654428..7fa0fbcb2a 100644 --- a/frontend/src/pages/CollabSandbox/index.tsx +++ b/frontend/src/pages/CollabSandbox/index.tsx @@ -22,11 +22,6 @@ import TestCase from "../../components/TestCase"; import CodeEditor from "../../components/CodeEditor"; import { CollabSessionData, join, leave } from "../../utils/collabSocket"; import { toast } from "react-toastify"; -import CustomDialog from "../../components/CustomDialog"; -import { - extractMinutesFromTime, - extractSecondsFromTime, -} from "../../utils/sessionTime"; const CollabSandbox: React.FC = () => { const match = useMatch(); @@ -41,17 +36,7 @@ const CollabSandbox: React.FC = () => { throw new Error(USE_COLLAB_ERROR_MESSAGE); } - const { - compilerResult, - handleRejectEndSession, - handleConfirmEndSession, - checkPartnerStatus, - isEndSessionModalOpen, - resetCollab, - handleExitSession, - isExitSessionModalOpen, - time, - } = collab; + const { compilerResult, resetCollab } = collab; const [state, dispatch] = useReducer(reducer, initialState); const { selectedQuestion } = state; @@ -75,7 +60,6 @@ const CollabSandbox: React.FC = () => { const editorState = await join(matchUser.id, matchId); if (editorState.ready) { setEditorState(editorState); - checkPartnerStatus(); } else { toast.error(COLLAB_CONNECTION_ERROR); setIsConnecting(false); @@ -119,29 +103,6 @@ const CollabSandbox: React.FC = () => { return ( - - Are you sure you want to end the collaboration session? -
- You will not be able to rejoin. - - } - primaryAction="Confirm" - handlePrimaryAction={() => handleConfirmEndSession(false)} - secondaryAction="Cancel" - open={isEndSessionModalOpen} - handleClose={handleRejectEndSession} - /> - Date: Sat, 9 Nov 2024 01:37:37 +0800 Subject: [PATCH 07/10] Fix update qn history bug --- .../src/handlers/websocketHandler.ts | 3 +- .../CollabSessionControls/index.tsx | 24 ++++-- frontend/src/contexts/CollabContext.tsx | 80 ++++++++++++------- frontend/src/utils/constants.ts | 2 + 4 files changed, 72 insertions(+), 37 deletions(-) diff --git a/backend/collab-service/src/handlers/websocketHandler.ts b/backend/collab-service/src/handlers/websocketHandler.ts index d47f81046b..5cf333812f 100644 --- a/backend/collab-service/src/handlers/websocketHandler.ts +++ b/backend/collab-service/src/handlers/websocketHandler.ts @@ -17,6 +17,7 @@ enum CollabEvents { // Send ROOM_READY = "room_ready", + DOCUMENT_READY = "document_ready", UPDATE = "updateV2", UPDATE_CURSOR = "update_cursor", END_SESSION = "end_session", @@ -31,7 +32,7 @@ const collabSessions = new Map(); const partnerReadiness = new Map(); export const handleWebsocketCollabEvents = (socket: Socket) => { - socket.on(CollabEvents.JOIN, async (uid: string, roomId: string) => { + socket.on(CollabEvents.JOIN, (uid: string, roomId: string) => { const connectionKey = `${uid}:${roomId}`; if (userConnections.has(connectionKey)) { clearTimeout(userConnections.get(connectionKey)!); diff --git a/frontend/src/components/CollabSessionControls/index.tsx b/frontend/src/components/CollabSessionControls/index.tsx index 4fcf0b0671..680d9695af 100644 --- a/frontend/src/components/CollabSessionControls/index.tsx +++ b/frontend/src/components/CollabSessionControls/index.tsx @@ -7,7 +7,7 @@ import { USE_COLLAB_ERROR_MESSAGE, USE_MATCH_ERROR_MESSAGE, } from "../../utils/constants"; -import { useEffect, useReducer, useState } from "react"; +import { useEffect, useReducer, useRef, useState } from "react"; import CustomDialog from "../CustomDialog"; import { extractMinutesFromTime, @@ -42,10 +42,12 @@ const CollabSessionControls: React.FC = () => { handleRejectEndSession, handleExitSession, isExitSessionModalOpen, + qnHistoryId, } = collab; const [time, setTime] = useState(0); - const [stopTime, setStopTime] = useState(false); + const [stopTime, setStopTime] = useState(true); + const timeRef = useRef(time); const [state, dispatch] = useReducer(reducer, initialState); const { selectedQuestion } = state; @@ -55,7 +57,7 @@ const CollabSessionControls: React.FC = () => { collabSocket.off(CollabEvents.PARTNER_DISCONNECTED); toast.info(COLLAB_ENDED_MESSAGE); handleConfirmEndSession( - time, + timeRef.current, setTime, setStopTime, true, @@ -66,7 +68,7 @@ const CollabSessionControls: React.FC = () => { collabSocket.once(CollabEvents.PARTNER_DISCONNECTED, () => { collabSocket.off(CollabEvents.END_SESSION); toast.error(COLLAB_PARTNER_DISCONNECTED_MESSAGE); - handleConfirmEndSession(time, setTime, setStopTime, true); + handleConfirmEndSession(timeRef.current, setTime, setStopTime, true); }); return () => { @@ -76,6 +78,8 @@ const CollabSessionControls: React.FC = () => { }, []); useEffect(() => { + timeRef.current = time; + if (stopTime) { return; } @@ -88,6 +92,12 @@ const CollabSessionControls: React.FC = () => { return () => clearInterval(intervalId); }, [time, stopTime]); + useEffect(() => { + if (qnHistoryId) { + setStopTime(false); + } + }, [qnHistoryId]); + useEffect(() => { if (!questionId) { return; @@ -105,7 +115,8 @@ const CollabSessionControls: React.FC = () => { }} variant="outlined" color="success" - onClick={() => handleSubmitSessionClick(time)} + onClick={() => handleSubmitSessionClick(timeRef.current)} + disabled={stopTime} > Submit @@ -119,6 +130,7 @@ const CollabSessionControls: React.FC = () => { onClick={() => { handleEndSessionClick(); }} + disabled={stopTime} > End Session @@ -133,7 +145,7 @@ const CollabSessionControls: React.FC = () => { } primaryAction="Confirm" handlePrimaryAction={() => - handleConfirmEndSession(time, setTime, setStopTime, false) + handleConfirmEndSession(timeRef.current, setTime, setStopTime, false) } secondaryAction="Cancel" open={isEndSessionModalOpen} diff --git a/frontend/src/contexts/CollabContext.tsx b/frontend/src/contexts/CollabContext.tsx index b9585b6462..83ff9197fc 100644 --- a/frontend/src/contexts/CollabContext.tsx +++ b/frontend/src/contexts/CollabContext.tsx @@ -1,17 +1,24 @@ /* eslint-disable react-refresh/only-export-components */ -import React, { createContext, useContext, useState } from "react"; +import React, { + createContext, + useContext, + useEffect, + useRef, + useState, +} from "react"; import { USE_MATCH_ERROR_MESSAGE, FAILED_TESTCASE_MESSAGE, SUCCESS_TESTCASE_MESSAGE, FAILED_TO_SUBMIT_CODE_MESSAGE, COLLAB_END_ERROR, + COLLAB_SUBMIT_ERROR, } from "../utils/constants"; import { toast } from "react-toastify"; import { useMatch } from "./MatchContext"; -import { codeExecutionClient } from "../utils/api"; +import { codeExecutionClient, qnHistoryClient } from "../utils/api"; import { useReducer } from "react"; import { updateQnHistoryById } from "../reducers/qnHistoryReducer"; import qnHistoryReducer, { initialQHState } from "../reducers/qnHistoryReducer"; @@ -54,6 +61,7 @@ type CollabContextType = { checkDocReady: () => void; handleExitSession: () => void; isExitSessionModalOpen: boolean; + qnHistoryId: string | null; }; const CollabContext = createContext(null); @@ -82,7 +90,17 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { const [isExitSessionModalOpen, setIsExitSessionModalOpen] = useState(false); const [qnHistoryId, setQnHistoryId] = useState(null); - const [hasSubmitted, setHasSubmitted] = useState(false); + + const codeRef = useRef(code); + const qnHistoryIdRef = useRef(qnHistoryId); + + useEffect(() => { + codeRef.current = code; + }, [code]); + + useEffect(() => { + qnHistoryIdRef.current = qnHistoryId; + }, [qnHistoryId]); const handleSubmitSessionClick = async (time: number) => { try { @@ -92,8 +110,6 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { code: code.replace(/\t/g, " ".repeat(4)), language: matchCriteria?.language.toLowerCase(), }); - setHasSubmitted(true); - console.log([...res.data.data]); setCompilerResult([...res.data.data]); let isMatch = true; @@ -110,13 +126,18 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { toast.error(FAILED_TESTCASE_MESSAGE); } + if (!qnHistoryIdRef.current) { + toast.error(COLLAB_SUBMIT_ERROR); + return; + } + updateQnHistoryById( - qnHistoryId as string, + qnHistoryIdRef.current, { submissionStatus: isMatch ? "Accepted" : "Rejected", dateAttempted: new Date().toISOString(), timeTaken: time, - code, + code: codeRef.current, }, qnHistoryDispatch ); @@ -143,7 +164,7 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { setIsEndSessionModalOpen(false); const roomId = getMatchId(); - if (!matchUser || !roomId || !qnHistoryId) { + if (!matchUser || !roomId || !qnHistoryIdRef.current) { toast.error(COLLAB_END_ERROR); return; } @@ -155,20 +176,26 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { setTime(sessionDuration); } else { // Get question history - const data = await qnHistoryClient.get(qnHistoryId); + const data = await qnHistoryClient.get(qnHistoryIdRef.current); + + // Only update question history if it has not been submitted before + if (data.data.qnHistory.timeTaken === 0) { + updateQnHistoryById( + qnHistoryIdRef.current, + { + submissionStatus: "Attempted", + dateAttempted: new Date().toISOString(), + timeTaken: time, + code: codeRef.current.replace(/\t/g, " ".repeat(4)), + }, + qnHistoryDispatch + ); + } + } - // Only update question history if it has not been submitted before - if (!hasSubmitted) { - updateQnHistoryById( - qnHistoryId as string, - { - submissionStatus: "Attempted", - dateAttempted: new Date().toISOString(), - timeTaken: time, - code: code.replace(/\t/g, " ".repeat(4)), - }, - qnHistoryDispatch - ); + if (!isInitiatedByPartner) { + // Notify partner + collabSocket.emit(CollabEvents.END_SESSION_REQUEST, roomId, time); } // Leave collaboration room @@ -195,17 +222,9 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { }); }; - const checkPartnerStatus = () => { - collabSocket.on(CollabEvents.PARTNER_LEFT, () => { - toast.error(COLLAB_ENDED_MESSAGE); - setIsEndSessionModalOpen(false); - stopMatch(); - appNavigate("/home"); - }); - }; - const resetCollab = () => { setCompilerResult([]); + setQnHistoryId(null); }; return ( @@ -223,6 +242,7 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { checkDocReady, handleExitSession, isExitSessionModalOpen, + qnHistoryId, }} > {children} diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts index 87370bb819..4fd41b3d31 100644 --- a/frontend/src/utils/constants.ts +++ b/frontend/src/utils/constants.ts @@ -111,6 +111,8 @@ export const COLLAB_CONNECTION_ERROR = "Error connecting you to the collaboration session! Please try again."; export const COLLAB_END_ERROR = "Error ending the collaboration session! Please try again."; +export const COLLAB_SUBMIT_ERROR = + "Error submitting your attempt! Please try again."; // Code execution export const FAILED_TESTCASE_MESSAGE = From 683f032810ea1e399f611e70f889a7138a1f0b88 Mon Sep 17 00:00:00 2001 From: Tin Ruiqi Date: Sat, 9 Nov 2024 11:59:04 +0800 Subject: [PATCH 08/10] Get doc from redis if not found on server --- .../src/handlers/websocketHandler.ts | 54 ++++++++++++------- frontend/src/components/CodeEditor/index.tsx | 39 ++++++++++---- frontend/src/contexts/CollabContext.tsx | 33 +++++++++++- frontend/src/utils/collabSocket.ts | 7 ++- frontend/src/utils/constants.ts | 4 ++ 5 files changed, 102 insertions(+), 35 deletions(-) diff --git a/backend/collab-service/src/handlers/websocketHandler.ts b/backend/collab-service/src/handlers/websocketHandler.ts index 5cf333812f..26c764a7d1 100644 --- a/backend/collab-service/src/handlers/websocketHandler.ts +++ b/backend/collab-service/src/handlers/websocketHandler.ts @@ -18,6 +18,7 @@ enum CollabEvents { // Send ROOM_READY = "room_ready", DOCUMENT_READY = "document_ready", + DOCUMENT_NOT_FOUND = "document_not_found", UPDATE = "updateV2", UPDATE_CURSOR = "update_cursor", END_SESSION = "end_session", @@ -109,7 +110,8 @@ export const handleWebsocketCollabEvents = (socket: Socket) => { if (doc) { applyUpdateV2(doc, new Uint8Array(update)); } else { - // TODO: error handling + io.to(roomId).emit(CollabEvents.DOCUMENT_NOT_FOUND); + io.sockets.adapter.rooms.delete(roomId); } } ); @@ -153,24 +155,18 @@ export const handleWebsocketCollabEvents = (socket: Socket) => { } ); - socket.on(CollabEvents.RECONNECT_REQUEST, async (roomId: string) => { - // TODO: Handle recconnection - socket.join(roomId); - - const doc = getDocument(roomId); - const storeData = await redisClient.get(`collaboration:${roomId}`); - - if (storeData) { - const tempDoc = new Doc(); - const update = Buffer.from(storeData, "base64"); - applyUpdateV2(tempDoc, new Uint8Array(update)); - const tempText = tempDoc.getText().toString(); + socket.on(CollabEvents.RECONNECT_REQUEST, (roomId: string) => { + const room = io.sockets.adapter.rooms.get(roomId); + if (!room || room.size < 2) { + socket.join(roomId); + socket.data.roomId = roomId; + } - const text = doc.getText(); - doc.transact(() => { - text.delete(0, text.length); - text.insert(0, tempText); - }); + if ( + io.sockets.adapter.rooms.get(roomId)?.size === 2 && + !collabSessions.has(roomId) + ) { + restoreDocument(roomId); } }); }; @@ -201,14 +197,32 @@ const getDocument = (roomId: string) => { return doc; }; -const saveDocument = async (roomId: string, doc: Doc) => { +const saveDocument = (roomId: string, doc: Doc) => { const docState = encodeStateAsUpdateV2(doc); const docAsString = Buffer.from(docState).toString("base64"); - await redisClient.set(`collaboration:${roomId}`, docAsString, { + redisClient.set(`collaboration:${roomId}`, docAsString, { EX: EXPIRY_TIME, }); }; +const restoreDocument = async (roomId: string) => { + const doc = getDocument(roomId); + const storeData = await redisClient.get(`collaboration:${roomId}`); + + if (storeData) { + const tempDoc = new Doc(); + const update = Buffer.from(storeData, "base64"); + applyUpdateV2(tempDoc, new Uint8Array(update)); + const tempText = tempDoc.getText().toString(); + + const text = doc.getText(); + doc.transact(() => { + text.delete(0, text.length); + text.insert(0, tempText); + }); + } +}; + const handleUserLeave = (uid: string, roomId: string, socket: Socket) => { const connectionKey = `${uid}:${roomId}`; userConnections.delete(connectionKey); diff --git a/frontend/src/components/CodeEditor/index.tsx b/frontend/src/components/CodeEditor/index.tsx index 3fb1b3678f..f8419e6886 100644 --- a/frontend/src/components/CodeEditor/index.tsx +++ b/frontend/src/components/CodeEditor/index.tsx @@ -8,14 +8,17 @@ import { useEffect, useState } from "react"; import { initDocument } from "../../utils/collabSocket"; import { cursorExtension } from "../../utils/collabCursor"; import { yCollab } from "y-codemirror.next"; -import { Text } from "yjs"; +import { Doc, Text } from "yjs"; import { Awareness } from "y-protocols/awareness"; import { useCollab } from "../../contexts/CollabContext"; -import { USE_COLLAB_ERROR_MESSAGE, USE_MATCH_ERROR_MESSAGE } from "../../utils/constants"; +import { + USE_COLLAB_ERROR_MESSAGE, + USE_MATCH_ERROR_MESSAGE, +} from "../../utils/constants"; import { useMatch } from "../../contexts/MatchContext"; interface CodeEditorProps { - editorState?: { text: Text; awareness: Awareness }; + editorState?: { doc: Doc; text: Text; awareness: Awareness }; uid?: string; username?: string; language: string; @@ -46,7 +49,8 @@ const CodeEditor: React.FC = (props) => { throw new Error(USE_MATCH_ERROR_MESSAGE); } - const { matchCriteria, matchUser, partner, questionId, questionTitle } = match; + const { matchCriteria, matchUser, partner, questionId, questionTitle } = + match; const collab = useCollab(); if (!collab) { @@ -69,14 +73,29 @@ const CodeEditor: React.FC = (props) => { }; useEffect(() => { - if (isReadOnly || !isEditorReady) { + if (isReadOnly || !isEditorReady || !editorState) { return; } const loadTemplate = async () => { - if (matchUser && partner && matchCriteria && questionId && questionTitle) { - checkDocReady(); - await initDocument(uid, roomId, template, matchUser.id, partner.id, matchCriteria.language, questionId, questionTitle); + if ( + matchUser && + partner && + matchCriteria && + questionId && + questionTitle + ) { + checkDocReady(roomId, editorState.doc, setIsDocumentLoaded); + await initDocument( + uid, + roomId, + template, + matchUser.id, + partner.id, + matchCriteria.language, + questionId, + questionTitle + ); setIsDocumentLoaded(true); } }; @@ -109,9 +128,7 @@ const CodeEditor: React.FC = (props) => { ]} value={isReadOnly ? template : undefined} placeholder={ - !isReadOnly && !isDocumentLoaded - ? "Loading code template..." - : undefined + !isReadOnly && !isDocumentLoaded ? "Loading the code..." : undefined } /> ); diff --git a/frontend/src/contexts/CollabContext.tsx b/frontend/src/contexts/CollabContext.tsx index 83ff9197fc..e631c738d4 100644 --- a/frontend/src/contexts/CollabContext.tsx +++ b/frontend/src/contexts/CollabContext.tsx @@ -14,6 +14,8 @@ import { FAILED_TO_SUBMIT_CODE_MESSAGE, COLLAB_END_ERROR, COLLAB_SUBMIT_ERROR, + COLLAB_DOCUMENT_ERROR, + COLLAB_DOCUMENT_RESTORED, } from "../utils/constants"; import { toast } from "react-toastify"; @@ -28,6 +30,7 @@ import { communicationSocket, } from "../utils/communicationSocket"; import useAppNavigate from "../hooks/useAppNavigate"; +import { applyUpdateV2, Doc } from "yjs"; export type CompilerResult = { status: string; @@ -58,7 +61,11 @@ type CollabContextType = { setCompilerResult: React.Dispatch>; isEndSessionModalOpen: boolean; resetCollab: () => void; - checkDocReady: () => void; + checkDocReady: ( + roomId: string, + doc: Doc, + setIsDocumentLoaded: React.Dispatch> + ) => void; handleExitSession: () => void; isExitSessionModalOpen: boolean; qnHistoryId: string | null; @@ -216,10 +223,32 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { resetCollab(); }; - const checkDocReady = () => { + const checkDocReady = ( + roomId: string, + doc: Doc, + setIsDocumentLoaded: React.Dispatch> + ) => { collabSocket.on(CollabEvents.DOCUMENT_READY, (qnHistoryId: string) => { setQnHistoryId(qnHistoryId); }); + + collabSocket.on(CollabEvents.DOCUMENT_NOT_FOUND, () => { + toast.error(COLLAB_DOCUMENT_ERROR); + setIsDocumentLoaded(false); + + const text = doc.getText(); + doc.transact(() => { + text.delete(0, text.length); + }, matchUser?.id); + + collabSocket.once(CollabEvents.UPDATE, (update) => { + applyUpdateV2(doc, new Uint8Array(update), matchUser?.id); + toast.success(COLLAB_DOCUMENT_RESTORED); + setIsDocumentLoaded(true); + }); + + collabSocket.emit(CollabEvents.RECONNECT_REQUEST, roomId); + }); }; const resetCollab = () => { diff --git a/frontend/src/utils/collabSocket.ts b/frontend/src/utils/collabSocket.ts index 82e4afe295..f5d997ee15 100644 --- a/frontend/src/utils/collabSocket.ts +++ b/frontend/src/utils/collabSocket.ts @@ -18,10 +18,12 @@ export enum CollabEvents { // Receive ROOM_READY = "room_ready", DOCUMENT_READY = "document_ready", + DOCUMENT_NOT_FOUND = "document_not_found", UPDATE = "updateV2", UPDATE_CURSOR = "update_cursor", END_SESSION = "end_session", PARTNER_DISCONNECTED = "partner_disconnected", + SOCKET_DISCONNECT = "disconnect", SOCKET_CLIENT_DISCONNECT = "io client disconnect", SOCKET_SERVER_DISCONNECT = "io server disconnect", @@ -31,6 +33,7 @@ export enum CollabEvents { export type CollabSessionData = { ready: boolean; + doc: Doc; text: Text; awareness: Awareness; }; @@ -61,7 +64,7 @@ export const join = ( awareness = new Awareness(doc); doc.on(CollabEvents.UPDATE, (update, origin) => { - if (origin != uid) { + if (origin !== uid) { collabSocket.emit(CollabEvents.UPDATE_REQUEST, roomId, update); } }); @@ -74,7 +77,7 @@ export const join = ( return new Promise((resolve) => { collabSocket.once(CollabEvents.ROOM_READY, (ready: boolean) => { - resolve({ ready: ready, text: text, awareness: awareness }); + resolve({ ready: ready, doc: doc, text: text, awareness: awareness }); }); }); }; diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts index 4fd41b3d31..c34e63b7cd 100644 --- a/frontend/src/utils/constants.ts +++ b/frontend/src/utils/constants.ts @@ -113,6 +113,10 @@ export const COLLAB_END_ERROR = "Error ending the collaboration session! Please try again."; export const COLLAB_SUBMIT_ERROR = "Error submitting your attempt! Please try again."; +export const COLLAB_DOCUMENT_ERROR = + "Error syncing the code! Please wait as we try to reconnect. Recent changes may be lost."; +export const COLLAB_DOCUMENT_RESTORED = + "Connection restored! You may resume editing the code."; // Code execution export const FAILED_TESTCASE_MESSAGE = From d7aaa902128cdaeb13bbc53f87337220d8bf0f60 Mon Sep 17 00:00:00 2001 From: Tin Ruiqi Date: Sat, 9 Nov 2024 13:19:48 +0800 Subject: [PATCH 09/10] Handle collab end on client refresh --- .../src/handlers/websocketHandler.ts | 10 +- .../src/handlers/websocketHandler.ts | 35 ++++--- frontend/src/components/CodeEditor/index.tsx | 2 +- frontend/src/contexts/CollabContext.tsx | 91 ++++++++++++++----- frontend/src/contexts/MatchContext.tsx | 7 +- frontend/src/pages/CollabSandbox/index.tsx | 2 + frontend/src/utils/collabSocket.ts | 33 +------ frontend/src/utils/constants.ts | 6 +- 8 files changed, 99 insertions(+), 87 deletions(-) diff --git a/backend/collab-service/src/handlers/websocketHandler.ts b/backend/collab-service/src/handlers/websocketHandler.ts index 26c764a7d1..3a049e2d18 100644 --- a/backend/collab-service/src/handlers/websocketHandler.ts +++ b/backend/collab-service/src/handlers/websocketHandler.ts @@ -37,7 +37,6 @@ export const handleWebsocketCollabEvents = (socket: Socket) => { const connectionKey = `${uid}:${roomId}`; if (userConnections.has(connectionKey)) { clearTimeout(userConnections.get(connectionKey)!); - return; } userConnections.set(connectionKey, null); @@ -50,11 +49,10 @@ export const handleWebsocketCollabEvents = (socket: Socket) => { socket.join(roomId); socket.data.roomId = roomId; - if ( - io.sockets.adapter.rooms.get(roomId)?.size === 2 && - !collabSessions.has(roomId) - ) { - createCollabSession(roomId); + if (io.sockets.adapter.rooms.get(roomId)?.size === 2) { + if (!collabSessions.has(roomId)) { + createCollabSession(roomId); + } io.to(roomId).emit(CollabEvents.ROOM_READY, true); } }); diff --git a/backend/matching-service/src/handlers/websocketHandler.ts b/backend/matching-service/src/handlers/websocketHandler.ts index a2065f7bbc..92f9861836 100644 --- a/backend/matching-service/src/handlers/websocketHandler.ts +++ b/backend/matching-service/src/handlers/websocketHandler.ts @@ -119,27 +119,24 @@ export const handleWebsocketMatchEvents = (socket: Socket) => { userConnections.delete(uid); }); - socket.on( - MatchEvents.MATCH_ACCEPT_REQUEST, - (matchId: string, userId1: string, userId2: string) => { - const partnerAccepted = handleMatchAccept(matchId); - if (partnerAccepted) { - const match = getMatchById(matchId); - if (!match) { - return; - } - - const { complexity, category } = match; - getRandomQuestion(complexity, category).then((res) => { - io.to(matchId).emit( - MatchEvents.MATCH_SUCCESSFUL, - res.data.question.id, - res.data.question.title - ); - }); + socket.on(MatchEvents.MATCH_ACCEPT_REQUEST, (matchId: string) => { + const partnerAccepted = handleMatchAccept(matchId); + if (partnerAccepted) { + const match = getMatchById(matchId); + if (!match) { + return; } + + const { complexity, category } = match; + getRandomQuestion(complexity, category).then((res) => { + io.to(matchId).emit( + MatchEvents.MATCH_SUCCESSFUL, + res.data.question.id, + res.data.question.title + ); + }); } - ); + }); socket.on( MatchEvents.MATCH_DECLINE_REQUEST, diff --git a/frontend/src/components/CodeEditor/index.tsx b/frontend/src/components/CodeEditor/index.tsx index f8419e6886..b21c4f142b 100644 --- a/frontend/src/components/CodeEditor/index.tsx +++ b/frontend/src/components/CodeEditor/index.tsx @@ -101,7 +101,7 @@ const CodeEditor: React.FC = (props) => { }; loadTemplate(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isReadOnly, isEditorReady]); + }, [isReadOnly, isEditorReady, editorState]); return ( = (props) => { const roomId = getMatchId(); if (!matchUser || !roomId || !qnHistoryIdRef.current) { toast.error(COLLAB_END_ERROR); + appNavigate("/home"); return; } @@ -213,8 +215,6 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { }; const handleExitSession = () => { - setIsExitSessionModalOpen(false); - // Delete match data stopMatch(); appNavigate("/home"); @@ -228,31 +228,80 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { doc: Doc, setIsDocumentLoaded: React.Dispatch> ) => { - collabSocket.on(CollabEvents.DOCUMENT_READY, (qnHistoryId: string) => { - setQnHistoryId(qnHistoryId); - }); - - collabSocket.on(CollabEvents.DOCUMENT_NOT_FOUND, () => { - toast.error(COLLAB_DOCUMENT_ERROR); - setIsDocumentLoaded(false); - - const text = doc.getText(); - doc.transact(() => { - text.delete(0, text.length); - }, matchUser?.id); - - collabSocket.once(CollabEvents.UPDATE, (update) => { - applyUpdateV2(doc, new Uint8Array(update), matchUser?.id); - toast.success(COLLAB_DOCUMENT_RESTORED); - setIsDocumentLoaded(true); + if (!collabSocket.hasListeners(CollabEvents.DOCUMENT_READY)) { + collabSocket.on(CollabEvents.DOCUMENT_READY, (qnHistoryId: string) => { + setQnHistoryId(qnHistoryId); }); + } + + if (!collabSocket.hasListeners(CollabEvents.DOCUMENT_NOT_FOUND)) { + collabSocket.on(CollabEvents.DOCUMENT_NOT_FOUND, () => { + toast.error(COLLAB_DOCUMENT_ERROR); + setIsDocumentLoaded(false); + + const text = doc.getText(); + doc.transact(() => { + text.delete(0, text.length); + }, matchUser?.id); + + collabSocket.once(CollabEvents.UPDATE, (update) => { + applyUpdateV2(doc, new Uint8Array(update), matchUser?.id); + toast.success(COLLAB_DOCUMENT_RESTORED); + setIsDocumentLoaded(true); + }); + + collabSocket.emit(CollabEvents.RECONNECT_REQUEST, roomId); + }); + } + + if (!collabSocket.hasListeners(CollabEvents.SOCKET_DISCONNECT)) { + collabSocket.on(CollabEvents.SOCKET_DISCONNECT, (reason) => { + console.log(reason); + if ( + reason !== CollabEvents.SOCKET_CLIENT_DISCONNECT && + reason !== CollabEvents.SOCKET_SERVER_DISCONNECT + ) { + toast.error(COLLAB_DOCUMENT_ERROR); + setIsDocumentLoaded(false); + } + }); + } - collabSocket.emit(CollabEvents.RECONNECT_REQUEST, roomId); - }); + if (!collabSocket.io.hasListeners(CollabEvents.SOCKET_RECONNECT_SUCCESS)) { + collabSocket.io.on(CollabEvents.SOCKET_RECONNECT_SUCCESS, () => { + const text = doc.getText(); + doc.transact(() => { + text.delete(0, text.length); + }, matchUser?.id); + + collabSocket.once(CollabEvents.UPDATE, (update) => { + applyUpdateV2(doc, new Uint8Array(update), matchUser?.id); + toast.success(COLLAB_DOCUMENT_RESTORED); + setIsDocumentLoaded(true); + }); + + collabSocket.emit(CollabEvents.RECONNECT_REQUEST, roomId); + }); + } + + if (!collabSocket.io.hasListeners(CollabEvents.SOCKET_RECONNECT_FAILED)) { + collabSocket.io.on(CollabEvents.SOCKET_RECONNECT_FAILED, () => { + toast.error(COLLAB_RECONNECTION_ERROR); + + if (matchUser) { + leave(matchUser.id, roomId, true); + } + communicationSocket.emit(CommunicationEvents.USER_DISCONNECT); + + handleExitSession(); + }); + } }; const resetCollab = () => { setCompilerResult([]); + setIsEndSessionModalOpen(false); + setIsExitSessionModalOpen(false); setQnHistoryId(null); }; diff --git a/frontend/src/contexts/MatchContext.tsx b/frontend/src/contexts/MatchContext.tsx index e2651d5f5d..32b0e2dfb9 100644 --- a/frontend/src/contexts/MatchContext.tsx +++ b/frontend/src/contexts/MatchContext.tsx @@ -391,12 +391,7 @@ const MatchProvider: React.FC<{ children?: React.ReactNode }> = (props) => { return; } - matchSocket.emit( - MatchEvents.MATCH_ACCEPT_REQUEST, - matchId, - matchUser.id, - partner.id - ); + matchSocket.emit(MatchEvents.MATCH_ACCEPT_REQUEST, matchId); }; const rematch = () => { diff --git a/frontend/src/pages/CollabSandbox/index.tsx b/frontend/src/pages/CollabSandbox/index.tsx index 7fa0fbcb2a..6deb92e314 100644 --- a/frontend/src/pages/CollabSandbox/index.tsx +++ b/frontend/src/pages/CollabSandbox/index.tsx @@ -52,6 +52,8 @@ const CollabSandbox: React.FC = () => { resetCollab(); if (!matchUser || !matchId) { + toast.error(COLLAB_CONNECTION_ERROR); + setIsConnecting(false); return; } diff --git a/frontend/src/utils/collabSocket.ts b/frontend/src/utils/collabSocket.ts index f5d997ee15..c0f2d72bf9 100644 --- a/frontend/src/utils/collabSocket.ts +++ b/frontend/src/utils/collabSocket.ts @@ -41,7 +41,7 @@ export type CollabSessionData = { const COLLAB_SOCKET_URL = "http://localhost:3003"; export const collabSocket = io(COLLAB_SOCKET_URL, { - reconnectionAttempts: 3, + reconnectionAttempts: 5, autoConnect: false, auth: { token: getToken(), @@ -57,7 +57,6 @@ export const join = ( roomId: string ): Promise => { collabSocket.connect(); - initConnectionStatusListeners(roomId); doc = new Doc(); text = doc.getText(); @@ -141,33 +140,3 @@ export const receiveCursorUpdate = (view: EditorView) => { }); }); }; - -export const reconnectRequest = (roomId: string) => { - collabSocket.emit(CollabEvents.RECONNECT_REQUEST, roomId); -}; - -const initConnectionStatusListeners = (roomId: string) => { - if (!collabSocket.hasListeners(CollabEvents.SOCKET_DISCONNECT)) { - collabSocket.on(CollabEvents.SOCKET_DISCONNECT, (reason) => { - if ( - reason !== CollabEvents.SOCKET_CLIENT_DISCONNECT && - reason !== CollabEvents.SOCKET_SERVER_DISCONNECT - ) { - // TODO: Handle socket disconnection - } - }); - } - - if (!collabSocket.io.hasListeners(CollabEvents.SOCKET_RECONNECT_SUCCESS)) { - collabSocket.io.on(CollabEvents.SOCKET_RECONNECT_SUCCESS, () => { - console.log("reconnect request"); - collabSocket.emit(CollabEvents.RECONNECT_REQUEST, roomId); - }); - } - - if (!collabSocket.io.hasListeners(CollabEvents.SOCKET_RECONNECT_FAILED)) { - collabSocket.io.on(CollabEvents.SOCKET_RECONNECT_FAILED, () => { - console.log("reconnect failed"); - }); - } -}; diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts index c34e63b7cd..0cdb7441c0 100644 --- a/frontend/src/utils/constants.ts +++ b/frontend/src/utils/constants.ts @@ -108,9 +108,11 @@ export const COLLAB_ENDED_MESSAGE = export const COLLAB_PARTNER_DISCONNECTED_MESSAGE = "Unfortunately, the collaboration session has ended as your partner has disconnected."; export const COLLAB_CONNECTION_ERROR = - "Error connecting you to the collaboration session! Please try again."; + "Error connecting you to the collaboration session! Please find another match."; +export const COLLAB_RECONNECTION_ERROR = + "Error reconnecting you to the collaboration session! Closing the session..."; export const COLLAB_END_ERROR = - "Error ending the collaboration session! Please try again."; + "Something went wrong! Forcefully ending the session..."; export const COLLAB_SUBMIT_ERROR = "Error submitting your attempt! Please try again."; export const COLLAB_DOCUMENT_ERROR = From 77e231aa85255a4c7ce1755b215cd128d4d00bfd Mon Sep 17 00:00:00 2001 From: Tin Ruiqi Date: Sat, 9 Nov 2024 14:21:41 +0800 Subject: [PATCH 10/10] Prevent code editor from re-rendering on code change --- frontend/src/components/CodeEditor/index.tsx | 7 +--- .../CollabSessionControls/index.tsx | 17 +++------ frontend/src/contexts/CollabContext.tsx | 37 +++++++++++-------- frontend/src/utils/collabSocket.ts | 6 +++ 4 files changed, 35 insertions(+), 32 deletions(-) diff --git a/frontend/src/components/CodeEditor/index.tsx b/frontend/src/components/CodeEditor/index.tsx index b21c4f142b..f74b69aa80 100644 --- a/frontend/src/components/CodeEditor/index.tsx +++ b/frontend/src/components/CodeEditor/index.tsx @@ -57,7 +57,7 @@ const CodeEditor: React.FC = (props) => { throw new Error(USE_COLLAB_ERROR_MESSAGE); } - const { setCode, checkDocReady } = collab; + const { checkDocReady } = collab; const [isEditorReady, setIsEditorReady] = useState(false); const [isDocumentLoaded, setIsDocumentLoaded] = useState(false); @@ -68,10 +68,6 @@ const CodeEditor: React.FC = (props) => { } }; - const handleChange = (value: string) => { - setCode(value); - }; - useEffect(() => { if (isReadOnly || !isEditorReady || !editorState) { return; @@ -111,7 +107,6 @@ const CodeEditor: React.FC = (props) => { width="100%" basicSetup={false} id="codeEditor" - onChange={handleChange} extensions={[ indentUnit.of("\t"), basicSetup(), diff --git a/frontend/src/components/CollabSessionControls/index.tsx b/frontend/src/components/CollabSessionControls/index.tsx index 680d9695af..4c0547e9cf 100644 --- a/frontend/src/components/CollabSessionControls/index.tsx +++ b/frontend/src/components/CollabSessionControls/index.tsx @@ -43,10 +43,11 @@ const CollabSessionControls: React.FC = () => { handleExitSession, isExitSessionModalOpen, qnHistoryId, + stopTime, + setStopTime, } = collab; const [time, setTime] = useState(0); - const [stopTime, setStopTime] = useState(true); const timeRef = useRef(time); const [state, dispatch] = useReducer(reducer, initialState); @@ -56,19 +57,13 @@ const CollabSessionControls: React.FC = () => { collabSocket.once(CollabEvents.END_SESSION, (sessionDuration: number) => { collabSocket.off(CollabEvents.PARTNER_DISCONNECTED); toast.info(COLLAB_ENDED_MESSAGE); - handleConfirmEndSession( - timeRef.current, - setTime, - setStopTime, - true, - sessionDuration - ); + handleConfirmEndSession(timeRef.current, setTime, true, sessionDuration); }); collabSocket.once(CollabEvents.PARTNER_DISCONNECTED, () => { collabSocket.off(CollabEvents.END_SESSION); toast.error(COLLAB_PARTNER_DISCONNECTED_MESSAGE); - handleConfirmEndSession(timeRef.current, setTime, setStopTime, true); + handleConfirmEndSession(timeRef.current, setTime, true); }); return () => { @@ -115,7 +110,7 @@ const CollabSessionControls: React.FC = () => { }} variant="outlined" color="success" - onClick={() => handleSubmitSessionClick(timeRef.current)} + onClick={() => handleSubmitSessionClick(time)} disabled={stopTime} > Submit @@ -145,7 +140,7 @@ const CollabSessionControls: React.FC = () => { } primaryAction="Confirm" handlePrimaryAction={() => - handleConfirmEndSession(timeRef.current, setTime, setStopTime, false) + handleConfirmEndSession(time, setTime, false) } secondaryAction="Cancel" open={isEndSessionModalOpen} diff --git a/frontend/src/contexts/CollabContext.tsx b/frontend/src/contexts/CollabContext.tsx index eae4b600d5..3167b81b4d 100644 --- a/frontend/src/contexts/CollabContext.tsx +++ b/frontend/src/contexts/CollabContext.tsx @@ -25,7 +25,12 @@ import { codeExecutionClient, qnHistoryClient } from "../utils/api"; import { useReducer } from "react"; import { updateQnHistoryById } from "../reducers/qnHistoryReducer"; import qnHistoryReducer, { initialQHState } from "../reducers/qnHistoryReducer"; -import { CollabEvents, collabSocket, leave } from "../utils/collabSocket"; +import { + CollabEvents, + collabSocket, + getDocContent, + leave, +} from "../utils/collabSocket"; import { CommunicationEvents, communicationSocket, @@ -53,11 +58,9 @@ type CollabContextType = { handleConfirmEndSession: ( time: number, setTime: React.Dispatch>, - setStopTime: React.Dispatch>, isInitiatedByPartner: boolean, sessionDuration?: number ) => void; - setCode: React.Dispatch>; compilerResult: CompilerResult[]; setCompilerResult: React.Dispatch>; isEndSessionModalOpen: boolean; @@ -70,6 +73,8 @@ type CollabContextType = { handleExitSession: () => void; isExitSessionModalOpen: boolean; qnHistoryId: string | null; + stopTime: boolean; + setStopTime: React.Dispatch>; }; const CollabContext = createContext(null); @@ -91,31 +96,27 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { qnHistoryReducer, initialQHState ); - const [code, setCode] = useState(""); + const [compilerResult, setCompilerResult] = useState([]); const [isEndSessionModalOpen, setIsEndSessionModalOpen] = useState(false); const [isExitSessionModalOpen, setIsExitSessionModalOpen] = useState(false); const [qnHistoryId, setQnHistoryId] = useState(null); + const [stopTime, setStopTime] = useState(true); - const codeRef = useRef(code); const qnHistoryIdRef = useRef(qnHistoryId); - useEffect(() => { - codeRef.current = code; - }, [code]); - useEffect(() => { qnHistoryIdRef.current = qnHistoryId; }, [qnHistoryId]); const handleSubmitSessionClick = async (time: number) => { + const code = getDocContent(); try { const res = await codeExecutionClient.post("/", { questionId, - // Replace tabs with 4 spaces to prevent formatting issues - code: code.replace(/\t/g, " ".repeat(4)), + code: code, language: matchCriteria?.language.toLowerCase(), }); setCompilerResult([...res.data.data]); @@ -145,7 +146,7 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { submissionStatus: isMatch ? "Accepted" : "Rejected", dateAttempted: new Date().toISOString(), timeTaken: time, - code: codeRef.current, + code: code, }, qnHistoryDispatch ); @@ -165,7 +166,6 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { const handleConfirmEndSession = async ( time: number, setTime: React.Dispatch>, - setStopTime: React.Dispatch>, isInitiatedByPartner: boolean, sessionDuration?: number ) => { @@ -189,13 +189,15 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { // Only update question history if it has not been submitted before if (data.data.qnHistory.timeTaken === 0) { + const code = getDocContent(); + updateQnHistoryById( qnHistoryIdRef.current, { submissionStatus: "Attempted", dateAttempted: new Date().toISOString(), timeTaken: time, - code: codeRef.current.replace(/\t/g, " ".repeat(4)), + code: code, }, qnHistoryDispatch ); @@ -238,6 +240,7 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { collabSocket.on(CollabEvents.DOCUMENT_NOT_FOUND, () => { toast.error(COLLAB_DOCUMENT_ERROR); setIsDocumentLoaded(false); + setStopTime(true); const text = doc.getText(); doc.transact(() => { @@ -248,6 +251,7 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { applyUpdateV2(doc, new Uint8Array(update), matchUser?.id); toast.success(COLLAB_DOCUMENT_RESTORED); setIsDocumentLoaded(true); + setStopTime(false); }); collabSocket.emit(CollabEvents.RECONNECT_REQUEST, roomId); @@ -263,6 +267,7 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { ) { toast.error(COLLAB_DOCUMENT_ERROR); setIsDocumentLoaded(false); + setStopTime(true); } }); } @@ -278,6 +283,7 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { applyUpdateV2(doc, new Uint8Array(update), matchUser?.id); toast.success(COLLAB_DOCUMENT_RESTORED); setIsDocumentLoaded(true); + setStopTime(false); }); collabSocket.emit(CollabEvents.RECONNECT_REQUEST, roomId); @@ -312,7 +318,6 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { handleEndSessionClick, handleRejectEndSession, handleConfirmEndSession, - setCode, compilerResult, setCompilerResult, isEndSessionModalOpen, @@ -321,6 +326,8 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { handleExitSession, isExitSessionModalOpen, qnHistoryId, + stopTime, + setStopTime, }} > {children} diff --git a/frontend/src/utils/collabSocket.ts b/frontend/src/utils/collabSocket.ts index c0f2d72bf9..9da9fc56b3 100644 --- a/frontend/src/utils/collabSocket.ts +++ b/frontend/src/utils/collabSocket.ts @@ -140,3 +140,9 @@ export const receiveCursorUpdate = (view: EditorView) => { }); }); }; + +export const getDocContent = () => { + return doc && !doc.isDestroyed + ? doc.getText().toString().replace(/\t/g, " ".repeat(4)) // Replace tabs with 4 spaces to prevent formatting issues + : ""; +};