diff --git a/backend/collaboration-service/events.py b/backend/collaboration-service/events.py index 781590c07d..20620bc3c0 100644 --- a/backend/collaboration-service/events.py +++ b/backend/collaboration-service/events.py @@ -4,3 +4,4 @@ class Events: CURSOR_UPDATED = 'cursor_updated' USER_LEFT = 'user_left' LANGUAGE_CHANGE= 'language_change' + CODE_SUBMITTED = 'code_submitted' diff --git a/backend/collaboration-service/models.py b/backend/collaboration-service/models.py index 16623c062f..2375dd2997 100644 --- a/backend/collaboration-service/models.py +++ b/backend/collaboration-service/models.py @@ -46,6 +46,7 @@ def __init__(self, room_id: str, user: User): self.id = room_id self.users: list[User] = [user] self.code = "" + self.submitted = False Room.rooms[room_id] = self user.join_room(self) diff --git a/backend/collaboration-service/server.py b/backend/collaboration-service/server.py index 5ff3b5f09b..e105882b50 100644 --- a/backend/collaboration-service/server.py +++ b/backend/collaboration-service/server.py @@ -158,6 +158,29 @@ async def language_change(sid, data): except Exception as e: logging.error(f"Failed to emit LANGUAGE_CHANGE for user {sid}: {e}") +@sio.on(Events.CODE_SUBMITTED) +async def code_submitted(sid): + logging.debug(f'code_submitted {sid=}') + user: User = User.users.get(sid) + + if user is None: + logging.error(f"User not found for sid {sid}") + return + + if user.room is None: + logging.error(f"User {sid} is not associated with any room") + return + + user.room.submitted = True + + try: + await sio.emit(Events.CODE_SUBMITTED, None, room=user.room.id, skip_sid=sid) + logging.debug(f"Emitted CODE_SUBMITTED to room {user.room.id}") + except Exception as e: + logging.error(f"Failed to emit CODE_SUBMITTED for user {sid}: {e}") + + await sio.disconnect(sid); + @sio.event async def disconnect(sid): @@ -181,7 +204,7 @@ async def disconnect(sid): room_still_exists = room.remove_user(user) - if room_still_exists: + if room_still_exists and not room.submitted: try: await sio.emit(Events.USER_LEFT, user.details(), room=room.id) logging.debug(f"Emitted USER_LEFT to room {room.id}") diff --git a/backend/matching-service/src/matchingService.ts b/backend/matching-service/src/matchingService.ts index b3a54e83d6..943e6e7ae9 100644 --- a/backend/matching-service/src/matchingService.ts +++ b/backend/matching-service/src/matchingService.ts @@ -10,10 +10,7 @@ const DELAY_TIME = 30000; const CONFIRM_DELAY_TIME = 10000; const RELAX_CONSTRAINT_DELAY = 10000; -export async function handleMatchingRequest( - userRequest: any, - socketId: string -) { +export async function handleMatchingRequest(userRequest: any, socketId: string) { userRequest.socketId = socketId; addUserToQueue(userRequest); @@ -39,10 +36,7 @@ function sendConfirmDelayedTimeoutMessage(recordId: string) { }, CONFIRM_DELAY_TIME ); - console.log( - "Sent delayed message for confirm timeout for recordId: ", - recordId - ); + console.log("Sent delayed message for confirm timeout for recordId: ", recordId); } function sendRelaxConstraintsMessage(userRequest: any) { @@ -51,9 +45,7 @@ function sendRelaxConstraintsMessage(userRequest: any) { console.log("Scheduled to relax constraints for user:", userRequest.userId); } -export async function handleUserRequestWithRelaxedConstraints( - userRequest: any -) { +export async function handleUserRequestWithRelaxedConstraints(userRequest: any) { const { userId, topic, difficulty } = userRequest; const user = await prisma.matchRecord.findFirst({ @@ -66,9 +58,7 @@ export async function handleUserRequestWithRelaxedConstraints( data: { constraintsRelaxed: true }, }); } else { - console.error( - `No match record found for user ${userId} during relaxed constraints matching` - ); + console.error(`No match record found for user ${userId} during relaxed constraints matching`); return; } @@ -148,9 +138,7 @@ export async function handleUserRequestWithRelaxedConstraints( }, }); } else { - console.error( - `No match record found for user ${userId} during relaxed constraints matching` - ); + console.error(`No match record found for user ${userId} during relaxed constraints matching`); return; } @@ -169,15 +157,11 @@ export async function handleUserRequestWithRelaxedConstraints( sendConfirmDelayedTimeoutMessage(currentUserRecord.recordId.toString()); sendConfirmDelayedTimeoutMessage(existingMatch.recordId.toString()); } else { - console.log( - `No match found for ${userId} after relaxing constraints, waiting for future matches` - ); + console.log(`No match found for ${userId} after relaxing constraints, waiting for future matches`); } } -export async function handleUserRequestWithoutRelaxedConstraints( - userRequest: any -) { +export async function handleUserRequestWithoutRelaxedConstraints(userRequest: any) { const { userId, topic, difficulty, socketId } = userRequest; // Check if user already has a match record @@ -300,13 +284,9 @@ export async function handleUserRequestWithoutRelaxedConstraints( io.to(socketId).emit("matched", { matchedWith: existingMatch.userId, - roomNumber, - questionId: question.questionId, }); io.to(existingMatch.socketId).emit("matched", { matchedWith: userId, - roomNumber, - questionId: question.questionId, }); // Add confirm timeout messages @@ -315,9 +295,7 @@ export async function handleUserRequestWithoutRelaxedConstraints( } else { // No match found // Add user to match record and schedule constraint relaxation - console.log( - `No match found for ${userId}, added to record and scheduling constraint relaxation` - ); + console.log(`No match found for ${userId}, added to record and scheduling constraint relaxation`); await addOrUpdateMatchRecord(userRequest); sendRelaxConstraintsMessage(userRequest); } @@ -372,20 +350,14 @@ export async function handleMatchingConfirm(userRequest: any) { where: { recordId: matchedRecord.recordId }, data: { isArchived: true }, }); - io.to(matchedRecord.socketId).emit( - "other_declined", - "Match not confirmed. Please try again." - ); + io.to(matchedRecord.socketId).emit("other_declined", "Match not confirmed. Please try again."); } if (userRecord !== null) { await prisma.matchRecord.update({ where: { recordId: userRecord.recordId }, data: { isArchived: true }, }); - io.to(userRecord.socketId).emit( - "other_declined", - "Match not confirmed. Please try again." - ); + io.to(userRecord.socketId).emit("other_declined", "Match not confirmed. Please try again."); } return; } @@ -413,33 +385,34 @@ export async function handleMatchingConfirm(userRequest: any) { where: { recordId: userRecord.recordId }, data: { isArchived: true }, }); + + // Set roomNumber and questionId in this session + // Currently: use data from matchedRecord, i.e. user who confirmed first + const roomNumber = matchedRecord.roomNumber; + const questionId = matchedRecord.questionId ?? userRecord.questionId ?? 1; + await prisma.sessionHistory.create({ data: { - roomNumber: matchedRecord.roomNumber, - questionId: matchedRecord.questionId ?? userRecord.questionId ?? 0, + roomNumber: roomNumber, + questionId: questionId, isOngoing: true, userOneId: userRecord.userId, userTwoId: matchedRecord.userId, }, }); - io.to(userRecord.socketId).emit( - "matching_success", - "Match confirmed. Proceeding to collaboration service." - ); - io.to(matchedRecord.socketId).emit( - "matching_success", - "Match confirmed. Proceeding to collaboration service." - ); + io.to(userRecord.socketId).emit("matching_success", { + roomNumber, + questionId, + }); + io.to(matchedRecord.socketId).emit("matching_success", { + roomNumber, + questionId, + }); // TODO: add further logic here to proceed to collaboration service } else { - console.log( - `User ${userId} confirmed match, waiting for other user to confirm` - ); - io.to(matchedRecord.socketId).emit( - "other_accepted", - "Other user confirmed match. Please confirm." - ); + console.log(`User ${userId} confirmed match, waiting for other user to confirm`); + io.to(matchedRecord.socketId).emit("other_accepted", "Other user confirmed match. Please confirm."); } } @@ -460,28 +433,16 @@ export async function handleMatchingDecline(userRequest: any) { where: { recordId: matchedRecord.recordId }, data: { isArchived: true }, }); - io.to(matchedRecord.socketId).emit( - "other_declined", - "Match not confirmed. Please try again." - ); - io.to(matchedRecord.socketId).emit( - "matching_fail", - "Match not confirmed. Please try again." - ); + io.to(matchedRecord.socketId).emit("other_declined", "Match not confirmed. Please try again."); + io.to(matchedRecord.socketId).emit("matching_fail", "Match not confirmed. Please try again."); } if (userRecord !== null) { await prisma.matchRecord.update({ where: { recordId: userRecord.recordId }, data: { isArchived: true }, }); - io.to(userRecord.socketId).emit( - "other_declined", - "Match not confirmed. Please try again." - ); - io.to(userRecord.socketId).emit( - "matching_fail", - "Match not confirmed. Please try again." - ); + io.to(userRecord.socketId).emit("other_declined", "Match not confirmed. Please try again."); + io.to(userRecord.socketId).emit("matching_fail", "Match not confirmed. Please try again."); } return; @@ -494,23 +455,14 @@ export async function handleMatchingDecline(userRequest: any) { // user decline, match failed regardless console.log(`User ${userId} declined match`); - io.to(matchedRecord.socketId).emit( - "other_declined", - "Match not confirmed. Please try again." - ); + io.to(matchedRecord.socketId).emit("other_declined", "Match not confirmed. Please try again."); await prisma.matchRecord.update({ where: { recordId: matchedRecord.recordId }, data: { isArchived: true }, }); - io.to(userRecord.socketId).emit( - "matching_fail", - "Match not confirmed. Please try again." - ); - io.to(matchedRecord.socketId).emit( - "matching_fail", - "Match not confirmed. Please try again." - ); + io.to(userRecord.socketId).emit("matching_fail", "Match not confirmed. Please try again."); + io.to(matchedRecord.socketId).emit("matching_fail", "Match not confirmed. Please try again."); } export async function handleTimeout(userRequest: any) { @@ -541,18 +493,13 @@ export async function handleConfirmTimeout(recordId: string) { console.log(`Timeout: Confirm timeout for recordId ${recordId}`); if (result !== null && !result.isArchived) { if (result.isConfirmed === false) { - console.log( - `Timeout: Match not confirmed for recordId ${recordId} with userId ${result.userId}` - ); + console.log(`Timeout: Match not confirmed for recordId ${recordId} with userId ${result.userId}`); } else { console.log( `Timeout: Match confirmed for recordId ${recordId} with userId ${result.userId} but other user did not confirm` ); } - io.to(result.socketId).emit( - "matching_fail", - "Match not confirmed. Please try again." - ); + io.to(result.socketId).emit("matching_fail", "Match not confirmed. Please try again."); await prisma.matchRecord.update({ where: { recordId: recordIdInt }, data: { isArchived: true }, diff --git a/frontend/src/components/CodeContainer/CodeContainer.scss b/frontend/src/components/CodeContainer/CodeContainer.scss new file mode 100644 index 0000000000..d79486e9dd --- /dev/null +++ b/frontend/src/components/CodeContainer/CodeContainer.scss @@ -0,0 +1,62 @@ +.CodeContainer { + display: flex; + + .left-side { + flex: 0.4; + padding: 10px; + color: #fff; + display: flex; + flex-direction: column; + + .md-editor { + background-color: #1e1e1e; + border-radius: 10px; + border: 1px solid #3c3c3c; + padding: 20px; + color: #fff; + height: 500px; + overflow: auto; + } + } + + .right-side { + flex: 0.6; + padding: 10px; + color: #fff; + display: flex; + flex-direction: column; + + .header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + + .language-select { + background-color: #1e1e1e; + color: #fff; + border: 1px solid #3c3c3c; + padding: 5px; + border-radius: 5px; + } + + .submit-button { + background-color: #caff33; + color: #121212; + border-radius: 5px; + + &.disabled { + color: #6c757d; + } + } + } + + .code-editor { + background-color: #1e1e1e; + border-radius: 10px; + border: 1px solid #3c3c3c; + color: #fff; + overflow: hidden; + } + } +} diff --git a/frontend/src/components/CodeContainer/CodeContainer.tsx b/frontend/src/components/CodeContainer/CodeContainer.tsx new file mode 100644 index 0000000000..153b0028ea --- /dev/null +++ b/frontend/src/components/CodeContainer/CodeContainer.tsx @@ -0,0 +1,454 @@ +import MDEditor from "@uiw/react-md-editor"; +import { ReactElement, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { Question, TestCase } from "../../models/question.model"; +import { Decoration, EditorView, WidgetType } from "@codemirror/view"; +import CodeMirror from "@uiw/react-codemirror"; +import { okaidia } from "@uiw/codemirror-theme-okaidia"; +import { python } from "@codemirror/lang-python"; +import { cpp } from "@codemirror/lang-cpp"; +import { java } from "@codemirror/lang-java"; +import { autocompletion } from "@codemirror/autocomplete"; +import { RangeSetBuilder, Extension } from "@codemirror/state"; +import SessionService, { Language } from "../../services/session.service"; +import { AxiosError } from "axios"; +import { io, Socket } from "socket.io-client"; +import { useConfirmationDialog } from "../../contexts/ConfirmationDialogContext"; +import { useMainDialog } from "../../contexts/MainDialogContext"; +import QuestionService from "../../services/question.service"; +import { SessionContext } from "../../contexts/SessionContext"; +import { UserContext } from "../../contexts/UserContext"; +import { Button } from "@mui/material"; +import { useNavigate } from "react-router-dom"; +import "./CodeContainer.scss"; + +const COLLABORATION_WEBSOCKET_URL = process.env.REACT_APP_COLLABORATION_SERVICE_URL as string; + +// Define the CursorWidget +class CursorWidget extends WidgetType { + color: string; + + constructor(color: string) { + super(); + this.color = color; + } + + toDOM() { + const cursor = document.createElement("span"); + cursor.style.borderLeft = `2px solid ${this.color}`; + cursor.style.marginLeft = "-1px"; + cursor.style.height = "1em"; + cursor.className = "remote-cursor"; + return cursor; + } + + ignoreEvent() { + return true; + } +} + +const userColors = [ + "#FF5733", // Red + "#33FF57", // Green + "#3357FF", // Blue + "#F333FF", // Pink + "#FF33F3", // Magenta + "#33FFF3", // Cyan + "#FFA533", // Orange + "#A533FF", // Purple + "#33A5FF", // Light Blue + "#33FF99", // Light Green +]; + +const getColorForUser = (sid: string): string => { + let hash = 0; + for (let i = 0; i < sid.length; i++) { + hash = sid.charCodeAt(i) + ((hash << 5) - hash); + } + const index = Math.abs(hash) % userColors.length; + return userColors[index]; +}; + +const languageExtensions: { [key in Language]: Extension[] } = { + python: [python(), autocompletion()], + cpp: [cpp(), autocompletion()], + java: [java(), autocompletion()], +}; + +// Function to create decorations +const createCursorDecorations = (otherCursors: { + [sid: string]: { cursor_position: number; color: string }; +}): Extension => { + return EditorView.decorations.of((view) => { + const builder = new RangeSetBuilder(); + for (const [sid, cursor] of Object.entries(otherCursors)) { + const { cursor_position, color } = cursor; + if (typeof cursor_position === "number") { + // Ensure cursor_position is a number + const decoration = Decoration.widget({ + widget: new CursorWidget(color), + side: 0, + }); + builder.add(cursor_position, cursor_position, decoration); + } else { + console.warn(`Invalid cursor_position for sid ${sid}:`, cursor_position); + } + } + return builder.finish(); + }); +}; + +interface CodeContainerProps { + questionData: Question | null; + roomNumber: string | undefined; + givenTestCases: TestCase[]; + setGivenTestCases: React.Dispatch>; + customTestCases: TestCase[]; + setCustomTestCases: React.Dispatch>; +} + +const CodeContainer: React.FC = ({ + questionData, + roomNumber, + givenTestCases, + setGivenTestCases, + customTestCases, + setCustomTestCases, +}): ReactElement => { + const { setMainDialogTitle, setMainDialogContent, openMainDialog } = useMainDialog(); + const { setConfirmationDialogTitle, setConfirmationDialogContent, setConfirmationCallBack, openConfirmationDialog } = + useConfirmationDialog(); + const navigate = useNavigate(); + const { user } = useContext(UserContext); + const { questionId, clearSession } = useContext(SessionContext); + + // Use state + ref combination to handle real-time state change + socket events + const [code, setCode] = useState("# Write your solution here\n"); + const [language, setLanguage] = useState("python"); + const codeRef = useRef(code); + const languageRef = useRef(language); + + const [joinedRoom, setJoinedRoom] = useState(false); + const [isExecuting, setIsExecuting] = useState(false); + + const collaborationSocketRef = useRef(null); + + const setUpCollaborationSocket = () => { + const token = localStorage.getItem("jwt-token"); + if (!token) { + console.error("No JWT token found in localStorage."); + return; + } + + // Collaboration socket for code editing + const socket = io(COLLABORATION_WEBSOCKET_URL, { + extraHeaders: { + Authorization: `${token}`, + }, + }); + collaborationSocketRef.current = socket; + + socket.on("connect", () => { + console.log("Connected to socket.io server."); + socket.emit("join_request", { room_id: roomNumber }); + }); + + socket.on("disconnect", () => { + console.log("Disconnected from socket.io server."); + clearSession(); + navigate("/"); + }); + + socket.on("join_request", (data: any) => { + console.log("Received join_request data:", data); + if (data?.user_id && data.user_id === user?.id) { + // Current user successfully joined a room + setJoinedRoom(true); + } else { + // emit current code and cursor for any new user joining the room + console.log("emitting"); + socket.emit("language_change", { language: languageRef.current, room_id: roomNumber }); + socket.emit("code_updated", { code: codeRef.current }); + socket.emit("cursor_updated", { cursor_position: lastCursorPosition.current }); + } + }); + + socket.on("language_change", (newLanguage: string) => { + if (["python", "cpp", "javascript", "java"].includes(newLanguage)) { + setLanguage(newLanguage as Language); + if (newLanguage === "cpp") { + setCode( + "#include \nusing namespace std;\n\nint main() {\n\t// Write your solution here\n\treturn 0;\n}", + ); + } else if (newLanguage === "java") { + setCode( + "public class Main {\n\tpublic static void main(String[] args) {\n\t\t// Write your solution here\n\t}\n}", + ); + } else { + setCode("# Write your solution here\n"); + } + } else { + console.warn(`Unsupported language received: ${newLanguage}`); + } + }); + + // Handle real-time code updates + socket.on("code_updated", (newCode: string) => { + setCode(newCode); + }); + + // Handle cursor updates + socket.on("cursor_updated", (userDetails: any) => { + const { sid, cursor_position } = userDetails; + if (sid === socket.id) return; // Ignore own cursor + + if (typeof cursor_position !== "number") { + console.error(`Invalid cursor_position for sid ${sid}:`, cursor_position); + return; + } + + setOtherCursors((prev) => ({ + ...prev, + [sid]: { + cursor_position, + color: getColorForUser(sid), + }, + })); + }); + + // Handle user disconnection to remove their cursor + socket.on("user_disconnected", (sid: string) => { + console.log(`User disconnected: ${sid}`); + setOtherCursors((prev) => { + const newCursors = { ...prev }; + delete newCursors[sid]; + return newCursors; + }); + }); + + // Handle other user submitting code and ending session + socket.on("code_submitted", (sid: string) => { + console.log(`Code submitted: ${sid}`); + setMainDialogTitle("Code submitted"); + setMainDialogContent( + "Your partner has submitted the code and ended the session. You can view the code in your session history!", + ); + openMainDialog(); + clearSession(); + navigate("/"); + }); + + // Handle socket errors + socket.on("error", (error: any) => { + console.error("Socket error:", error); + }); + + socket.on("user_left", (uid: string) => { + if (user && uid !== user.id) { + setConfirmationDialogTitle("Partner Disconnected"); + setConfirmationDialogContent( + "Your partner has left the coding session. Would you like to end the session and return to home page?", + ); + setConfirmationCallBack(() => () => { + clearSession(); + navigate("/"); + }); + openConfirmationDialog(); + } + }); + }; + + // On load, set up collaboration socket + useEffect(() => { + if (user && roomNumber) { + setUpCollaborationSocket(); + } + return () => { + collaborationSocketRef.current?.disconnect(); + }; + }, [user, roomNumber]); + + useEffect(() => { + codeRef.current = code; + }, [code]); + + useEffect(() => { + languageRef.current = language; + }, [language]); + + // States for cursors + const lastCursorPosition = useRef(null); + const [otherCursors, setOtherCursors] = useState<{ + [sid: string]: { cursor_position: number; color: string }; + }>({}); + + const handleLanguageChange = (e: React.ChangeEvent) => { + const newLanguage = e.target.value as Language; + if (["python", "cpp", "javascript", "java"].includes(newLanguage)) { + setLanguage(newLanguage); + if (newLanguage === "cpp") { + setCode( + "#include \nusing namespace std;\n\nint main() {\n\t// Write your solution here\n\treturn 0;\n}", + ); + } else if (newLanguage === "java") { + setCode( + "public class Main {\n\tpublic static void main(String[] args) {\n\t\t// Write your solution here\n\t}\n}", + ); + } else { + setCode("# Write your solution here\n"); + } + if (joinedRoom) { + collaborationSocketRef.current?.emit("language_change", { + language: newLanguage, + room_id: roomNumber, + }); + } + } else { + console.warn(`Attempted to set unsupported language: ${newLanguage}`); + } + }; + + const handleCodeChange = (value: string) => { + setCode(value); + if (joinedRoom) { + // Emit only if joined + collaborationSocketRef.current?.emit("code_updated", { code: value }); + } + }; + + const handleCursorChange = (viewUpdate: any) => { + const cursorPosition = viewUpdate.state.selection.main.head; + if (cursorPosition !== lastCursorPosition.current) { + lastCursorPosition.current = cursorPosition; + collaborationSocketRef.current?.emit("cursor_updated", { + cursor_position: cursorPosition, + }); + } + }; + + const cursorDecorationsExtension = useMemo(() => { + return createCursorDecorations(otherCursors); + }, [otherCursors]); + + // Function to execute the code against all test cases + const executeCode = async () => { + if (!questionData) { + alert("No question data available."); + return; + } + + const submittedTestCases = customTestCases.filter((tc) => tc.isSubmitted); + + // Prepare payload for the API + const payload = { + lang: languageRef.current, + code: codeRef.current, + customTests: submittedTestCases.map((tc) => ({ + input: tc.input, + output: tc.expectedOutput || null, + })), + }; + + try { + setIsExecuting(true); + const response = await QuestionService.test(questionId, payload); + setIsExecuting(false); + // Assuming the API returns an array of actual outputs corresponding to the test cases + const { outputs } = response; + + if (!Array.isArray(outputs) || outputs.length !== givenTestCases.length + submittedTestCases.length) { + console.error("Invalid response from code execution API:", response); + alert("Invalid response from code execution API."); + return; + } + + // Update actual outputs in test cases + const updatedCustomTestCases = customTestCases.map((tc) => { + const submissionIndex = submittedTestCases.findIndex((stc) => stc.id === tc.id); + if (submissionIndex !== -1) { + return { + ...tc, + actualOutput: outputs[submissionIndex], + }; + } + return tc; + }); + setCustomTestCases(updatedCustomTestCases); + + const updatedGivenTestCases = givenTestCases.map((tc, i) => { + return { + ...tc, + actualOutput: outputs[i], + }; + }); + setGivenTestCases(updatedGivenTestCases); + } catch (error) { + console.error("Error executing code:", error); + alert("An error occurred while executing the code."); + } + }; + + const submitAndEndSession = async () => { + try { + setConfirmationDialogTitle("Submit and end session"); + setConfirmationDialogContent( + "You are about to submit your code and end the session for both you and your partner. Are you sure?", + ); + setConfirmationCallBack(() => async () => { + await SessionService.submitSession(user?.id as string, roomNumber!, codeRef.current, languageRef.current); + collaborationSocketRef.current?.emit("code_submitted"); + }); + openConfirmationDialog(); + } catch (error) { + setMainDialogTitle("Error"); + setMainDialogContent( + error instanceof AxiosError && error.response?.data.message + ? error.response?.data.message + : "An error occurred while submitting the code.", + ); + openMainDialog(); + } + }; + + return ( +
+
+ +
+ +
+
+ +
+ + +
+
+ handleCursorChange(viewUpdate)} + theme={okaidia} + /> +
+
+ ); +}; + +export default CodeContainer; diff --git a/frontend/src/components/QuestionContainer/QuestionContainer.scss b/frontend/src/components/QuestionContainer/QuestionContainer.scss new file mode 100644 index 0000000000..19757e2b18 --- /dev/null +++ b/frontend/src/components/QuestionContainer/QuestionContainer.scss @@ -0,0 +1,21 @@ +.question-container { + margin-bottom: 20px; + + .question-title { + margin-bottom: 10px; + color: #e4e4e4; + display: flex; + align-items: center; + } + + .details { + display: flex; + gap: 10px; + + .light-grey-chip { + background-color: #373737; + color: #ffffff; + border-radius: 6px; + } + } +} diff --git a/frontend/src/components/QuestionContainer/QuestionContainer.tsx b/frontend/src/components/QuestionContainer/QuestionContainer.tsx new file mode 100644 index 0000000000..809e607ccf --- /dev/null +++ b/frontend/src/components/QuestionContainer/QuestionContainer.tsx @@ -0,0 +1,38 @@ +import { Chip, Typography } from "@mui/material"; +import { ReactElement } from "react"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; +import { Question } from "../../models/question.model"; +import Spinner from "../Spinner/Spinner"; +import "./QuestionContainer.scss"; + +const QuestionContainer = (props: { questionData: Question | null }): ReactElement => { + const { questionData } = props; + + return ( +
+ {questionData ? ( +
+ + {questionData?.title} + + +
+ + + window.open(questionData?.link, "_blank")} + icon={} + /> +
+
+ ) : ( + + )} +
+ ); +}; + +export default QuestionContainer; diff --git a/frontend/src/components/RecentSessions/RecentSessions.tsx b/frontend/src/components/RecentSessions/RecentSessions.tsx index 5f4224b089..f5158dbe74 100644 --- a/frontend/src/components/RecentSessions/RecentSessions.tsx +++ b/frontend/src/components/RecentSessions/RecentSessions.tsx @@ -11,7 +11,7 @@ import Spinner from "../Spinner/Spinner"; const RecentSessions = (): ReactElement => { const { user } = useContext(UserContext); - const { setSessionState, setQuestionId, setRoomNumber } = useContext(SessionContext); + const { setSessionState, setQuestionId, setRoomNumber, setOtherUserId } = useContext(SessionContext); const { setMainDialogTitle, setMainDialogContent, openMainDialog } = useMainDialog(); const navigate = useNavigate(); const [sessionHistory, setSessionHistory] = useState([]); @@ -48,6 +48,7 @@ const RecentSessions = (): ReactElement => { setSessionState(SessionState.SUCCESS); setQuestionId(rejoinResponse.questionId); setRoomNumber(rejoinResponse.roomNumber); + setOtherUserId(rejoinResponse.otherUserId); navigate(`/code-editor/${rejoinResponse.roomNumber}`); } catch (error) { if (error instanceof AxiosError) { diff --git a/frontend/src/components/TestCases/TestCases.tsx b/frontend/src/components/TestCases/TestCases.tsx index 6a594d3156..e0d3a9f23d 100644 --- a/frontend/src/components/TestCases/TestCases.tsx +++ b/frontend/src/components/TestCases/TestCases.tsx @@ -3,39 +3,59 @@ import { Typography, Button, TextField, IconButton } from "@mui/material"; import CloseIcon from "@mui/icons-material/Close"; import "./TestCases.scss"; import { EditNote } from "@mui/icons-material"; - -interface TestCase { - id: string; - number: number; - input: string; - expectedOutput: string; - actualOutput?: { - output: string | null; - error: string | null; - isCorrect: boolean | null; - }; - isSubmitted?: boolean; -} +import { TestCase } from "../../models/question.model"; interface TestCasesProps { givenTestCases: TestCase[]; customTestCases: TestCase[]; - addTestCase: () => void; - updateTestCase: (id: string, field: "input" | "expectedOutput", value: string) => void; - unsubmitTestCase: (id: string) => void; - submitTestCase: (id: string) => void; - deleteTestCase: (id: string) => void; + setCustomTestCases: React.Dispatch>; } -const TestCases: React.FC = ({ - givenTestCases, - customTestCases, - addTestCase, - updateTestCase, - unsubmitTestCase, - submitTestCase, - deleteTestCase, -}) => { +const TestCases: React.FC = ({ givenTestCases, customTestCases, setCustomTestCases }) => { + const addTestCase = () => { + if (givenTestCases.length + customTestCases.length >= 5) { + // Adjust the limit as needed + alert("You can only add up to 5 test cases."); + return; + } + const newTestCase: TestCase = { + id: `user-${Date.now()}`, + number: givenTestCases.length + customTestCases.length + 1, + input: "", + expectedOutput: "", + isSubmitted: false, + }; + setCustomTestCases([...customTestCases, newTestCase]); + }; + + // Function to update a test case field + const updateTestCase = (id: string, field: "input" | "expectedOutput", value: string) => { + const updatedTestCases = customTestCases.map((tc) => (tc.id === id ? { ...tc, [field]: value } : tc)); + setCustomTestCases(updatedTestCases); + }; + + // Make a custom test case editable + const unsubmitTestCase = (id: string) => { + setCustomTestCases(customTestCases.map((tc) => (tc.id === id ? { ...tc, isSubmitted: false } : tc))); + }; + + // Function to submit a test case (mark as submitted) + const submitTestCase = (id: string) => { + const updatedTestCases = customTestCases.map((tc) => (tc.id === id ? { ...tc, isSubmitted: true } : tc)); + setCustomTestCases(updatedTestCases); + }; + + // Function to delete a test case + const deleteTestCase = (id: string) => { + const updatedTestCases = customTestCases.filter((tc) => tc.id !== id); + // Re-number the remaining test cases + const renumberedTestCases = updatedTestCases.map((tc, index) => ({ + ...tc, + number: givenTestCases.length + index + 1, + })); + setCustomTestCases(renumberedTestCases); + }; + return (
diff --git a/frontend/src/models/question.model.ts b/frontend/src/models/question.model.ts index 2a478566a1..38ac7e1275 100644 --- a/frontend/src/models/question.model.ts +++ b/frontend/src/models/question.model.ts @@ -23,3 +23,16 @@ export interface Question { output: string; }[]; } + +export interface TestCase { + id: string; + number: number; + input: string; + expectedOutput: string; + actualOutput?: { + output: string | null; + error: string | null; + isCorrect: boolean | null; + }; + isSubmitted?: boolean; +} diff --git a/frontend/src/pages/CodeEditor/CodeEditor.scss b/frontend/src/pages/CodeEditor/CodeEditor.scss index 28947842ab..3314302dc0 100644 --- a/frontend/src/pages/CodeEditor/CodeEditor.scss +++ b/frontend/src/pages/CodeEditor/CodeEditor.scss @@ -14,145 +14,6 @@ background: var(--Grey-10, #1a1a1a); } -.top-section { - margin-bottom: 20px; - - .question-title { - margin-bottom: 10px; - color: #e4e4e4; - display: flex; - align-items: center; - } - - .details { - display: flex; - gap: 10px; - - .light-grey-chip { - background-color: #373737; - color: #ffffff; - border-radius: 6px; - } - } -} - -.editors { - display: flex; - - .left-side { - flex: 0.4; - padding: 10px; - color: #fff; - display: flex; - flex-direction: column; - - .md-editor { - background-color: #1e1e1e; - border-radius: 10px; - border: 1px solid #3c3c3c; - padding: 20px; - color: #fff; - height: 500px; - overflow: auto; - } - } - - .right-side { - flex: 0.6; - padding: 10px; - color: #fff; - display: flex; - flex-direction: column; - - .header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 10px; - - .language-select { - background-color: #1e1e1e; - color: #fff; - border: 1px solid #3c3c3c; - padding: 5px; - border-radius: 5px; - } - - .submit-button { - background-color: #caff33; - color: #121212; - border-radius: 5px; - - &.disabled { - color: #6c757d; - } - } - } - - .code-editor { - background-color: #1e1e1e; - border-radius: 10px; - border: 1px solid #3c3c3c; - color: #fff; - overflow: hidden; - } - } -} - -.test-cases-box { - margin-top: 20px; - background-color: #2e2e2e; - padding: 20px; - border-radius: 10px; - border: 1px solid #3c3c3c; - color: #fff; - - .test-cases-title { - margin-bottom: 20px; - } - - .test-cases-list { - display: flex; - flex-direction: column; - gap: 15px; - } - - .test-case { - background-color: #1e1e1e; - padding: 15px; - border-radius: 8px; - border: 1px solid #3c3c3c; - - .test-case-header { - margin-bottom: 10px; - - .test-case-number { - font-weight: bold; - color: #caff33; - } - } - - .test-case-content { - display: flex; - flex-direction: column; - gap: 8px; - - .test-case-field { - display: flex; - - .field-label { - font-weight: bold; - width: 150px; - } - - .field-value { - color: #e4e4e4; - } - } - } - } -} - .ai-hint-button { position: fixed; top: 20px; diff --git a/frontend/src/pages/CodeEditor/CodeEditor.tsx b/frontend/src/pages/CodeEditor/CodeEditor.tsx index b00c5f2310..22aa5b06f4 100644 --- a/frontend/src/pages/CodeEditor/CodeEditor.tsx +++ b/frontend/src/pages/CodeEditor/CodeEditor.tsx @@ -1,24 +1,14 @@ -import React, { useState, useEffect, useRef, useMemo, useContext } from "react"; -import MDEditor from "@uiw/react-md-editor"; -import { Button, Chip, Typography } from "@mui/material"; -import CodeMirror from "@uiw/react-codemirror"; -import { okaidia } from "@uiw/codemirror-theme-okaidia"; -import { python } from "@codemirror/lang-python"; -import { cpp } from "@codemirror/lang-cpp"; -import { java } from "@codemirror/lang-java"; +import React, { useState, useEffect, useRef, useContext } from "react"; +import { Button, Typography } from "@mui/material"; import Chatbox from "../../components/Chatbox/Chatbox"; import VideoCall from "../../components/VideoCall/VideoCall"; import HintBox from "../../components/HintBox/HintBox"; -import OpenInNewIcon from "@mui/icons-material/OpenInNew"; import VideoCallIcon from "@mui/icons-material/VideoCall"; import LightbulbIcon from "@mui/icons-material/Lightbulb"; -import { autocompletion } from "@codemirror/autocomplete"; import ChatIcon from "@mui/icons-material/Chat"; import io, { Socket } from "socket.io-client"; import "./CodeEditor.scss"; import { useNavigate, useParams } from "react-router-dom"; -import { Decoration, EditorView, WidgetType } from "@codemirror/view"; -import { RangeSetBuilder, Extension } from "@codemirror/state"; import QuestionService from "../../services/question.service"; import { UserContext } from "../../contexts/UserContext"; import { ChatMessage } from "../../models/communication.model"; @@ -27,130 +17,45 @@ import { useConfirmationDialog } from "../../contexts/ConfirmationDialogContext" import Peer, { MediaConnection } from "peerjs"; import TestCases from "../../components/TestCases/TestCases"; import { Circle } from "@mui/icons-material"; -import SessionService, { Language } from "../../services/session.service"; -import { AxiosError } from "axios"; -import { useMainDialog } from "../../contexts/MainDialogContext"; +import { Question, TestCase } from "../../models/question.model"; +import QuestionContainer from "../../components/QuestionContainer/QuestionContainer"; +import CodeContainer from "../../components/CodeContainer/CodeContainer"; const COMMUNICATION_WEBSOCKET_URL = process.env.REACT_APP_COMMUNICATION_SERVICE_URL as string; -const COLLABORATION_WEBSOCKET_URL = process.env.REACT_APP_COLLABORATION_SERVICE_URL as string; const VIDEO_PEER_SERVICE_PORT = process.env.REACT_APP_VIDEO_SERVICE_PORT; -// Define the CursorWidget -class CursorWidget extends WidgetType { - color: string; - - constructor(color: string) { - super(); - this.color = color; - } - - toDOM() { - const cursor = document.createElement("span"); - cursor.style.borderLeft = `2px solid ${this.color}`; - cursor.style.marginLeft = "-1px"; - cursor.style.height = "1em"; - cursor.className = "remote-cursor"; - return cursor; - } - - ignoreEvent() { - return true; - } -} - -// Function to create decorations -const createCursorDecorations = (otherCursors: { - [sid: string]: { cursor_position: number; color: string }; -}): Extension => { - return EditorView.decorations.of((view) => { - const builder = new RangeSetBuilder(); - for (const [sid, cursor] of Object.entries(otherCursors)) { - const { cursor_position, color } = cursor; - if (typeof cursor_position === "number") { - // Ensure cursor_position is a number - const decoration = Decoration.widget({ - widget: new CursorWidget(color), - side: 0, - }); - builder.add(cursor_position, cursor_position, decoration); - } else { - console.warn(`Invalid cursor_position for sid ${sid}:`, cursor_position); - } - } - return builder.finish(); - }); -}; - -interface QuestionData { - questionId: Number; - title: string; - description: string; - categories: string[]; - complexity: "Easy" | "Medium" | "Hard"; - link: string; - testCases: { - input: string; - output: string; - }[]; -} - -interface TestCase { - id: string; // Unique identifier - number: number; - input: string; - expectedOutput: string; - actualOutput?: { - output: string | null; - error: string | null; - isCorrect: boolean | null; - }; - isSubmitted?: boolean; -} - const CodeEditor: React.FC = () => { const { user } = useContext(UserContext); const { sessionState, questionId, clearSession, otherUserId, otherUserProfile } = useContext(SessionContext); const { setConfirmationDialogTitle, setConfirmationDialogContent, setConfirmationCallBack, openConfirmationDialog } = useConfirmationDialog(); - const { setMainDialogTitle, setMainDialogContent, openMainDialog } = useMainDialog(); const navigate = useNavigate(); - const [questionData, setQuestionData] = useState(null); - - // Use state + ref combination to handle real-time state change + socket events - const [code, setCode] = useState("# Write your solution here\n"); - const [language, setLanguage] = useState("python"); - const codeRef = useRef(code); - const languageRef = useRef(language); - - useEffect(() => { - codeRef.current = code; - }, [code]); + const { roomNumber } = useParams(); - useEffect(() => { - languageRef.current = language; - }, [language]); + // State for question and test cases + const [questionData, setQuestionData] = useState(null); + const [givenTestCases, setGivenTestCases] = useState([]); + const [customTestCases, setCustomTestCases] = useState([]); - const { roomNumber } = useParams(); - const [joinedRoom, setJoinedRoom] = useState(false); // New state - const [isHintBoxExpanded, setIsHintBoxExpanded] = useState(false); // New state + // States for UI display + const [isHintBoxExpanded, setIsHintBoxExpanded] = useState(false); const [isChatboxExpanded, setIsChatboxExpanded] = useState(false); const [isVideoCallExpanded, setIsVideoCallExpanded] = useState(false); - const [chatHistory, setChatHistory] = useState([]); - const chatHistoryRef = useRef([]); // For updating state of chatHistory const [hasNewChatMessage, setHasNewChatMessage] = useState(false); const [hasNewVideoCall, setHasNewVideoCall] = useState(false); + // States for communication data display + const [chatHistory, setChatHistory] = useState([]); + const chatHistoryRef = useRef([]); // For updating state of chatHistory const myStream = useRef(null); - const remoteVideoRef = useRef(null); // To keep the video playing in the collapsed video call window, necessary to create a duplicate ref // Cannot use the same ref for both elements, because ref is recreated in the DOM const collapsedRemoteVideoRef = useRef(null); - const myVideoRef = useRef(null); - const collaborationSocketRef = useRef(null); + // States for communication connection and data transfer const communicationSocketRef = useRef(null); const peerInstanceRef = useRef(); const mediaConnectionRef = useRef(); @@ -158,39 +63,7 @@ const CodeEditor: React.FC = () => { const [isVideoEnabled, setIsVideoEnabled] = useState(true); const [isAudioEnabled, setIsAudioEnabled] = useState(true); - const lastCursorPosition = useRef(null); - const [otherCursors, setOtherCursors] = useState<{ - [sid: string]: { cursor_position: number; color: string }; - }>({}); - - const languageExtensions: { [key in Language]: Extension[] } = { - python: [python(), autocompletion()], - cpp: [cpp(), autocompletion()], - java: [java(), autocompletion()], - }; - - const userColors = [ - "#FF5733", // Red - "#33FF57", // Green - "#3357FF", // Blue - "#F333FF", // Pink - "#FF33F3", // Magenta - "#33FFF3", // Cyan - "#FFA533", // Orange - "#A533FF", // Purple - "#33A5FF", // Light Blue - "#33FF99", // Light Green - ]; - - const getColorForUser = (sid: string): string => { - let hash = 0; - for (let i = 0; i < sid.length; i++) { - hash = sid.charCodeAt(i) + ((hash << 5) - hash); - } - const index = Math.abs(hash) % userColors.length; - return userColors[index]; - }; - + // On load, fetch question data useEffect(() => { const fetchQuestionData = async () => { try { @@ -218,143 +91,31 @@ const CodeEditor: React.FC = () => { } }, [questionId, sessionState]); - const appendToChatHistory = (newMessage: ChatMessage) => { - setChatHistory([...chatHistoryRef.current, newMessage]); - }; - + // On load, set up communication socket and peer connection useEffect(() => { - chatHistoryRef.current = chatHistory; - }, [chatHistory]); - - const clearSocketsAndPeer = () => { - if (collaborationSocketRef.current) { - collaborationSocketRef.current.disconnect(); - console.log("Disconnected from collaboration websocket server."); - } - if (communicationSocketRef.current) { - communicationSocketRef.current.disconnect(); - console.log("Disconnected from communication websocket server."); + // set up websockets + if (!roomNumber) { + return; } - myStream.current?.getTracks().forEach((track) => track.stop()); - myStream.current = null; - mediaConnectionRef.current?.close(); - peerInstanceRef.current?.destroy(); - }; - - const chooseLeaveSession = () => { - setConfirmationDialogTitle("Leave Session"); - setConfirmationDialogContent("Are you sure you want to leave the session?"); - setConfirmationCallBack(() => () => { - SessionService.leaveSession(user?.id as string, roomNumber!); - clearSocketsAndPeer(); - clearSession(); - navigate("/"); - }); - openConfirmationDialog(); - }; - const setUpCollaborationSocket = (token: string) => { - // Collaboration socket for code editing - const socket = io(COLLABORATION_WEBSOCKET_URL, { - extraHeaders: { - Authorization: `${token}`, - }, - }); - collaborationSocketRef.current = socket; - - socket.on("connect", () => { - console.log("Connected to socket.io server."); - socket.emit("join_request", { room_id: roomNumber }); - }); - - socket.on("join_request", (data: any) => { - console.log("Received join_request data:", data); - if (data?.user_id && data.user_id === user?.id) { - // Current user successfully joined a room - setJoinedRoom(true); - } else { - // emit current code and cursor for any new user joining the room - console.log("emitting"); - socket.emit("language_change", { language: languageRef.current, room_id: roomNumber }); - socket.emit("code_updated", { code: codeRef.current }); - socket.emit("cursor_updated", { cursor_position: lastCursorPosition.current }); - } - }); - - socket.on("language_change", (newLanguage: string) => { - if (["python", "cpp", "javascript", "java"].includes(newLanguage)) { - setLanguage(newLanguage as Language); - if (newLanguage === "cpp") { - setCode( - "#include \nusing namespace std;\n\nint main() {\n\t// Write your solution here\n\treturn 0;\n}", - ); - } else if (newLanguage === "java") { - setCode( - "public class Main {\n\tpublic static void main(String[] args) {\n\t\t// Write your solution here\n\t}\n}", - ); - } else { - setCode("# Write your solution here\n"); - } - } else { - console.warn(`Unsupported language received: ${newLanguage}`); - } - }); - - // Handle real-time code updates - socket.on("code_updated", (newCode: string) => { - setCode(newCode); - }); - - // Handle cursor updates - socket.on("cursor_updated", (userDetails: any) => { - const { sid, cursor_position } = userDetails; - if (sid === socket.id) return; // Ignore own cursor - - if (typeof cursor_position !== "number") { - console.error(`Invalid cursor_position for sid ${sid}:`, cursor_position); - return; - } - - setOtherCursors((prev) => ({ - ...prev, - [sid]: { - cursor_position, - color: getColorForUser(sid), - }, - })); - }); - - // Handle user disconnection to remove their cursor - socket.on("user_disconnected", (sid: string) => { - console.log(`User disconnected: ${sid}`); - setOtherCursors((prev) => { - const newCursors = { ...prev }; - delete newCursors[sid]; - return newCursors; - }); - }); + const token = localStorage.getItem("jwt-token"); + if (!token) { + console.error("No JWT token found in localStorage."); + return; + } - // Handle socket errors - socket.on("error", (error: any) => { - console.error("Socket error:", error); - }); + setUpCommunicationSocket(token); + setUpVideoPeerConnection(token); - socket.on("user_left", (uid: string) => { - if (user && uid !== user.id) { - setConfirmationDialogTitle("Partner Disconnected"); - setConfirmationDialogContent( - "Your partner has left the coding session. Would you like to end the session and return to home page?", - ); - setConfirmationCallBack(() => () => { - SessionService.leaveSession(user?.id as string, roomNumber!); - clearSocketsAndPeer(); - clearSession(); - navigate("/"); - }); - openConfirmationDialog(); - } - }); - }; + // Cleanup on component unmount + return () => { + communicationSocketRef.current?.disconnect(); + myStream.current?.getTracks().forEach((track) => track.stop()); + myStream.current = null; + mediaConnectionRef.current?.close(); + peerInstanceRef.current?.destroy(); + }; + }, [roomNumber]); const setUpCommunicationSocket = (token: string) => { // Communication socket for chat @@ -439,28 +200,6 @@ const CodeEditor: React.FC = () => { }); }; - useEffect(() => { - // set up websockets - if (!roomNumber) { - return; - } - - const token = localStorage.getItem("jwt-token"); - if (!token) { - console.error("No JWT token found in localStorage."); - return; - } - - setUpCollaborationSocket(token); - setUpCommunicationSocket(token); - setUpVideoPeerConnection(token); - - // Cleanup on component unmount - return () => { - clearSocketsAndPeer(); - }; - }, [roomNumber]); - const getUserMediaStream = async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ @@ -528,18 +267,6 @@ const CodeEditor: React.FC = () => { } }; - useEffect(() => { - myStream.current?.getVideoTracks().forEach((track) => { - track.enabled = isVideoEnabled; - }); - }, [isVideoEnabled]); - - useEffect(() => { - myStream.current?.getAudioTracks().forEach((track) => { - track.enabled = isAudioEnabled; - }); - }, [isAudioEnabled]); - const hangUpVideoCall = () => { myStream.current?.getTracks().forEach((track) => track.stop()); myStream.current = null; @@ -548,260 +275,64 @@ const CodeEditor: React.FC = () => { setHasNewVideoCall(false); }; - const handleLanguageChange = (e: React.ChangeEvent) => { - const newLanguage = e.target.value as Language; - if (["python", "cpp", "javascript", "java"].includes(newLanguage)) { - setLanguage(newLanguage); - if (newLanguage === "cpp") { - setCode( - "#include \nusing namespace std;\n\nint main() {\n\t// Write your solution here\n\treturn 0;\n}", - ); - } else if (newLanguage === "java") { - setCode( - "public class Main {\n\tpublic static void main(String[] args) {\n\t\t// Write your solution here\n\t}\n}", - ); - } else { - setCode("# Write your solution here\n"); - } - if (joinedRoom) { - collaborationSocketRef.current?.emit("language_change", { - language: newLanguage, - room_id: roomNumber, - }); - } - } else { - console.warn(`Attempted to set unsupported language: ${newLanguage}`); - } - }; - - const handleCodeChange = (value: string) => { - setCode(value); - if (joinedRoom) { - // Emit only if joined - collaborationSocketRef.current?.emit("code_updated", { code: value }); - } - }; - - const handleCursorChange = (viewUpdate: any) => { - const cursorPosition = viewUpdate.state.selection.main.head; - if (cursorPosition !== lastCursorPosition.current) { - lastCursorPosition.current = cursorPosition; - collaborationSocketRef.current?.emit("cursor_updated", { - cursor_position: cursorPosition, - }); - } - }; - - const cursorDecorationsExtension = useMemo(() => { - return createCursorDecorations(otherCursors); - }, [otherCursors]); - - // State for all test cases - const [givenTestCases, setGivenTestCases] = useState([]); - const [customTestCases, setCustomTestCases] = useState([]); - const [isExecuting, setIsExecuting] = useState(false); - - const addTestCase = () => { - if (givenTestCases.length + customTestCases.length >= 5) { - // Adjust the limit as needed - alert("You can only add up to 5 test cases."); - return; - } - const newTestCase: TestCase = { - id: `user-${Date.now()}`, - number: givenTestCases.length + customTestCases.length + 1, - input: "", - expectedOutput: "", - isSubmitted: false, - }; - setCustomTestCases([...customTestCases, newTestCase]); - }; - - // Function to update a test case field - const updateTestCase = (id: string, field: "input" | "expectedOutput", value: string) => { - const updatedTestCases = customTestCases.map((tc) => (tc.id === id ? { ...tc, [field]: value } : tc)); - setCustomTestCases(updatedTestCases); - }; - - // Make a custom test case editable - const unsubmitTestCase = (id: string) => { - setCustomTestCases(customTestCases.map((tc) => (tc.id === id ? { ...tc, isSubmitted: false } : tc))); - }; - - // Function to submit a test case (mark as submitted) - const submitTestCase = (id: string) => { - const updatedTestCases = customTestCases.map((tc) => (tc.id === id ? { ...tc, isSubmitted: true } : tc)); - setCustomTestCases(updatedTestCases); - }; - - // Function to delete a test case - const deleteTestCase = (id: string) => { - const updatedTestCases = customTestCases.filter((tc) => tc.id !== id); - // Re-number the remaining test cases - const renumberedTestCases = updatedTestCases.map((tc, index) => ({ - ...tc, - number: givenTestCases.length + index + 1, - })); - setCustomTestCases(renumberedTestCases); + const appendToChatHistory = (newMessage: ChatMessage) => { + setChatHistory([...chatHistoryRef.current, newMessage]); }; - // Function to execute the code against all test cases - const executeCode = async () => { - if (!questionData) { - alert("No question data available."); - return; - } - - const submittedTestCases = customTestCases.filter((tc) => tc.isSubmitted); - - // Prepare payload for the API - const payload = { - lang: languageRef.current, - code: codeRef.current, - customTests: submittedTestCases.map((tc) => ({ - input: tc.input, - output: tc.expectedOutput || null, - })), - }; - - try { - setIsExecuting(true); - const response = await QuestionService.test(questionId, payload); - setIsExecuting(false); - // Assuming the API returns an array of actual outputs corresponding to the test cases - const { outputs } = response; - - if (!Array.isArray(outputs) || outputs.length !== givenTestCases.length + submittedTestCases.length) { - console.error("Invalid response from code execution API:", response); - alert("Invalid response from code execution API."); - return; - } - - // Update actual outputs in test cases - const updatedCustomTestCases = customTestCases.map((tc) => { - const submissionIndex = submittedTestCases.findIndex((stc) => stc.id === tc.id); - if (submissionIndex !== -1) { - return { - ...tc, - actualOutput: outputs[submissionIndex], - }; - } - return tc; - }); - setCustomTestCases(updatedCustomTestCases); + useEffect(() => { + chatHistoryRef.current = chatHistory; + }, [chatHistory]); - const updatedGivenTestCases = givenTestCases.map((tc, i) => { - return { - ...tc, - actualOutput: outputs[i], - }; - }); - setGivenTestCases(updatedGivenTestCases); - } catch (error) { - console.error("Error executing code:", error); - alert("An error occurred while executing the code."); - } - }; + useEffect(() => { + myStream.current?.getVideoTracks().forEach((track) => { + track.enabled = isVideoEnabled; + }); + }, [isVideoEnabled]); - const submitAndEndSession = async () => { - try { - setConfirmationDialogTitle("Submit and end session"); - setConfirmationDialogContent( - "You are about to submit your code and end the session for both you and your partner. Are you sure?", - ); - setConfirmationCallBack(() => async () => { - await SessionService.submitSession(user?.id as string, roomNumber!, codeRef.current, languageRef.current); - clearSocketsAndPeer(); - clearSession(); - navigate("/"); - }); - openConfirmationDialog(); - } catch (error) { - setMainDialogTitle("Error"); - setMainDialogContent( - error instanceof AxiosError && error.response?.data.message - ? error.response?.data.message - : "An error occurred while submitting the code.", - ); - openMainDialog(); - } - }; + useEffect(() => { + myStream.current?.getAudioTracks().forEach((track) => { + track.enabled = isAudioEnabled; + }); + }, [isAudioEnabled]); return (
-
- - {questionData?.title} - + -
- - - window.open(questionData?.link, "_blank")} - icon={} - /> -
-
- -
-
- -
- -
-
- -
- - -
-
- handleCursorChange(viewUpdate)} - theme={okaidia} - /> -
-
+ {/* Test Cases Section */}
-
@@ -815,6 +346,7 @@ const CodeEditor: React.FC = () => {
)} + {/* Chatbox component */} {isChatboxExpanded && ( { @@ -828,6 +360,7 @@ const CodeEditor: React.FC = () => { /> )} + {/* Floating video call icon */} {!isVideoCallExpanded && !myStream.current && (
@@ -835,6 +368,7 @@ const CodeEditor: React.FC = () => {
)} + {/* Collapsed video call component */}
{
+ {/* Expanded video call component */}
{ socket.on("matched", (data: any) => { console.log("Matched with: ", data.matchedWith); - console.log("Received roomNumber: ", data.roomNumber); setOtherUserId(data.matchedWith); - setRoomNumber(data.roomNumber); // Use server-assigned room number - setQuestionId(data.questionId); accumulateMatchingTime(); - console.log("QuestionId", data.questionId); setSessionState(SessionState.PENDING); }); @@ -89,7 +85,7 @@ const Interview = (): ReactElement => { setOtherUserDeclined(true); }); - socket.on("matching_success", () => { + socket.on("matching_success", (data: any) => { console.log("Matching succeeded"); if (!userAccepted) { setUserAccepted(true); @@ -97,6 +93,8 @@ const Interview = (): ReactElement => { if (!otherUserAccepted) { setOtherUserAccepted(true); } + setRoomNumber(data.roomNumber); + setQuestionId(data.questionId); setTimeout(() => { setSessionState(SessionState.SUCCESS); }, 1000); @@ -144,6 +142,7 @@ const Interview = (): ReactElement => { setSessionState(SessionState.SUCCESS); setQuestionId(rejoinResponse.questionId); setRoomNumber(rejoinResponse.roomNumber); + setOtherUserId(rejoinResponse.otherUserId); navigate(`/code-editor/${rejoinResponse.roomNumber}`); } catch (error) { if (error instanceof AxiosError) {