From 68023da7dd9dfc49de8e04dfd2179db267ac4e8d Mon Sep 17 00:00:00 2001 From: bensohh Date: Sun, 27 Oct 2024 15:01:04 +0800 Subject: [PATCH 1/9] Implement session timer using localstorage --- .../src/app/collaboration/[id]/page.tsx | 93 ++++++++++++++++--- .../src/app/collaboration/[id]/styles.scss | 6 ++ 2 files changed, 84 insertions(+), 15 deletions(-) diff --git a/apps/frontend/src/app/collaboration/[id]/page.tsx b/apps/frontend/src/app/collaboration/[id]/page.tsx index fbca214fb4..7e671a69a1 100644 --- a/apps/frontend/src/app/collaboration/[id]/page.tsx +++ b/apps/frontend/src/app/collaboration/[id]/page.tsx @@ -15,7 +15,7 @@ import { import { Content } from "antd/es/layout/layout"; import "./styles.scss"; import { useRouter, useSearchParams } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { GetSingleQuestion, Question } from "@/app/services/question"; import { ClockCircleOutlined, @@ -50,6 +50,11 @@ export default function CollaborationPage(props: CollaborationProps) { ); const [currentUser, setCurrentUser] = useState(undefined); const [matchedUser, setMatchedUser] = useState(undefined); + const [sessionDuration, setSessionDuration] = useState(() => { + const storedTime = localStorage.getItem("session-duration"); + return storedTime ? parseInt(storedTime) : 0; + }); // State for count-up timer (TODO: currently using localstorage to store time, change to db stored time in the future) + const stopwatchRef = useRef(null); // Chat states const [messageToSend, setMessageToSend] = useState( @@ -61,8 +66,42 @@ export default function CollaborationPage(props: CollaborationProps) { undefined ); - // Retrieve the docRefId from query params during page navigation - // const searchParams = useSearchParams(); + // Stops the session duration stopwatch + const stopStopwatch = () => { + if (stopwatchRef.current) { + clearInterval(stopwatchRef.current); + } + }; + + // Starts the session duration stopwatch + const startStopwatch = () => { + if (stopwatchRef.current) { + clearInterval(stopwatchRef.current); + } + + stopwatchRef.current = setInterval(() => { + setSessionDuration((prevTime) => { + const newTime = prevTime + 1; + localStorage.setItem("session-duration", newTime.toString()); + return newTime; + }); + }, 1000); + }; + + // Convert seconds into time of format "hh:mm:ss" + const formatTime = (seconds: number) => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + return ( + (hours > 9 ? hours : "0" + hours) + + ":" + + (minutes > 9 ? minutes : "0" + minutes) + + ":" + + (secs > 9 ? secs : "0" + secs) + ); + }; // Fetch the question on initialisation useEffect(() => { @@ -81,14 +120,19 @@ export default function CollaborationPage(props: CollaborationProps) { setMatchedUser(matchedUser); setCurrentUser(currentUser); + // Fetch question and set question states GetSingleQuestion(docRefId).then((data: Question) => { setQuestionTitle(`${data.id}. ${data.title}`); setComplexity(data.complexity); setCategories(data.categories); setDescription(data.description); }); + + // Start stopwatch + startStopwatch(); }, []); + // Tabs component items for testcases const items: TabsProps["items"] = [ { key: "1", @@ -117,15 +161,21 @@ export default function CollaborationPage(props: CollaborationProps) { }, ]; + // Handles the cleaning of localstorage variables, stopping the timer & signalling collab user on webrtc const handleCloseCollaboration = () => { - // Remove localstorage variables for collaboration - localStorage.removeItem("user"); - localStorage.removeItem("matchedUser"); - localStorage.removeItem("collaId"); - localStorage.removeItem("docRefId"); + // Stop stopwatch + stopStopwatch(); + // Remove localstorage variable for stored session duration + localStorage.removeItem("session-duration"); // TODO: Remove this after collaboration backend data stored - // Redirect back to matching page - router.push("/matching"); + // // Remove localstorage variables for collaboration + // localStorage.removeItem("user"); + // localStorage.removeItem("matchedUser"); + // localStorage.removeItem("collaId"); + // localStorage.removeItem("docRefId"); + + // // Redirect back to matching page + // router.push("/matching"); }; return ( @@ -170,7 +220,11 @@ export default function CollaborationPage(props: CollaborationProps) { Test Cases {/* TODO: Link to execution service for running code against test-cases */} - @@ -189,7 +243,11 @@ export default function CollaborationPage(props: CollaborationProps) { Code {/* TODO: Link to execution service for code submission */} - @@ -221,15 +279,20 @@ export default function CollaborationPage(props: CollaborationProps) { Session Details {/* TODO: End the collaboration session, cleanup the localstorage variables */} -
Duration: - {/* TODO: Implement a count-up timer for session duration */} - 00:00:00 + + {formatTime(sessionDuration)} +
Matched User: diff --git a/apps/frontend/src/app/collaboration/[id]/styles.scss b/apps/frontend/src/app/collaboration/[id]/styles.scss index 35f2ea7a79..7fee13a64d 100644 --- a/apps/frontend/src/app/collaboration/[id]/styles.scss +++ b/apps/frontend/src/app/collaboration/[id]/styles.scss @@ -168,3 +168,9 @@ .title-icons { margin-right: 4px; } + +.code-submit-button, +.session-end-button, +.test-case-button { + width: fit-content; +} From 77ff847a112567c529fb6430d4ceb8821a82c777 Mon Sep 17 00:00:00 2001 From: bensohh Date: Sun, 27 Oct 2024 15:44:10 +0800 Subject: [PATCH 2/9] Add modal component on top of end session --- .../src/app/collaboration/[id]/page.tsx | 45 +++++++++++-------- .../src/app/collaboration/[id]/styles.scss | 4 ++ 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/apps/frontend/src/app/collaboration/[id]/page.tsx b/apps/frontend/src/app/collaboration/[id]/page.tsx index 7e671a69a1..2a69d08491 100644 --- a/apps/frontend/src/app/collaboration/[id]/page.tsx +++ b/apps/frontend/src/app/collaboration/[id]/page.tsx @@ -5,6 +5,7 @@ import { Col, Input, Layout, + Modal, Row, Select, Tabs, @@ -66,6 +67,9 @@ export default function CollaborationPage(props: CollaborationProps) { undefined ); + // Modal state + const [isModalOpen, setIsModalOpen] = useState(false); + // Stops the session duration stopwatch const stopStopwatch = () => { if (stopwatchRef.current) { @@ -168,14 +172,14 @@ export default function CollaborationPage(props: CollaborationProps) { // Remove localstorage variable for stored session duration localStorage.removeItem("session-duration"); // TODO: Remove this after collaboration backend data stored - // // Remove localstorage variables for collaboration - // localStorage.removeItem("user"); - // localStorage.removeItem("matchedUser"); - // localStorage.removeItem("collaId"); - // localStorage.removeItem("docRefId"); + // Remove localstorage variables for collaboration + localStorage.removeItem("user"); + localStorage.removeItem("matchedUser"); + localStorage.removeItem("collaId"); + localStorage.removeItem("docRefId"); - // // Redirect back to matching page - // router.push("/matching"); + // Redirect back to matching page + router.push("/matching"); }; return ( @@ -251,15 +255,6 @@ export default function CollaborationPage(props: CollaborationProps) { Submit
- {/*
-
Select Language:
-
- Start Time: + Start Time: 01:23:45
- + Session Duration:{" "} - + 01:23:45
- Matched with: + Matched with: John Doe
diff --git a/apps/frontend/src/components/CollaborativeEditor/CollaborativeEditor.tsx b/apps/frontend/src/components/CollaborativeEditor/CollaborativeEditor.tsx index 5328da6cdf..dda8403650 100644 --- a/apps/frontend/src/components/CollaborativeEditor/CollaborativeEditor.tsx +++ b/apps/frontend/src/components/CollaborativeEditor/CollaborativeEditor.tsx @@ -1,8 +1,12 @@ // Referenced from example in https://www.npmjs.com/package/y-codemirror.next import React, { Dispatch, + ForwardedRef, + forwardRef, + RefObject, SetStateAction, useEffect, + useImperativeHandle, useRef, useState, } from "react"; @@ -26,6 +30,11 @@ interface CollaborativeEditorProps { collaborationId: string; language: string; setMatchedUser: Dispatch>; + handleCloseCollaboration: (type: string) => void; +} + +export interface CollaborativeEditorHandle { + endSession: () => void; } export const usercolors = [ @@ -43,196 +52,215 @@ export const usercolors = [ export const userColor = usercolors[Math.floor(Math.random() * (usercolors.length - 1))]; -const CollaborativeEditor = (props: CollaborativeEditorProps) => { - const editorRef = useRef(null); - // const viewRef = useRef(null); - const [selectedLanguage, setSelectedLanguage] = useState("JavaScript"); - const [trigger, setTrigger] = useState(false); - - const languageConf = new Compartment(); - - // Referenced: https://codemirror.net/examples/config/#dynamic-configuration - const autoLanguage = EditorState.transactionExtender.of((tr) => { - if (!tr.docChanged) return null; - - const snippet = tr.newDoc.sliceString(0, 100); - // Test for various language - const docIsPython = /^\s*(def|class)\s/.test(snippet); - const docIsJava = /^\s*(class|public\s+static\s+void\s+main)\s/.test( - snippet - ); // Java has some problems - const docIsCpp = /^\s*(#include|namespace|int\s+main)\s/.test(snippet); // Yet to test c++ - const docIsGo = /^(package|import|func|type|var|const)\s/.test(snippet); - - let newLanguage; - let languageType; - let languageLabel; - - if (docIsPython) { - newLanguage = python(); - languageLabel = "Python"; - languageType = pythonLanguage; - } else if (docIsJava) { - newLanguage = java(); - languageLabel = "Java"; - languageType = javaLanguage; - } else if (docIsGo) { - newLanguage = go(); - languageLabel = "Go"; - languageType = goLanguage; - } else if (docIsCpp) { - newLanguage = cpp(); - languageLabel = "C++"; - languageType = cppLanguage; - } else { - newLanguage = javascript(); // Default to JavaScript - languageLabel = "JavaScript"; - languageType = javascriptLanguage; - } - - const stateLanguage = tr.startState.facet(language); - if (languageType == stateLanguage) return null; - - setSelectedLanguage(languageLabel); - - return { - effects: languageConf.reconfigure(newLanguage), - }; - }); +const CollaborativeEditor = forwardRef( + ( + props: CollaborativeEditorProps, + ref: ForwardedRef + ) => { + const editorRef = useRef(null); + const providerRef = useRef(null); + const [selectedLanguage, setSelectedLanguage] = useState("JavaScript"); + // const [sessionEndNotified, setSessionEndNotified] = + // useState(false); + let sessionEndNotified = false; - const [messageApi, contextHolder] = message.useMessage(); + const languageConf = new Compartment(); - const success = (message: string) => { - messageApi.open({ - type: "success", - content: message, - }); - }; + // Referenced: https://codemirror.net/examples/config/#dynamic-configuration + const autoLanguage = EditorState.transactionExtender.of((tr) => { + if (!tr.docChanged) return null; - const error = (message: string) => { - messageApi.open({ - type: "error", - content: message, - }); - }; + const snippet = tr.newDoc.sliceString(0, 100); + // Test for various language + const docIsPython = /^\s*(def|class)\s/.test(snippet); + const docIsJava = /^\s*(class|public\s+static\s+void\s+main)\s/.test( + snippet + ); // Java has some problems + const docIsCpp = /^\s*(#include|namespace|int\s+main)\s/.test(snippet); // Yet to test c++ + const docIsGo = /^(package|import|func|type|var|const)\s/.test(snippet); - const warning = (message: string) => { - messageApi.open({ - type: "warning", - content: message, - }); - }; - - // const handleLanguageChange = (val: any) => { - // console.log("came in here"); - // console.log(val); - // setSelectedLanguage(val); - - // let languageExtension; - // switch (val) { - // case "python": - // languageExtension = python(); - // break; - // default: - // languageExtension = javascript(); - // } - - // // Update the language configuration - // if (viewRef.current) { - // console.log("insude here"); - // viewRef.current.dispatch({ - // effects: languageConf.reconfigure(languageExtension), - // }); - // } - // }; - - useEffect(() => { - if (process.env.NEXT_PUBLIC_SIGNALLING_SERVICE_URL === undefined) { - error("Missing Signalling Service Url"); - return; - } - - const ydoc = new Y.Doc(); - const provider = new WebrtcProvider(props.collaborationId, ydoc, { - signaling: [process.env.NEXT_PUBLIC_SIGNALLING_SERVICE_URL], - maxConns: 2, - }); - const ytext = ydoc.getText("codemirror"); - const undoManager = new Y.UndoManager(ytext); + let newLanguage; + let languageType; + let languageLabel; + + if (docIsPython) { + newLanguage = python(); + languageLabel = "Python"; + languageType = pythonLanguage; + } else if (docIsJava) { + newLanguage = java(); + languageLabel = "Java"; + languageType = javaLanguage; + } else if (docIsGo) { + newLanguage = go(); + languageLabel = "Go"; + languageType = goLanguage; + } else if (docIsCpp) { + newLanguage = cpp(); + languageLabel = "C++"; + languageType = cppLanguage; + } else { + newLanguage = javascript(); // Default to JavaScript + languageLabel = "JavaScript"; + languageType = javascriptLanguage; + } - provider.awareness.setLocalStateField("user", { - name: props.user, - color: userColor.color, - colorLight: userColor.light, + const stateLanguage = tr.startState.facet(language); + if (languageType == stateLanguage) return null; + + setSelectedLanguage(languageLabel); + + return { + effects: languageConf.reconfigure(newLanguage), + }; }); - // Check initial awareness states - const states = provider.awareness.getStates(); - for (const [clientID, state] of Array.from(states)) { - if (state.user && state.user.name !== props.user) { - props.setMatchedUser(state.user.name); - break; + const [messageApi, contextHolder] = message.useMessage(); + + const success = (message: string) => { + messageApi.open({ + type: "success", + content: message, + }); + }; + + const info = (message: string) => { + messageApi.open({ + type: "info", + content: message, + }); + }; + + const error = (message: string) => { + messageApi.open({ + type: "error", + content: message, + }); + }; + + const warning = (message: string) => { + messageApi.open({ + type: "warning", + content: message, + }); + }; + + useImperativeHandle(ref, () => ({ + endSession: () => { + if (providerRef.current) { + // Set awareness state to indicate session ended to notify peer about session ending + providerRef.current.awareness.setLocalStateField( + "sessionEnded", + true + ); + success("Session ended. All participants will be notified."); + } + }, + })); + + useEffect(() => { + if (process.env.NEXT_PUBLIC_SIGNALLING_SERVICE_URL === undefined) { + error("Missing Signalling Service Url"); + return; } - } - // Listen for awareness changes - provider.awareness.on("change", () => { - const updatedStates = provider.awareness.getStates(); - for (const [clientID, state] of Array.from(updatedStates)) { + const ydoc = new Y.Doc(); + const provider = new WebrtcProvider(props.collaborationId, ydoc, { + signaling: [process.env.NEXT_PUBLIC_SIGNALLING_SERVICE_URL], + maxConns: 2, + }); + + providerRef.current = provider; + const ytext = ydoc.getText("codemirror"); + const undoManager = new Y.UndoManager(ytext); + + provider.awareness.setLocalStateField("user", { + name: props.user, + color: userColor.color, + colorLight: userColor.light, + }); + + // Check initial awareness states + const states = provider.awareness.getStates(); + for (const [clientID, state] of Array.from(states)) { if (state.user && state.user.name !== props.user) { props.setMatchedUser(state.user.name); break; } } - }); - const state = EditorState.create({ - doc: ytext.toString(), - extensions: [ - basicSetup, - languageConf.of(javascript()), - autoLanguage, - yCollab(ytext, provider.awareness, { undoManager }), - ], - }); + // Listen for awareness changes + provider.awareness.on("change", () => { + const updatedStates = provider.awareness.getStates(); + for (const [clientID, state] of Array.from(updatedStates)) { + if (state.sessionEnded && state.user.name !== props.user) { + if (!sessionEndNotified) { + info( + `Session has been ended by another participant ${state.user.name}` + ); - const view = new EditorView({ - state, - parent: editorRef.current || undefined, - }); + props.handleCloseCollaboration("peer"); + sessionEndNotified = true; + if (providerRef.current) { + providerRef.current.disconnect(); + } + return; + } + } - return () => { - // Cleanup on component unmount - console.log("unmounting collaboration editor"); // TODO: remove - view.destroy(); - // viewRef.current?.destroy(); - provider.disconnect(); - ydoc.destroy(); - }; - }, []); - - return ( - <> - {contextHolder} -
-
Select Language:
- setSelectedLanguage(val)} + disabled + /> +
+
-
-
-
- Current Language Detected: {selectedLanguage} -
- - ); -}; +
+ Current Language Detected: {selectedLanguage} +
+ + ); + } +); export default CollaborativeEditor; From 5be7f3c10f312e66f0219848a565477682b8c22c Mon Sep 17 00:00:00 2001 From: bensohh Date: Mon, 28 Oct 2024 16:48:53 +0800 Subject: [PATCH 5/9] Minor UI adjustment for modal --- apps/frontend/src/app/collaboration/[id]/page.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/frontend/src/app/collaboration/[id]/page.tsx b/apps/frontend/src/app/collaboration/[id]/page.tsx index fbfde4c2b4..abedefacba 100644 --- a/apps/frontend/src/app/collaboration/[id]/page.tsx +++ b/apps/frontend/src/app/collaboration/[id]/page.tsx @@ -221,8 +221,7 @@ export default function CollaborationPage(props: CollaborationProps) { >

The collaboration session has ended. You will be redirected in{" "} - {countDown} - seconds + {countDown} seconds

Question:{" "} From 1fc5c9f9429ac69f004f690109319152df8b504e Mon Sep 17 00:00:00 2001 From: bensohh Date: Tue, 29 Oct 2024 11:05:02 +0800 Subject: [PATCH 6/9] Fix commented providerRef error --- .../src/app/collaboration/[id]/page.tsx | 57 ++++---- .../CollaborativeEditor.tsx | 133 +++++++++--------- 2 files changed, 103 insertions(+), 87 deletions(-) diff --git a/apps/frontend/src/app/collaboration/[id]/page.tsx b/apps/frontend/src/app/collaboration/[id]/page.tsx index 8c77ea6fc0..33fd1c61d5 100644 --- a/apps/frontend/src/app/collaboration/[id]/page.tsx +++ b/apps/frontend/src/app/collaboration/[id]/page.tsx @@ -40,7 +40,7 @@ interface CollaborationProps {} export default function CollaborationPage(props: CollaborationProps) { const router = useRouter(); -// const providerRef = useRef(null); + const providerRef = useRef(null); const editorRef = useRef(null); @@ -54,7 +54,9 @@ export default function CollaborationPage(props: CollaborationProps) { const [questionTitle, setQuestionTitle] = useState( undefined ); - const [questionDocRefId, setQuestionDocRefId] = useState(undefined); + const [questionDocRefId, setQuestionDocRefId] = useState( + undefined + ); const [complexity, setComplexity] = useState(undefined); const [categories, setCategories] = useState([]); // Store the selected filter categories const [description, setDescription] = useState(undefined); @@ -71,7 +73,9 @@ export default function CollaborationPage(props: CollaborationProps) { return storedTime ? parseInt(storedTime) : 0; }); // State for count-up timer (TODO: currently using localstorage to store time, change to db stored time in the future) const stopwatchRef = useRef(null); - const [matchedTopics, setMatchedTopics] = useState(undefined); + const [matchedTopics, setMatchedTopics] = useState( + undefined + ); // Chat states const [messageToSend, setMessageToSend] = useState( @@ -142,31 +146,34 @@ export default function CollaborationPage(props: CollaborationProps) { throw new Error("Provider not initialized"); } providerRef.current.awareness.setLocalStateField("codeSavedStatus", true); - } + }; const handleSubmitCode = async () => { if (!collaborationId) { throw new Error("Collaboration ID not found"); } - const data = await CreateOrUpdateHistory({ - title: questionTitle ?? "", - code: code, - language: selectedLanguage, - user: currentUser ?? "", - matchedUser: matchedUser ?? "", - matchId: collaborationId ?? "", - matchedTopics: matchedTopics ?? [], - questionDocRefId: questionDocRefId ?? "", - questionDifficulty: complexity ?? "", - questionTopics: categories, - }, collaborationId); + const data = await CreateOrUpdateHistory( + { + title: questionTitle ?? "", + code: code, + language: selectedLanguage, + user: currentUser ?? "", + matchedUser: matchedUser ?? "", + matchId: collaborationId ?? "", + matchedTopics: matchedTopics ?? [], + questionDocRefId: questionDocRefId ?? "", + questionDifficulty: complexity ?? "", + questionTopics: categories, + }, + collaborationId + ); successMessage("Code saved successfully!"); sendCodeSavedStatusToMatchedUser(); - } + }; const handleCodeChange = (code: string) => { setCode(code); - } + }; // Fetch the question on initialisation useEffect(() => { @@ -175,11 +182,13 @@ export default function CollaborationPage(props: CollaborationProps) { } // Retrieve details from localstorage - const questionDocRefId: string = localStorage.getItem("questionDocRefId") ?? ""; + const questionDocRefId: string = + localStorage.getItem("questionDocRefId") ?? ""; const collabId: string = localStorage.getItem("collabId") ?? ""; const matchedUser: string = localStorage.getItem("matchedUser") ?? ""; const currentUser: string = localStorage.getItem("user") ?? ""; - const matchedTopics: string[] = localStorage.getItem("matchedTopics")?.split(",") ?? []; + const matchedTopics: string[] = + localStorage.getItem("matchedTopics")?.split(",") ?? []; // Set states from localstorage setCollaborationId(collabId); @@ -377,10 +386,10 @@ export default function CollaborationPage(props: CollaborationProps) { Code

{/* TODO: Link to execution service for code submission */} - diff --git a/apps/frontend/src/components/CollaborativeEditor/CollaborativeEditor.tsx b/apps/frontend/src/components/CollaborativeEditor/CollaborativeEditor.tsx index 7e058f74e4..1f8eafeb42 100644 --- a/apps/frontend/src/components/CollaborativeEditor/CollaborativeEditor.tsx +++ b/apps/frontend/src/components/CollaborativeEditor/CollaborativeEditor.tsx @@ -32,7 +32,7 @@ interface CollaborativeEditorProps { language: string; setMatchedUser: Dispatch>; handleCloseCollaboration: (type: string) => void; -// providerRef: MutableRefObject; + // providerRef: MutableRefObject; matchedUser: string; onCodeChange: (code: string) => void; } @@ -84,57 +84,57 @@ const CollaborativeEditor = forwardRef( const languageConf = new Compartment(); // Referenced: https://codemirror.net/examples/config/#dynamic-configuration - const autoLanguage = EditorState.transactionExtender.of((tr) => { - if (!tr.docChanged) return null; - - const snippet = tr.newDoc.sliceString(0, 100); - - // Handle code change - props.onCodeChange(tr.newDoc.toString()); - - // Test for various language - const docIsPython = /^\s*(def|class)\s/.test(snippet); - const docIsJava = /^\s*(class|public\s+static\s+void\s+main)\s/.test( - snippet - ); // Java has some problems - const docIsCpp = /^\s*(#include|namespace|int\s+main)\s/.test(snippet); // Yet to test c++ - const docIsGo = /^(package|import|func|type|var|const)\s/.test(snippet); - - let newLanguage; - let languageType; - let languageLabel; - - if (docIsPython) { - newLanguage = python(); - languageLabel = "Python"; - languageType = pythonLanguage; - } else if (docIsJava) { - newLanguage = java(); - languageLabel = "Java"; - languageType = javaLanguage; - } else if (docIsGo) { - newLanguage = go(); - languageLabel = "Go"; - languageType = goLanguage; - } else if (docIsCpp) { - newLanguage = cpp(); - languageLabel = "C++"; - languageType = cppLanguage; - } else { - newLanguage = javascript(); // Default to JavaScript - languageLabel = "JavaScript"; - languageType = javascriptLanguage; - } - - const stateLanguage = tr.startState.facet(language); - if (languageType == stateLanguage) return null; - - setSelectedLanguage(languageLabel); - - return { - effects: languageConf.reconfigure(newLanguage), - }; - }); + const autoLanguage = EditorState.transactionExtender.of((tr) => { + if (!tr.docChanged) return null; + + const snippet = tr.newDoc.sliceString(0, 100); + + // Handle code change + props.onCodeChange(tr.newDoc.toString()); + + // Test for various language + const docIsPython = /^\s*(def|class)\s/.test(snippet); + const docIsJava = /^\s*(class|public\s+static\s+void\s+main)\s/.test( + snippet + ); // Java has some problems + const docIsCpp = /^\s*(#include|namespace|int\s+main)\s/.test(snippet); // Yet to test c++ + const docIsGo = /^(package|import|func|type|var|const)\s/.test(snippet); + + let newLanguage; + let languageType; + let languageLabel; + + if (docIsPython) { + newLanguage = python(); + languageLabel = "Python"; + languageType = pythonLanguage; + } else if (docIsJava) { + newLanguage = java(); + languageLabel = "Java"; + languageType = javaLanguage; + } else if (docIsGo) { + newLanguage = go(); + languageLabel = "Go"; + languageType = goLanguage; + } else if (docIsCpp) { + newLanguage = cpp(); + languageLabel = "C++"; + languageType = cppLanguage; + } else { + newLanguage = javascript(); // Default to JavaScript + languageLabel = "JavaScript"; + languageType = javascriptLanguage; + } + + const stateLanguage = tr.startState.facet(language); + if (languageType == stateLanguage) return null; + + setSelectedLanguage(languageLabel); + + return { + effects: languageConf.reconfigure(newLanguage), + }; + }); const [messageApi, contextHolder] = message.useMessage(); @@ -235,20 +235,27 @@ const CollaborativeEditor = forwardRef( } } }); - + // Listener for awareness updates to receive status changes from peers - provider.awareness.on("update", ({ added, updated } : AwarenessUpdate) => { - added.concat(updated).filter(clientId => clientId !== provider.awareness.clientID).forEach((clientID) => { - const state = provider.awareness.getStates().get(clientID) as Awareness; - if (state && state.codeSavedStatus) { - // Display the received status message - messageApi.open({ - type: "success", - content: `${props.matchedUser ?? "Peer"} saved code successfully!`, + provider.awareness.on("update", ({ added, updated }: AwarenessUpdate) => { + added + .concat(updated) + .filter((clientId) => clientId !== provider.awareness.clientID) + .forEach((clientID) => { + const state = provider.awareness + .getStates() + .get(clientID) as Awareness; + if (state && state.codeSavedStatus) { + // Display the received status message + messageApi.open({ + type: "success", + content: `${ + props.matchedUser ?? "Peer" + } saved code successfully!`, + }); + } }); - } }); - }); const state = EditorState.create({ doc: ytext.toString(), From 09708e1435935d6a226a074ce0906d2809ad5052 Mon Sep 17 00:00:00 2001 From: bensohh Date: Tue, 29 Oct 2024 14:50:52 +0800 Subject: [PATCH 7/9] Add dockerfile and remove immediate redirect upon closing collab editor --- apps/signalling-service/Dockerfile | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 apps/signalling-service/Dockerfile diff --git a/apps/signalling-service/Dockerfile b/apps/signalling-service/Dockerfile new file mode 100644 index 0000000000..6189b2fde6 --- /dev/null +++ b/apps/signalling-service/Dockerfile @@ -0,0 +1,21 @@ +FROM node:18-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +RUN apk add --no-cache libc6-compat +WORKDIR /app + +COPY package*.json pnpm-lock.yaml* ./ +RUN \ + if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ + else echo "Lockfile not found." && exit 1; \ + fi + +COPY . . + +# Expose port 3001 so it can be mapped by Docker daemon. +EXPOSE 4444 + +# Define the command to run your app using CMD which defines your runtime. +CMD [ "node", "server.js" ] \ No newline at end of file From b70f478925eac9d3f831e697b35aa6cccae61071 Mon Sep 17 00:00:00 2001 From: bensohh Date: Tue, 29 Oct 2024 15:50:13 +0800 Subject: [PATCH 8/9] Update docker-compose.yml to include signalling server --- apps/docker-compose.yml | 15 +++++++++++++++ apps/frontend/src/app/collaboration/[id]/page.tsx | 3 --- apps/signalling-service/README.md | 12 +++++++++++- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/apps/docker-compose.yml b/apps/docker-compose.yml index 0a0ef34fdf..6152c29757 100644 --- a/apps/docker-compose.yml +++ b/apps/docker-compose.yml @@ -11,6 +11,8 @@ services: - ./frontend/.env volumes: - ./frontend:/frontend + depends_on: + - signalling-service user-service: build: @@ -66,6 +68,19 @@ services: - apps_network volumes: - ./history-service:/history-service + + signalling-service: + build: + context: ./signalling-service + dockerfile: Dockerfile + ports: + - 4444:4444 + env_file: + - ./signalling-service/.env + networks: + - apps_network + volumes: + - ./signalling-service:/signalling-service redis: image: redis:latest diff --git a/apps/frontend/src/app/collaboration/[id]/page.tsx b/apps/frontend/src/app/collaboration/[id]/page.tsx index 33fd1c61d5..62b3e9398d 100644 --- a/apps/frontend/src/app/collaboration/[id]/page.tsx +++ b/apps/frontend/src/app/collaboration/[id]/page.tsx @@ -269,9 +269,6 @@ export default function CollaborationPage(props: CollaborationProps) { localStorage.removeItem("collabId"); localStorage.removeItem("questionDocRefId"); localStorage.removeItem("matchedTopics"); - - // Redirect back to matching page - router.push("/matching"); }; return ( diff --git a/apps/signalling-service/README.md b/apps/signalling-service/README.md index f51e5e76b8..c49ca99356 100644 --- a/apps/signalling-service/README.md +++ b/apps/signalling-service/README.md @@ -25,4 +25,14 @@ First, run the development server: pnpm dev ``` -## Build Dockerfile (TODO) +## Build Dockerfile + +```bash +docker build -t signalling-service -f Dockerfile . +``` + +## Run Docker Container + +```bash +docker run -p 4444:4444 --env-file .env -d signalling-service +``` From 5fdbaa8c656ac72bf4c2aeec6817710492d251c4 Mon Sep 17 00:00:00 2001 From: bensohh Date: Tue, 29 Oct 2024 16:30:32 +0800 Subject: [PATCH 9/9] Replace providerRef with outer ref reference and update test.yml --- .github/workflows/test.yml | 14 +++++++++++++- apps/frontend/src/app/collaboration/[id]/page.tsx | 2 +- .../CollaborativeEditor/CollaborativeEditor.tsx | 14 +++++++------- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2a73652149..82bd2743a4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,6 +30,7 @@ jobs: USER_SERVICE_URL: ${{ vars.USER_SERVICE_URL }} MATCHING_SERVICE_URL: ${{ vars.MATCHING_SERVICE_URL }} HISTORY_SERVICE_URL: ${{ vars.HISTORY_SERVICE_URL }} + SIGNALLING_SERVICE_URL: ${{ vars.SIGNALLING_SERVICE_URL }} JWT_SECRET: ${{ secrets.JWT_SECRET }} QUESTION_FIREBASE_CREDENTIAL_PATH: ${{ vars.QUESTION_SERVICE_FIREBASE_CREDENTIAL_PATH }} HISTORY_FIREBASE_CREDENTIAL_PATH: ${{ vars.HISTORY_SERVICE_FIREBASE_CREDENTIAL_PATH }} @@ -37,6 +38,7 @@ jobs: USER_SERVICE_PORT: ${{ vars.USER_SERVICE_PORT }} MATCHING_SERVICE_PORT: ${{ vars.MATCHING_SERVICE_PORT }} HISTORY_SERVICE_PORT: ${{ vars.HISTORY_SERVICE_PORT }} + SIGNALLING_SERVICE_PORT: ${{ vars.SIGNALLING_SERVICE_PORT }} MATCHING_SERVICE_TIMEOUT: ${{ vars.MATCHING_SERVICE_TIMEOUT }} REDIS_URL: ${{ vars.REDIS_URL }} QUESTION_SERVICE_GRPC_URL: ${{ vars.QUESTION_SERVICE_GPRC_URL }} @@ -46,6 +48,7 @@ jobs: echo "NEXT_PUBLIC_USER_SERVICE_URL=$USER_SERVICE_URL" >> .env echo "NEXT_PUBLIC_MATCHING_SERVICE_URL=$MATCHING_SERVICE_URL" >> .env echo "NEXT_PUBLIC_HISTORY_SERVICE_URL=$HISTORY_SERVICE_URL" >> .env + echo "NEXT_PUBLIC_SIGNALLING_SERVICE_URL=$SIGNALLING_SERVICE_URL" >> .env cd ../question-service echo "FIREBASE_CREDENTIAL_PATH=$QUESTION_FIREBASE_CREDENTIAL_PATH" >> .env @@ -67,6 +70,9 @@ jobs: echo "FIREBASE_CREDENTIAL_PATH=$HISTORY_FIREBASE_CREDENTIAL_PATH" >> .env echo "PORT=$HISTORY_SERVICE_PORT" >> .env + cd ../signalling-service + echo "PORT=$SIGNALLING_SERVICE_PORT" >> .env + - name: Create Database Credential Files env: QUESTION_FIREBASE_JSON: ${{ secrets.QUESTION_SERVICE_FIREBASE_CREDENTIAL }} @@ -101,6 +107,7 @@ jobs: QUESTION_SERVICE_URL: ${{ vars.QUESTION_SERVICE_URL }} MATCHING_SERVICE_URL: ${{ vars.MATCHING_SERVICE_URL }} HISTORY_SERVICE_URL: ${{ vars.HISTORY_SERVICE_URL }} + SIGNALLING_SERVICE_URL: ${{ vars.SIGNALLING_SERVICE_URL }} run: | echo "Testing Question Service..." curl -sSL -o /dev/null $QUESTION_SERVICE_URL && echo "Question Service is up" @@ -117,5 +124,10 @@ jobs: echo "WebSocket for Matching Service is live" fi # Add in test for matching service in the future - + echo "Testing Signalling Service..." + if ! (echo "Hello" | websocat $SIGNALLING_SERVICE_URL); then + echo "WebSocket for Signalling Service is not live" + else + echo "WebSocket for Signalling Service is live" + fi # We can add more tests here diff --git a/apps/frontend/src/app/collaboration/[id]/page.tsx b/apps/frontend/src/app/collaboration/[id]/page.tsx index 62b3e9398d..41c56023f0 100644 --- a/apps/frontend/src/app/collaboration/[id]/page.tsx +++ b/apps/frontend/src/app/collaboration/[id]/page.tsx @@ -399,7 +399,7 @@ export default function CollaborationPage(props: CollaborationProps) { language={selectedLanguage} setMatchedUser={setMatchedUser} handleCloseCollaboration={handleCloseCollaboration} - // providerRef={providerRef} + providerRef={providerRef} matchedUser={matchedUser} onCodeChange={handleCodeChange} /> diff --git a/apps/frontend/src/components/CollaborativeEditor/CollaborativeEditor.tsx b/apps/frontend/src/components/CollaborativeEditor/CollaborativeEditor.tsx index 1f8eafeb42..857e149be1 100644 --- a/apps/frontend/src/components/CollaborativeEditor/CollaborativeEditor.tsx +++ b/apps/frontend/src/components/CollaborativeEditor/CollaborativeEditor.tsx @@ -32,7 +32,7 @@ interface CollaborativeEditorProps { language: string; setMatchedUser: Dispatch>; handleCloseCollaboration: (type: string) => void; - // providerRef: MutableRefObject; + providerRef: MutableRefObject; matchedUser: string; onCodeChange: (code: string) => void; } @@ -77,7 +77,7 @@ const CollaborativeEditor = forwardRef( ref: ForwardedRef ) => { const editorRef = useRef(null); - const providerRef = useRef(null); + // const providerRef = useRef(null); const [selectedLanguage, setSelectedLanguage] = useState("JavaScript"); let sessionEndNotified = false; @@ -168,9 +168,9 @@ const CollaborativeEditor = forwardRef( useImperativeHandle(ref, () => ({ endSession: () => { - if (providerRef.current) { + if (props.providerRef.current) { // Set awareness state to indicate session ended to notify peer about session ending - providerRef.current.awareness.setLocalStateField( + props.providerRef.current.awareness.setLocalStateField( "sessionEnded", true ); @@ -191,7 +191,7 @@ const CollaborativeEditor = forwardRef( maxConns: 2, }); - providerRef.current = provider; + props.providerRef.current = provider; const ytext = ydoc.getText("codemirror"); const undoManager = new Y.UndoManager(ytext); @@ -222,8 +222,8 @@ const CollaborativeEditor = forwardRef( props.handleCloseCollaboration("peer"); sessionEndNotified = true; - if (providerRef.current) { - providerRef.current.disconnect(); + if (props.providerRef.current) { + props.providerRef.current.disconnect(); } return; }