diff --git a/backend/collab-service/src/handlers/websocketHandler.ts b/backend/collab-service/src/handlers/websocketHandler.ts index 88db617c04..3a049e2d18 100644 --- a/backend/collab-service/src/handlers/websocketHandler.ts +++ b/backend/collab-service/src/handlers/websocketHandler.ts @@ -13,28 +13,30 @@ 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", DOCUMENT_READY = "document_ready", + DOCUMENT_NOT_FOUND = "document_not_found", UPDATE = "updateV2", UPDATE_CURSOR = "update_cursor", - PARTNER_LEFT = "partner_left", + END_SESSION = "end_session", + 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(); 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)!); - return; } userConnections.set(connectionKey, null); @@ -47,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); } }); @@ -107,7 +108,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); } } ); @@ -124,41 +126,45 @@ export const handleWebsocketCollabEvents = (socket: Socket) => { socket.on( CollabEvents.LEAVE, - (uid: string, roomId: string, isImmediate: boolean) => { + (uid: string, roomId: string, isPartnerNotified: boolean) => { const connectionKey = `${uid}:${roomId}`; - if (isImmediate || !userConnections.has(connectionKey)) { + if (userConnections.has(connectionKey)) { + clearTimeout(userConnections.get(connectionKey)!); + } + + if (isPartnerNotified) { handleUserLeave(uid, roomId, socket); return; } - clearTimeout(userConnections.get(connectionKey)!); - const connectionTimeout = setTimeout(() => { handleUserLeave(uid, roomId, socket); + io.to(roomId).emit(CollabEvents.PARTNER_DISCONNECTED); }, CONNECTION_DELAY); userConnections.set(connectionKey, connectionTimeout); } ); - 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}`); + socket.on( + CollabEvents.END_SESSION_REQUEST, + (roomId: string, sessionDuration: number) => { + socket.to(roomId).emit(CollabEvents.END_SESSION, sessionDuration); + } + ); - 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); } }); }; @@ -172,6 +178,7 @@ const removeCollabSession = (roomId: string) => { collabSessions.get(roomId)?.destroy(); collabSessions.delete(roomId); partnerReadiness.delete(roomId); + redisClient.del(roomId); }; const getDocument = (roomId: string) => { @@ -188,28 +195,38 @@ 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}`; - 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(CollabEvents.PARTNER_LEFT); - } + removeCollabSession(roomId); }; diff --git a/frontend/src/components/CodeEditor/index.tsx b/frontend/src/components/CodeEditor/index.tsx index 3fb1b3678f..f74b69aa80 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,14 +49,15 @@ 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) { throw new Error(USE_COLLAB_ERROR_MESSAGE); } - const { setCode, checkDocReady } = collab; + const { checkDocReady } = collab; const [isEditorReady, setIsEditorReady] = useState(false); const [isDocumentLoaded, setIsDocumentLoaded] = useState(false); @@ -64,25 +68,36 @@ const CodeEditor: React.FC = (props) => { } }; - const handleChange = (value: string) => { - setCode(value); - }; - 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); } }; loadTemplate(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isReadOnly, isEditorReady]); + }, [isReadOnly, isEditorReady, editorState]); return ( = (props) => { width="100%" basicSetup={false} id="codeEditor" - onChange={handleChange} extensions={[ indentUnit.of("\t"), basicSetup(), @@ -109,9 +123,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/components/CollabSessionControls/index.tsx b/frontend/src/components/CollabSessionControls/index.tsx index 81b30bdb6a..4c0547e9cf 100644 --- a/frontend/src/components/CollabSessionControls/index.tsx +++ b/frontend/src/components/CollabSessionControls/index.tsx @@ -1,15 +1,104 @@ 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, useRef, 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, + qnHistoryId, + stopTime, + setStopTime, + } = collab; + + const [time, setTime] = useState(0); + const timeRef = useRef(time); + + 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(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, true); + }); + + return () => { + collabSocket.off(CollabEvents.END_SESSION); + collabSocket.off(CollabEvents.PARTNER_DISCONNECTED); + }; + }, []); + + useEffect(() => { + timeRef.current = time; + + if (stopTime) { + return; + } + + const intervalId = setInterval( + () => setTime((prevTime) => prevTime + 1), + 1000 + ); + + return () => clearInterval(intervalId); + }, [time, stopTime]); + + useEffect(() => { + if (qnHistoryId) { + setStopTime(false); + } + }, [qnHistoryId]); + + useEffect(() => { + if (!questionId) { + return; + } + getQuestionById(questionId, dispatch); + }, [questionId]); return ( @@ -21,7 +110,8 @@ const CollabSessionControls: React.FC = () => { }} variant="outlined" color="success" - onClick={() => handleSubmitSessionClick()} + onClick={() => handleSubmitSessionClick(time)} + disabled={stopTime} > Submit @@ -35,9 +125,39 @@ const CollabSessionControls: React.FC = () => { onClick={() => { handleEndSessionClick(); }} + disabled={stopTime} > 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, false) + } + secondaryAction="Cancel" + open={isEndSessionModalOpen} + handleClose={handleRejectEndSession} + /> +
); }; diff --git a/frontend/src/components/CustomDialog/index.tsx b/frontend/src/components/CustomDialog/index.tsx new file mode 100644 index 0000000000..9ed355991b --- /dev/null +++ b/frontend/src/components/CustomDialog/index.tsx @@ -0,0 +1,96 @@ +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), + })} + > + {secondaryAction ? ( + + ) : ( + <> + )} + + + + ); +}; + +export default CustomDialog; diff --git a/frontend/src/components/QuestionImageContainer/QuestionImageContainer.test.tsx b/frontend/src/components/QuestionImageContainer/QuestionImageContainer.test.tsx index 317628bbba..599c6cda4c 100644 --- a/frontend/src/components/QuestionImageContainer/QuestionImageContainer.test.tsx +++ b/frontend/src/components/QuestionImageContainer/QuestionImageContainer.test.tsx @@ -10,7 +10,7 @@ jest.mock("../../utils/api", () => ({ describe("Question Image Container", () => { const mockLocalStorage = (() => { - const store: { [key: string]: string } = { token: "test" }; + const store: { [key: string]: string } = { accessToken: "test" }; return { getItem(key: string) { @@ -122,7 +122,7 @@ describe("Question Image Container", () => { expect.any(FormData), expect.objectContaining({ headers: { - Authorization: `Bearer ${mockLocalStorage.getItem("token")}`, + Authorization: `Bearer ${mockLocalStorage.getItem("accessToken")}`, "Content-Type": "multipart/form-data", }, }) diff --git a/frontend/src/contexts/CollabContext.tsx b/frontend/src/contexts/CollabContext.tsx index 7d5ab7446f..3167b81b4d 100644 --- a/frontend/src/contexts/CollabContext.tsx +++ b/frontend/src/contexts/CollabContext.tsx @@ -1,26 +1,42 @@ /* 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, SUCCESS_TESTCASE_MESSAGE, FAILED_TO_SUBMIT_CODE_MESSAGE, - COLLAB_ENDED_MESSAGE, + COLLAB_END_ERROR, + COLLAB_SUBMIT_ERROR, + COLLAB_DOCUMENT_ERROR, + COLLAB_DOCUMENT_RESTORED, + COLLAB_RECONNECTION_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"; -import { CollabEvents, collabSocket, leave } from "../utils/collabSocket"; +import { + CollabEvents, + collabSocket, + getDocContent, + leave, +} from "../utils/collabSocket"; import { CommunicationEvents, communicationSocket, } from "../utils/communicationSocket"; import useAppNavigate from "../hooks/useAppNavigate"; +import { applyUpdateV2, Doc } from "yjs"; export type CompilerResult = { status: string; @@ -36,18 +52,29 @@ export type CompilerResult = { }; type CollabContextType = { - handleSubmitSessionClick: () => void; + handleSubmitSessionClick: (time: number) => void; handleEndSessionClick: () => void; handleRejectEndSession: () => void; - handleConfirmEndSession: () => void; - checkPartnerStatus: () => void; - setCode: React.Dispatch>; + handleConfirmEndSession: ( + time: number, + setTime: React.Dispatch>, + isInitiatedByPartner: boolean, + sessionDuration?: number + ) => void; compilerResult: CompilerResult[]; setCompilerResult: React.Dispatch>; isEndSessionModalOpen: boolean; - time: number; resetCollab: () => void; - checkDocReady: () => void; + checkDocReady: ( + roomId: string, + doc: Doc, + setIsDocumentLoaded: React.Dispatch> + ) => void; + handleExitSession: () => void; + isExitSessionModalOpen: boolean; + qnHistoryId: string | null; + stopTime: boolean; + setStopTime: React.Dispatch>; }; const CollabContext = createContext(null); @@ -62,50 +89,38 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { throw new Error(USE_MATCH_ERROR_MESSAGE); } - const { - matchUser, - partner, - matchCriteria, - getMatchId, - stopMatch, - questionId, - } = match; - - const [time, setTime] = useState(0); - - useEffect(() => { - const intervalId = setInterval( - () => setTime((prevTime) => prevTime + 1), - 1000 - ); - - return () => clearInterval(intervalId); - }, [time]); + const { matchUser, matchCriteria, getMatchId, stopMatch, questionId } = match; // eslint-disable-next-line const [_qnHistoryState, qnHistoryDispatch] = useReducer( 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 [hasSubmitted, setHasSubmitted] = useState(false); + const [stopTime, setStopTime] = useState(true); - const handleSubmitSessionClick = async () => { + const qnHistoryIdRef = useRef(qnHistoryId); + + 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(), }); - setHasSubmitted(true); - console.log([...res.data.data]); setCompilerResult([...res.data.data]); - + let isMatch = true; for (let i = 0; i < res.data.data.length; i++) { if (!res.data.data[i].isMatch) { @@ -120,13 +135,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: code, }, qnHistoryDispatch ); @@ -143,30 +163,60 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { setIsEndSessionModalOpen(false); }; - const handleConfirmEndSession = async () => { + const handleConfirmEndSession = async ( + time: number, + setTime: React.Dispatch>, + isInitiatedByPartner: boolean, + sessionDuration?: number + ) => { setIsEndSessionModalOpen(false); - // 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 - ); + const roomId = getMatchId(); + if (!matchUser || !roomId || !qnHistoryIdRef.current) { + toast.error(COLLAB_END_ERROR); + appNavigate("/home"); + return; + } + + setStopTime(true); + setIsExitSessionModalOpen(true); + + if (isInitiatedByPartner && sessionDuration) { + setTime(sessionDuration); + } else { + // Get question history + const data = await qnHistoryClient.get(qnHistoryIdRef.current); + + // 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: code, + }, + qnHistoryDispatch + ); + } + } + + if (!isInitiatedByPartner) { + // Notify partner + collabSocket.emit(CollabEvents.END_SESSION_REQUEST, roomId, time); } // Leave collaboration room - leave(matchUser?.id as string, getMatchId() as string, true); - leave(partner?.id as string, getMatchId() as string, true); + leave(matchUser.id, roomId, true); // Leave chat room communicationSocket.emit(CommunicationEvents.USER_DISCONNECT); + }; + const handleExitSession = () => { // Delete match data stopMatch(); appNavigate("/home"); @@ -175,24 +225,90 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { resetCollab(); }; - const checkDocReady = () => { - collabSocket.on(CollabEvents.DOCUMENT_READY, (qnHistoryId: string) => { - setQnHistoryId(qnHistoryId); - }); - }; + const checkDocReady = ( + roomId: string, + doc: Doc, + setIsDocumentLoaded: React.Dispatch> + ) => { + if (!collabSocket.hasListeners(CollabEvents.DOCUMENT_READY)) { + collabSocket.on(CollabEvents.DOCUMENT_READY, (qnHistoryId: string) => { + setQnHistoryId(qnHistoryId); + }); + } - const checkPartnerStatus = () => { - collabSocket.on(CollabEvents.PARTNER_LEFT, () => { - toast.error(COLLAB_ENDED_MESSAGE); - setIsEndSessionModalOpen(false); - stopMatch(); - appNavigate("/home"); - }); + if (!collabSocket.hasListeners(CollabEvents.DOCUMENT_NOT_FOUND)) { + collabSocket.on(CollabEvents.DOCUMENT_NOT_FOUND, () => { + toast.error(COLLAB_DOCUMENT_ERROR); + setIsDocumentLoaded(false); + setStopTime(true); + + 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); + setStopTime(false); + }); + + 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); + setStopTime(true); + } + }); + } + + 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); + setStopTime(false); + }); + + 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([]); - setTime(0); + setIsEndSessionModalOpen(false); + setIsExitSessionModalOpen(false); + setQnHistoryId(null); }; return ( @@ -202,14 +318,16 @@ const CollabProvider: React.FC<{ children?: React.ReactNode }> = (props) => { handleEndSessionClick, handleRejectEndSession, handleConfirmEndSession, - checkPartnerStatus, - setCode, compilerResult, setCompilerResult, isEndSessionModalOpen, - time, resetCollab, checkDocReady, + handleExitSession, + isExitSessionModalOpen, + qnHistoryId, + stopTime, + setStopTime, }} > {children} diff --git a/frontend/src/contexts/MatchContext.tsx b/frontend/src/contexts/MatchContext.tsx index c97f15f46d..32b0e2dfb9 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, @@ -78,7 +80,6 @@ type MatchContextType = { retryMatch: () => void; matchingTimeout: () => void; matchOfferTimeout: () => void; - verifyMatchStatus: () => void; getMatchId: () => string | null; matchUser: MatchUser | null; matchCriteria: MatchCriteria | null; @@ -129,12 +130,11 @@ const MatchProvider: React.FC<{ children?: React.ReactNode }> = (props) => { }, [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; } @@ -142,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(); }; @@ -166,6 +170,7 @@ const MatchProvider: React.FC<{ children?: React.ReactNode }> = (props) => { return () => { closeSocketConnection(); + unblock(); window.removeEventListener("beforeunload", handleBeforeUnload); window.removeEventListener("unload", handleUnload); }; @@ -381,10 +386,12 @@ const MatchProvider: React.FC<{ children?: React.ReactNode }> = (props) => { }; const acceptMatch = () => { - matchSocket.emit( - MatchEvents.MATCH_ACCEPT_REQUEST, - matchId - ); + if (!matchUser || !partner) { + toast.error(MATCH_ACCEPTANCE_ERROR); + return; + } + + matchSocket.emit(MatchEvents.MATCH_ACCEPT_REQUEST, matchId); }; const rematch = () => { @@ -453,30 +460,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); - } else { - setMatchId(null); - setPartner(null); - } - setLoading(false); - } - ); - }; - const getMatchId = () => { return matchId; }; @@ -491,7 +474,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 c2c96abcc4..6deb92e314 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"; @@ -35,57 +24,36 @@ 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, - partner, - matchCriteria, - loading, - questionId, - } = match; + const { getMatchId, matchUser, matchCriteria, questionId } = match; const collab = useCollab(); if (!collab) { throw new Error(USE_COLLAB_ERROR_MESSAGE); } - const { - compilerResult, - handleRejectEndSession, - handleConfirmEndSession, - checkPartnerStatus, - isEndSessionModalOpen, - resetCollab, - } = collab; + const { compilerResult, resetCollab } = collab; const [state, dispatch] = useReducer(reducer, initialState); 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(() => { - verifyMatchStatus(); - - if (!questionId) { - return; - } - getQuestionById(questionId, dispatch); - resetCollab(); - const matchId = getMatchId(); if (!matchUser || !matchId) { + toast.error(COLLAB_CONNECTION_ERROR); + setIsConnecting(false); return; } @@ -94,7 +62,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); @@ -107,22 +74,28 @@ const CollabSandbox: React.FC = () => { connectToCollabSession(); - return () => leave(matchUser.id, matchId); + // handle page refresh / tab closure + const handleUnload = () => { + leave(matchUser.id, matchId, false); + }; + window.addEventListener("unload", handleUnload); + + return () => { + leave(matchUser.id, matchId, false); + window.removeEventListener("unload", handleUnload); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - if (loading) { - return ; - } + useEffect(() => { + if (!questionId) { + return; + } + getQuestionById(questionId, dispatch); + }, [questionId]); - if ( - !matchUser || - !partner || - !matchCriteria || - !getMatchId() || - !isConnecting - ) { + if (!matchUser || !matchCriteria || !matchId || !isConnecting) { return ; } @@ -132,52 +105,6 @@ const CollabSandbox: React.FC = () => { return ( - - - {"End Session?"} - - - - Are you sure you want to end session? -
- You will lose your current progress. -
-
- - - - -
{ ? selectedQuestion.cTemplate : "" } - roomId={getMatchId()!} + roomId={matchId} /> => { collabSocket.connect(); - initConnectionStatusListeners(roomId); doc = new Doc(); text = doc.getText(); awareness = new Awareness(doc); doc.on(CollabEvents.UPDATE, (update, origin) => { - if (origin != uid) { + if (origin !== uid) { collabSocket.emit(CollabEvents.UPDATE_REQUEST, roomId, update); } }); @@ -72,7 +76,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 }); }); }); }; @@ -106,12 +110,19 @@ export const initDocument = ( }); }; -export const leave = (uid: string, roomId: string, isImmediate?: 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, isImmediate); - doc.destroy(); + doc?.destroy(); + + if (collabSocket.connected) { + collabSocket.emit(CollabEvents.LEAVE, uid, roomId, isPartnerNotified); + } }; export const sendCursorUpdate = (roomId: string, cursor: Cursor) => { @@ -130,32 +141,8 @@ 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"); - }); - } +export const getDocContent = () => { + return doc && !doc.isDestroyed + ? doc.getText().toString().replace(/\t/g, " ".repeat(4)) // Replace tabs with 4 spaces to prevent formatting issues + : ""; }; diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts index 92e54cba55..0cdb7441c0 100644 --- a/frontend/src/utils/constants.ts +++ b/frontend/src/utils/constants.ts @@ -97,14 +97,28 @@ export const MATCH_LOGIN_REQUIRED_MESSAGE = export const MATCH_OFFER_TIMEOUT_MESSAGE = "Match offer timeout!"; export const MATCH_CONNECTION_ERROR = "Connection error! Please try again later."; +export const MATCH_ACCEPTANCE_ERROR = + "Error accepting match request! Please try again."; export const QUESTION_DOES_NOT_EXIST_ERROR = "There are no questions with the specified complexity and category. Please try another combination."; // Collab export const COLLAB_ENDED_MESSAGE = - "Your partner has left the collaboration session."; + "Your partner has ended the collaboration session."; +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 = + "Something went wrong! Forcefully ending the session..."; +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 = @@ -117,11 +131,15 @@ export const FAILED_TO_SUBMIT_CODE_MESSAGE = /* Alerts & Dialog Boxes */ // Questions export const ABORT_CREATE_OR_EDIT_QUESTION_CONFIRMATION_MESSAGE = - "Are you sure you want to leave this page? All process will be lost."; + "Are you sure you want to leave this page? All progress will be lost."; // Match export const ABORT_MATCH_PROCESS_CONFIRMATION_MESSAGE = - "Are you sure you want to leave the matching process?"; + "Are you sure you want to leave the matching process? Your match request will be cancelled."; + +// Collab +export const ABORT_COLLAB_SESSION_CONFIRMATION_MESSAGE = + "Are you sure you want to leave the collaboration session? You will not be able to rejoin."; /* Image paths */ export const FIND_MATCH_FORM_PATH = "/find_match_form.png";