diff --git a/backend/communication-service/Dockerfile b/backend/communication-service/Dockerfile index dce6d35ad9..cb060f16ec 100644 --- a/backend/communication-service/Dockerfile +++ b/backend/communication-service/Dockerfile @@ -10,6 +10,6 @@ COPY . . RUN npx prisma migrate dev --name init -EXPOSE 3004 +EXPOSE 3004 9000 CMD ["npm", "run", "start"] \ No newline at end of file diff --git a/backend/matching-service/.gitignore b/backend/matching-service/.gitignore index d6a0bec552..3664402d71 100644 --- a/backend/matching-service/.gitignore +++ b/backend/matching-service/.gitignore @@ -35,4 +35,5 @@ yarn.lock *.swp *~ dev.db +dev.db-journal migrations/ \ No newline at end of file diff --git a/backend/matching-service/package-lock.json b/backend/matching-service/package-lock.json index 07b13027d7..3d5c4b825d 100644 --- a/backend/matching-service/package-lock.json +++ b/backend/matching-service/package-lock.json @@ -13,6 +13,7 @@ "amqplib": "^0.10.4", "axios": "^1.7.7", "body-parser": "^1.20.3", + "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.1", "jwt-decode": "^4.0.0", diff --git a/backend/matching-service/package.json b/backend/matching-service/package.json index a380d538d8..481ed7bacb 100644 --- a/backend/matching-service/package.json +++ b/backend/matching-service/package.json @@ -15,6 +15,7 @@ "amqplib": "^0.10.4", "axios": "^1.7.7", "body-parser": "^1.20.3", + "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.1", "jwt-decode": "^4.0.0", diff --git a/backend/matching-service/src/matchingService.ts b/backend/matching-service/src/matchingService.ts index f9676639f3..e98f27aa72 100644 --- a/backend/matching-service/src/matchingService.ts +++ b/backend/matching-service/src/matchingService.ts @@ -3,6 +3,7 @@ import { io } from "./server"; import { PrismaClient } from "@prisma/client"; import { v4 as uuidv4 } from "uuid"; import { fetchRandomQuestion } from "./util"; +import exp from "constants"; const prisma = new PrismaClient(); @@ -11,7 +12,7 @@ const CONFIRM_DELAY_TIME = 10000; export async function handleMatchingRequest( userRequest: any, - socketId: string, + socketId: string ) { userRequest.socketId = socketId; @@ -36,11 +37,11 @@ function sendConfirmDelayedTimeoutMessage(recordId: string) { recordId: recordId, type: "confirm_timeout", }, - CONFIRM_DELAY_TIME, + CONFIRM_DELAY_TIME ); console.log( "Sent delayed message for confirm timeout for recordId: ", - recordId, + recordId ); } @@ -58,7 +59,7 @@ export async function handleUserRequest(userRequest: any) { console.log("Duplicate socket detected. New socket will be used."); io.to(pastSocketId).emit( "duplicate socket", - "New connection detected for the same user. Please close the current page", + "New connection detected for the same user. Please close the current page" ); // Update socket ID upon potential reconnection @@ -85,9 +86,19 @@ export async function handleUserRequest(userRequest: any) { if (existingMatch !== null) { const roomNumber = uuidv4(); const question = await fetchRandomQuestion(difficulty, topic); - console.log( - `Match found for ${userId} with ${existingMatch.userId} on topic ${topic}, difficulty ${difficulty}, roomNumber ${roomNumber}`, - ); + + if (!question) { + io.to(socketId).emit("question_error", { + message: "No Question found for the selected topic and difficulty", + }); + io.to(existingMatch.socketId).emit("question_error", { + message: "No Question found for the selected topic and difficulty", + }); + await prisma.matchRecord.delete({ + where: { recordId: existingMatch.recordId }, + }); + return; + } // Match found, update both records to mark as isPending await prisma.matchRecord.update({ where: { recordId: existingMatch.recordId }, @@ -103,26 +114,20 @@ export async function handleUserRequest(userRequest: any) { matchedUserId: existingMatch.userId, isPending: true, roomNumber, - questionId: question.questionId, + questionId: question?.questionId as number, }, }); - console.log( - `Matched ${userId} with ${existingMatch.userId} on topic ${topic}, difficulty ${difficulty}`, - ); - - console.log("Question matched with", question.questionId); - // Update both clients about the successful match io.to(socketId).emit("matched", { matchedWith: existingMatch.userId, roomNumber, - questionId: question.questionId, + questionId: question?.questionId, }); io.to(existingMatch.socketId).emit("matched", { matchedWith: userId, roomNumber, - questionId: question.questionId, + questionId: question?.questionId, }); // Add confirm timeout messages @@ -164,7 +169,7 @@ export async function handleMatchingConfirm(userRequest: any) { }); io.to(matchedRecord.socketId).emit( "other_declined", - "Match not confirmed. Please try again.", + "Match not confirmed. Please try again." ); } if (userRecord !== null) { @@ -174,7 +179,7 @@ export async function handleMatchingConfirm(userRequest: any) { }); io.to(userRecord.socketId).emit( "other_declined", - "Match not confirmed. Please try again.", + "Match not confirmed. Please try again." ); } return; @@ -206,20 +211,20 @@ export async function handleMatchingConfirm(userRequest: any) { io.to(userRecord.socketId).emit( "matching_success", - "Match confirmed. Proceeding to collaboration service.", + "Match confirmed. Proceeding to collaboration service." ); io.to(matchedRecord.socketId).emit( "matching_success", - "Match confirmed. Proceeding to collaboration service.", + "Match confirmed. Proceeding to collaboration service." ); // TODO: add further logic here to proceed to collaboration service } else { console.log( - `User ${userId} confirmed match, waiting for other user to confirm`, + `User ${userId} confirmed match, waiting for other user to confirm` ); io.to(matchedRecord.socketId).emit( "other_accepted", - "Other user confirmed match. Please confirm.", + "Other user confirmed match. Please confirm." ); } } @@ -243,11 +248,11 @@ export async function handleMatchingDecline(userRequest: any) { }); io.to(matchedRecord.socketId).emit( "other_declined", - "Match not confirmed. Please try again.", + "Match not confirmed. Please try again." ); io.to(matchedRecord.socketId).emit( "matching_fail", - "Match not confirmed. Please try again.", + "Match not confirmed. Please try again." ); } if (userRecord !== null) { @@ -257,11 +262,11 @@ export async function handleMatchingDecline(userRequest: any) { }); io.to(userRecord.socketId).emit( "other_declined", - "Match not confirmed. Please try again.", + "Match not confirmed. Please try again." ); io.to(userRecord.socketId).emit( "matching_fail", - "Match not confirmed. Please try again.", + "Match not confirmed. Please try again." ); } @@ -277,7 +282,7 @@ export async function handleMatchingDecline(userRequest: any) { console.log(`User ${userId} declined match`); io.to(matchedRecord.socketId).emit( "other_declined", - "Match not confirmed. Please try again.", + "Match not confirmed. Please try again." ); await prisma.matchRecord.update({ where: { recordId: matchedRecord.recordId }, @@ -286,11 +291,11 @@ export async function handleMatchingDecline(userRequest: any) { io.to(userRecord.socketId).emit( "matching_fail", - "Match not confirmed. Please try again.", + "Match not confirmed. Please try again." ); io.to(matchedRecord.socketId).emit( "matching_fail", - "Match not confirmed. Please try again.", + "Match not confirmed. Please try again." ); } @@ -323,16 +328,16 @@ export async function handleConfirmTimeout(recordId: string) { if (result !== null) { if (result.isConfirmed === false) { console.log( - `Timeout: Match not confirmed for recordId ${recordId} with userId ${result.userId}`, + `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`, + `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.", + "Match not confirmed. Please try again." ); await prisma.matchRecord.update({ where: { recordId: recordIdInt }, @@ -351,4 +356,4 @@ export async function handleDisconnected(socketId: string) { data: { isArchived: true }, }); } -} +} \ No newline at end of file diff --git a/backend/matching-service/src/model/question.ts b/backend/matching-service/src/model/question.ts new file mode 100644 index 0000000000..4dbf10f2ce --- /dev/null +++ b/backend/matching-service/src/model/question.ts @@ -0,0 +1,13 @@ +interface Question { + questionId: Number; + title: string; + description: string; + categories: string[]; + complexity: "Easy" | "Medium" | "Hard"; + link: string; + testCases: { + input: string; + output: string; + }[]; +} +export default Question; diff --git a/backend/matching-service/src/server.ts b/backend/matching-service/src/server.ts index a9e72ba02f..3063a1cc34 100644 --- a/backend/matching-service/src/server.ts +++ b/backend/matching-service/src/server.ts @@ -9,8 +9,46 @@ import { handleDisconnected, } from "./matchingService"; +import cors from "cors"; +import { PrismaClient } from "@prisma/client"; + const app = express(); +app.use(express.json()); + +app.use( + cors({ + origin: "*", + }) +); + +const prisma = new PrismaClient(); + +app.post("/check", async (req, res) => { + const { userId, roomId } = req.body.data; + + const record = await prisma.matchRecord.findFirst({ + where: { + roomNumber: roomId, + OR: [ + { + userId: userId, + }, + { + matchedUserId: userId, + }, + ], + matched: true, + }, + }); + + if (!record) { + res.status(200).json({ hasAccess: false }); + } else { + res.status(200).json({ hasAccess: true }); + } +}); + const server = createServer(app); export const io = new Server(server, { cors: { diff --git a/backend/matching-service/src/util.ts b/backend/matching-service/src/util.ts index 00f9bdfda8..761c698dd2 100644 --- a/backend/matching-service/src/util.ts +++ b/backend/matching-service/src/util.ts @@ -1,13 +1,21 @@ import axios from "axios"; import { configDotenv } from "dotenv"; +import Question from "./model/question"; configDotenv(); -const fetchRandomQuestion = async (complexity: string, categories: string) => { +const fetchRandomQuestion = async ( + complexity: string, + categories: string +): Promise => { const response = await axios.post(`${process.env.QUESTION_SERVICE}/random`, { complexity, categories, }); - return response.data; + if (response.status !== 200) { + return null; + } else { + return response.data; + } }; export { fetchRandomQuestion }; diff --git a/backend/question-service/src/routes/questionRoutes.ts b/backend/question-service/src/routes/questionRoutes.ts index 2d09c16b05..629087233b 100644 --- a/backend/question-service/src/routes/questionRoutes.ts +++ b/backend/question-service/src/routes/questionRoutes.ts @@ -38,7 +38,19 @@ router.post("/random", async (req: Request, res: Response) => { }); if (!question) { - return res.status(404).json({ message: "Question not found" }); + const otherQuestion = await Question.find({ + categories: { $all: categories }, + }); + + if (!otherQuestion) { + return res + .status(404) + .json({ message: "Question not found with given categories" }); + } else { + const randomQuestion = + otherQuestion[Math.floor(Math.random() * otherQuestion.length)]; + res.status(200).json(randomQuestion); + } } else { const randomQuestion = question[Math.floor(Math.random() * question.length)]; @@ -83,7 +95,7 @@ router.put("/:id", async (req: Request, res: Response) => { const updatedQuestion = await Question.findOneAndUpdate( { questionId: id }, { title, description, categories, complexity }, - { new: true }, + { new: true } ); if (!updatedQuestion) return res.status(404).json({ error: "Question not found" }); @@ -126,7 +138,7 @@ router.post("/test/:id", async (req: Request, res: Response) => { const tests = plainToInstance(Test, [req.body]); console.assert( tests.length === 1, - "tests must be an array of exactly 1 element", + "tests must be an array of exactly 1 element" ); const test = tests[0]; const errors = await validate(test); @@ -139,7 +151,7 @@ router.post("/test/:id", async (req: Request, res: Response) => { const testCases: TestCase[] = [...question.testCases, ...test.customTests]; const outputPromises = testCases.map((testCase) => - testCode(test.code, test.lang, testCase, authtoken), + testCode(test.code, test.lang, testCase, authtoken) ); try { const outputs = await Promise.all(outputPromises); diff --git a/docker-compose.yml b/docker-compose.yml index 6e5f65e67f..ad9c18b617 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,7 @@ services: code-execution-rabbitmq: image: rabbitmq:4.0-management ports: - - "7000:5672" + - "7004:5672" code-execution-redis: image: redis:alpine @@ -57,6 +57,7 @@ services: build: ./backend/communication-service ports: - "3004:3004" + - "9000:9000" matching: build: ./backend/matching-service diff --git a/frontend/.env.sample b/frontend/.env.sample index f2ec1511f6..d462a1f66e 100644 --- a/frontend/.env.sample +++ b/frontend/.env.sample @@ -4,8 +4,9 @@ PORT=3000 # REACT_APP_QUESTION_SERVICE_URL=http://localhost:3002 # REACT_APP_USER_SERVICE_URL=http://localhost:3001 # REACT_APP_MATCHING_SERVICE_URL=http://localhost:3003 -# REACT_APP_COLLABORATION_SERVICE_URL=http://localhost:3005 # REACT_APP_COMMUNICATION_SERVICE_URL=http://localhost:3004 +# REACT_APP_COLLABORATION_SERVICE_URL=http://localhost:3005 +# REACT_APP_VIDEO_SERVICE_PORT=9000 # In docker REACT_APP_QUESTION_SERVICE_URL=http://localhost/api/questions/ @@ -13,3 +14,4 @@ REACT_APP_USER_SERVICE_URL=http://localhost/api/users/ REACT_APP_MATCHING_SERVICE_URL=http://localhost:3003 REACT_APP_COMMUNICATION_SERVICE_URL=http://localhost:3004 REACT_APP_COLLABORATION_SERVICE_URL=http://localhost:3005 +REACT_APP_VIDEO_SERVICE_PORT=9000 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f8a4ef710e..6498bd9174 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,6 +15,7 @@ import Interview from "./pages/Interview/Interview"; import { SessionContextProvider } from "./contexts/SessionContext"; import VideoChat from "./pages/Communication/Commincation"; import CodeEditor from "./pages/CodeEditor/CodeEditor"; +import ProtectedRoute from "./util/ProtectedRoute"; const theme = createTheme({ typography: { @@ -82,8 +83,12 @@ const App = (): ReactElement => { } /> : } /> } /> - {/* } /> */} - } /> + } userId={user.id as string} /> : + } + /> diff --git a/frontend/src/components/VideoCall/VideoCall.scss b/frontend/src/components/VideoCall/VideoCall.scss index b5cde8231a..494fd15691 100644 --- a/frontend/src/components/VideoCall/VideoCall.scss +++ b/frontend/src/components/VideoCall/VideoCall.scss @@ -1,64 +1,73 @@ .video-call-expanded { position: fixed; - bottom: 10px; - right: 10px; - width: 400px; - height: 300px; - background-color: #2e2e2e; - border-radius: 10px; - border: 1px solid #3c3c3c; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: #1a1a1a; + z-index: 1000; display: flex; flex-direction: column; - z-index: 1000; - overflow: hidden; } .video-call-header { + padding: 1rem; + background-color: #2a2a2a; + color: white; display: flex; justify-content: space-between; align-items: center; - padding: 10px; - background-color: #373737; - border-bottom: 1px solid #3c3c3c; - color: #fff; -} - -.video-call-close-button { - background-color: #ff3333; - color: #fff; } .video-call-container { - flex-grow: 1; + flex: 1; display: flex; flex-direction: column; - position: relative; + padding: 1rem; + gap: 1rem; +} - .video-streams { - flex-grow: 1; - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; - background-color: #2e2e2e; - position: relative; +.video-grid { + flex: 1; + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; + padding: 1rem; - .local-video, - .remote-video { - width: 100%; - height: 100%; - background-color: black; - border-radius: 0px; - object-fit: cover; - } + @media (max-width: 768px) { + grid-template-columns: 1fr; } +} - .hangup-button { - position: absolute; - bottom: 10px; - right: 10px; - background-color: #ff3333; - color: #ffffff; - border-radius: 5px; - } +.video-box { + position: relative; + aspect-ratio: 16/9; + background-color: #2a2a2a; + border-radius: 8px; + overflow: hidden; +} + +.video-stream { + width: 100%; + height: 100%; + object-fit: cover; +} + +.video-label { + position: absolute; + bottom: 0.5rem; + left: 0.5rem; + color: white; + background-color: rgba(0, 0, 0, 0.5); + padding: 0.25rem 0.5rem; + border-radius: 4px; +} + +.video-controls { + display: flex; + justify-content: center; + gap: 1rem; + padding: 1rem; + background-color: #2a2a2a; + border-radius: 8px; } diff --git a/frontend/src/components/VideoCall/VideoCall.tsx b/frontend/src/components/VideoCall/VideoCall.tsx index 2f44a80a34..1ece862c9f 100644 --- a/frontend/src/components/VideoCall/VideoCall.tsx +++ b/frontend/src/components/VideoCall/VideoCall.tsx @@ -1,165 +1,87 @@ -import React, { useEffect, useRef, useState, useContext } from "react"; -import { Typography, Button } from "@mui/material"; +import React, { useContext } from "react"; +import { Button, Typography } from "@mui/material"; +import Peer, { MediaConnection } from "peerjs"; import "./VideoCall.scss"; -import Peer from "peerjs"; -import { Socket } from "socket.io-client"; import { UserContext } from "../../contexts/UserContext"; +import { SessionContext } from "../../contexts/SessionContext"; interface VideoCallProps { onClose: () => void; - communicationSocketRef: React.MutableRefObject; - roomNumber: string; + setIsVideoCallExpanded: React.Dispatch>; + setIsVideoEnabled: React.Dispatch>; + setIsAudioEnabled: React.Dispatch>; + peerInstanceRef: React.MutableRefObject; + mediaConnectionRef: React.MutableRefObject; + myVideoRef: React.MutableRefObject; + remoteVideoRef: React.MutableRefObject; + isOtherUserStreaming: boolean; + isVideoEnabled: boolean; + isAudioEnabled: boolean; } -const VideoCall: React.FC = ({ onClose, communicationSocketRef, roomNumber }) => { - const [isVideoHovered, setIsVideoHovered] = useState(false); - const [localStream, setLocalStream] = useState(null); - const [remoteStreams, setRemoteStreams] = useState<{ [peerId: string]: MediaStream }>({}); - const [peerId, setPeerId] = useState(""); - const peerRef = useRef(null); +const VideoCall: React.FC = ({ + onClose, + setIsVideoCallExpanded, + setIsVideoEnabled, + setIsAudioEnabled, + mediaConnectionRef, + myVideoRef, + remoteVideoRef, + isOtherUserStreaming, + isVideoEnabled, + isAudioEnabled, +}) => { const { user } = useContext(UserContext); + const { otherUserProfile } = useContext(SessionContext); - const localVideoRef = useRef(null); - const remoteVideoRefs = useRef<{ [peerId: string]: HTMLVideoElement }>({}); - - useEffect(() => { - // Initialize PeerJS - const peer = new Peer('', { - host: process.env.REACT_APP_PEERJS_SERVER_HOST || "/", - port: parseInt(process.env.REACT_APP_PEERJS_SERVER_PORT || "9000"), - path: "/peerjs", - secure: process.env.REACT_APP_PEERJS_SECURE === "true", - }); - peerRef.current = peer; - - let currentStream: MediaStream; - - // Get local media stream - navigator.mediaDevices - .getUserMedia({ video: true, audio: true }) - .then((stream) => { - setLocalStream(stream); - currentStream = stream; - - if (localVideoRef.current) { - localVideoRef.current.srcObject = stream; - } - - peer.on("call", (call) => { - call.answer(stream); - - call.on("stream", (remoteStream) => { - setRemoteStreams((prev) => ({ ...prev, [call.peer]: remoteStream })); - }); - - call.on("close", () => { - setRemoteStreams((prev) => { - const newStreams = { ...prev }; - delete newStreams[call.peer]; - return newStreams; - }); - }); - - call.on("error", (err) => { - console.error("Peer call error:", err); - }); - }); - - peer.on("open", (id) => { - setPeerId(id); - communicationSocketRef.current?.emit("peer-id", id, roomNumber); - }); - - peer.on("error", (err) => { - console.error("Peer error:", err); - }); - }) - .catch((err) => { - console.error("Failed to get local stream:", err); - }); - - communicationSocketRef.current?.on("peer-id", (otherPeerId: string) => { - if (otherPeerId === peerId) return; - if (!localStream) return; - - const call = peer.call(otherPeerId, localStream); - - call.on("stream", (remoteStream) => { - setRemoteStreams((prev) => ({ ...prev, [call.peer]: remoteStream })); - }); - - call.on("close", () => { - setRemoteStreams((prev) => { - const newStreams = { ...prev }; - delete newStreams[call.peer]; - return newStreams; - }); - }); - - call.on("error", (err) => { - console.error("Peer call error:", err); - }); - }); + const toggleVideo = () => { + setIsVideoEnabled(!isVideoEnabled); + }; - return () => { - if (currentStream) { - currentStream.getTracks().forEach((track) => track.stop()); - } - if (peer) { - peer.destroy(); - } - communicationSocketRef.current?.off("peer-id"); - }; - }, [communicationSocketRef, peerId, roomNumber]); + const toggleAudio = () => { + setIsAudioEnabled(!isAudioEnabled); + }; - const handleHangUp = () => { - if (localStream) { - localStream.getTracks().forEach((track) => track.stop()); - } - if (peerRef.current) { - peerRef.current.destroy(); - } - setRemoteStreams({}); + const handleEndCall = () => { + mediaConnectionRef.current?.close(); onClose(); }; return ( -
setIsVideoHovered(true)} - onMouseLeave={() => setIsVideoHovered(false)} - > -
Hang Up +
+
Video Call -
+
-
- {localStream && ( -
diff --git a/frontend/src/pages/CodeEditor/CodeEditor.scss b/frontend/src/pages/CodeEditor/CodeEditor.scss index e6d65beab9..ae63142df2 100644 --- a/frontend/src/pages/CodeEditor/CodeEditor.scss +++ b/frontend/src/pages/CodeEditor/CodeEditor.scss @@ -181,6 +181,7 @@ } .chatbox-icon { + z-index: 100; position: fixed; bottom: 10px; left: 10px; @@ -202,25 +203,51 @@ } } -.video-call-icon { - position: fixed; - bottom: 10px; - right: 10px; - cursor: pointer; - width: 60px; - height: 60px; - background-color: #2e2e2e; - border-radius: 25px; - display: flex; - justify-content: center; - align-items: center; - transition: - transform 0.2s ease, - background-color 0.2s ease; +.video-call { + &-icon { + z-index: 100; + position: fixed; + bottom: 10px; + right: 10px; + cursor: pointer; + width: 60px; + height: 60px; + background-color: #2e2e2e; + border-radius: 25px; + display: flex; + justify-content: center; + align-items: center; + transition: + transform 0.2s ease, + background-color 0.2s ease; + + &:hover { + transform: scale(1.1); + background-color: #373737; + } + } - &:hover { - transform: scale(1.1); - background-color: #373737; + &-collapsed { + z-index: 100; + position: fixed; + bottom: 10px; + right: 10px; + cursor: pointer; + width: 250px; + height: 140px; + background-color: #2e2e2e; + border-radius: 25px; + display: flex; + justify-content: center; + align-items: center; + transition: + transform 0.2s ease, + background-color 0.2s ease; + + &:hover { + transform: scale(1.1); + background-color: #373737; + } } } diff --git a/frontend/src/pages/CodeEditor/CodeEditor.tsx b/frontend/src/pages/CodeEditor/CodeEditor.tsx index 0bd07d7929..a3b0ad00e9 100644 --- a/frontend/src/pages/CodeEditor/CodeEditor.tsx +++ b/frontend/src/pages/CodeEditor/CodeEditor.tsx @@ -26,9 +26,11 @@ import { UserContext } from "../../contexts/UserContext"; import { ChatMessage } from "../../models/communication.model"; import { SessionContext, SessionState } from "../../contexts/SessionContext"; import { useConfirmationDialog } from "../../contexts/ConfirmationDialogContext"; +import Peer, { MediaConnection } from "peerjs"; 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 Language Type type Language = "python" | "cpp" | "java"; @@ -102,7 +104,7 @@ interface TestCase { const CodeEditor: React.FC = () => { const { user } = useContext(UserContext); - const { sessionState, questionId, clearSession } = useContext(SessionContext); + const { sessionState, questionId, clearSession, otherUserId, otherUserProfile } = useContext(SessionContext); const { setConfirmationDialogTitle, setConfirmationDialogContent, setConfirmationCallBack, openConfirmationDialog } = useConfirmationDialog(); const navigate = useNavigate(); @@ -121,8 +123,22 @@ const CodeEditor: React.FC = () => { 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); const communicationSocketRef = useRef(null); + const peerInstanceRef = useRef(); + const mediaConnectionRef = useRef(); + const [isOtherUserStreaming, setIsOtherUserStreaming] = useState(false); + 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 } }>({}); @@ -161,6 +177,7 @@ const CodeEditor: React.FC = () => { clearSession(); return; } + const fetchQuestionData = async () => { try { const response = await QuestionService.getQuestion(questionId); @@ -181,7 +198,7 @@ const CodeEditor: React.FC = () => { chatHistoryRef.current = chatHistory; }, [chatHistory]); - const clearSockets = () => { + const clearSocketsAndPeer = () => { if (collaborationSocketRef.current) { collaborationSocketRef.current.disconnect(); console.log("Disconnected from collaboration websocket server."); @@ -190,32 +207,24 @@ const CodeEditor: React.FC = () => { communicationSocketRef.current.disconnect(); console.log("Disconnected from communication websocket server."); } + 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(() => () => { - clearSockets(); + clearSocketsAndPeer(); clearSession(); navigate("/"); }); openConfirmationDialog(); }; - useEffect(() => { - // set up websockets - if (!roomNumber) { - console.error("No roomNumber provided."); - return; - } - - const token = localStorage.getItem("jwt-token"); - if (!token) { - console.error("No JWT token found in localStorage."); - return; - } - + const setUpCollaborationSocket = (token: string) => { // Collaboration socket for code editing const socket = io(COLLABORATION_WEBSOCKET_URL, { extraHeaders: { @@ -302,21 +311,26 @@ const CodeEditor: React.FC = () => { "Your partner has left the coding session. Would you like to end the session and return to home page?", ); setConfirmationCallBack(() => () => { - clearSockets(); + clearSocketsAndPeer(); clearSession(); navigate("/"); }); openConfirmationDialog(); } }); + }; + const setUpCommunicationSocket = (token: string) => { // Communication socket for chat if (!user) { - console.error("No user found."); + console.error("No user found when setting up communication socket."); return; } - - const chatSocket = io(COMMUNICATION_WEBSOCKET_URL); + const chatSocket = io(COMMUNICATION_WEBSOCKET_URL, { + extraHeaders: { + Authorization: `${token}`, + }, + }); communicationSocketRef.current = chatSocket; chatSocket.on("connect", () => { @@ -334,12 +348,164 @@ const CodeEditor: React.FC = () => { appendToChatHistory(newMessage); }); + communicationSocketRef.current?.on("user-disconnected", (newUserId: string) => { + if (newUserId === user.id) return; + if (mediaConnectionRef.current) { + mediaConnectionRef.current.close(); + } + }); + }; + + const setUpVideoPeerConnection = (token: string) => { + if (!user) { + console.error("No user found when setting up peer connection."); + return; + } + + // Peer connection for video call + const peer = new Peer(user.id as string, { + host: "localhost", + port: Number(VIDEO_PEER_SERVICE_PORT), + path: "/peerjs", + token: token, + }); + + peerInstanceRef.current = peer; + + peer.on("open", (id) => { + console.log(`User ${user.username} opened peer with ID: ${id}`); + }); + + peer.on("call", (call) => { + console.log("Received call from other user."); + call.answer(myStream.current!); + mediaConnectionRef.current?.close(); + mediaConnectionRef.current = call; + + call.on("stream", (remoteStream) => { + console.log("Streaming video from caller."); + if (remoteVideoRef.current) { + remoteVideoRef.current.srcObject = remoteStream; + } + if (collapsedRemoteVideoRef.current) { + collapsedRemoteVideoRef.current.srcObject = remoteStream; + } + setIsOtherUserStreaming(true); + }); + + call.on("close", () => { + console.log("Call is hung up."); + setIsOtherUserStreaming(false); + }); + }); + }; + + 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 () => { - clearSockets(); + clearSocketsAndPeer(); }; }, [roomNumber]); + const getUserMediaStream = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: true, + audio: true, + }); + + stream.getVideoTracks().forEach((track) => { + track.enabled = isVideoEnabled; + }); + stream.getAudioTracks().forEach((track) => { + track.enabled = isAudioEnabled; + }); + myStream.current = stream; + + if (myVideoRef.current) { + myVideoRef.current.srcObject = stream; + } + } catch (err) { + console.error("Failed to initialize call:", err); + } + }; + + const callOtherUserPeer = () => { + // connect to the peer identified by the other user's ID + if (!otherUserId) { + console.error("Other user ID not found in session context."); + return; + } + console.log(`User ${user?.username} calling the other user with peer ID ${otherUserId}`); + mediaConnectionRef.current?.close(); + const call = peerInstanceRef.current!.call(otherUserId, myStream.current!); + mediaConnectionRef.current = call; + + call.on("stream", (remoteStream) => { + if (!remoteStream) { + console.log("Callee is not ready to stream video."); + call.close(); + } + console.log("Streaming video from callee."); + if (remoteVideoRef.current) { + remoteVideoRef.current.srcObject = remoteStream; + } + if (collapsedRemoteVideoRef.current) { + collapsedRemoteVideoRef.current.srcObject = remoteStream; + } + setIsOtherUserStreaming(true); + }); + + call.on("close", () => { + console.log("Call is hung up."); + setIsOtherUserStreaming(false); + }); + }; + + const openVideoCall = async () => { + setIsVideoCallExpanded(true); + if (!myStream.current) { + await getUserMediaStream(); + } + if (myStream.current) { + callOtherUserPeer(); + } + }; + + 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; + mediaConnectionRef.current?.close(); + setIsVideoCallExpanded(false); + }; + const handleLanguageChange = (e: React.ChangeEvent) => { const newLanguage = e.target.value as Language; if (["python", "cpp", "javascript", "java"].includes(newLanguage)) { @@ -487,20 +653,46 @@ const CodeEditor: React.FC = () => { /> )} - {/* Floating Video Call Icon */} - {!isVideoCallExpanded && ( -
setIsVideoCallExpanded(true)}> + {!isVideoCallExpanded && !myStream.current && ( +
)} - {isVideoCallExpanded && ( + {!isVideoCallExpanded && !myStream.current && ( +
+ +
+ )} + +
+
+
+
+ +
- )} +
{/* Floating AI Hint Button */} {!isHintBoxExpanded && ( @@ -516,6 +708,18 @@ const CodeEditor: React.FC = () => { {isHintBoxExpanded && questionData && ( setIsHintBoxExpanded(false)} /> )} +
+
+
+
diff --git a/frontend/src/pages/Interview/Interview.tsx b/frontend/src/pages/Interview/Interview.tsx index 930dff2d1b..cec3f237ab 100644 --- a/frontend/src/pages/Interview/Interview.tsx +++ b/frontend/src/pages/Interview/Interview.tsx @@ -52,6 +52,13 @@ const Interview = (): ReactElement => { setSessionState(SessionState.PENDING); }); + socket.on("question_error", (data: any) => { + clearSession(); + setMainDialogTitle("Error"); + setMainDialogContent(data.message); + openMainDialog(); + }); + socket.on("timeout", (message: string) => { console.log("Timeout: ", message); accumulateMatchingTime(); diff --git a/frontend/src/util/ProtectedRoute.tsx b/frontend/src/util/ProtectedRoute.tsx new file mode 100644 index 0000000000..0e08c00b2e --- /dev/null +++ b/frontend/src/util/ProtectedRoute.tsx @@ -0,0 +1,40 @@ +// components/ProtectedRoute.js +import { useEffect, useState } from "react"; +import { Navigate, useParams } from "react-router-dom"; +import axios from "axios"; +import { ReactElement } from "react"; + +interface ProtectedRouteProps { + element: ReactElement; + userId: string; +} + +const ProtectedRoute = ({ element, userId }: ProtectedRouteProps): ReactElement => { + const { roomNumber } = useParams(); + const [hasAccess, setHasAccess] = useState(null); + + useEffect(() => { + const checkRoomAccess = async () => { + try { + const body = { userId: userId, roomId: roomNumber }; + const response = await axios.post(`${process.env.REACT_APP_MATCHING_SERVICE_URL}/check`, { + data: body, + }); + setHasAccess(response.data.hasAccess); + } catch (error) { + console.error("Error checking room access:", error); + setHasAccess(false); + } + }; + + checkRoomAccess(); + }, [roomNumber]); + + if (hasAccess === null) { + return
Loading...
; + } + + return hasAccess ? element : ; +}; + +export default ProtectedRoute;