Skip to content

Commit

Permalink
Merge branch 'development' into qnhist
Browse files Browse the repository at this point in the history
  • Loading branch information
feliciagan committed Nov 9, 2024
2 parents f16b0d0 + 7a55edd commit 75529bb
Show file tree
Hide file tree
Showing 10 changed files with 594 additions and 317 deletions.
101 changes: 59 additions & 42 deletions backend/collab-service/src/handlers/websocketHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,28 +13,30 @@ enum CollabEvents {
UPDATE_REQUEST = "update_request",
UPDATE_CURSOR_REQUEST = "update_cursor_request",
RECONNECT_REQUEST = "reconnect_request",
END_SESSION_REQUEST = "end_session_request",

// Send
ROOM_READY = "room_ready",
DOCUMENT_READY = "document_ready",
DOCUMENT_NOT_FOUND = "document_not_found",
UPDATE = "updateV2",
UPDATE_CURSOR = "update_cursor",
PARTNER_LEFT = "partner_left",
END_SESSION = "end_session",
PARTNER_DISCONNECTED = "partner_disconnected",
}

const EXPIRY_TIME = 3600;
const CONNECTION_DELAY = 3000; // time window to allow for page re-renders / refresh
const CONNECTION_DELAY = 3000; // time window to allow for page re-renders

const userConnections = new Map<string, NodeJS.Timeout | null>();
const collabSessions = new Map<string, Doc>();
const partnerReadiness = new Map<string, boolean>();

export const handleWebsocketCollabEvents = (socket: Socket) => {
socket.on(CollabEvents.JOIN, async (uid: string, roomId: string) => {
socket.on(CollabEvents.JOIN, (uid: string, roomId: string) => {
const connectionKey = `${uid}:${roomId}`;
if (userConnections.has(connectionKey)) {
clearTimeout(userConnections.get(connectionKey)!);
return;
}
userConnections.set(connectionKey, null);

Expand All @@ -47,11 +49,10 @@ export const handleWebsocketCollabEvents = (socket: Socket) => {
socket.join(roomId);
socket.data.roomId = roomId;

if (
io.sockets.adapter.rooms.get(roomId)?.size === 2 &&
!collabSessions.has(roomId)
) {
createCollabSession(roomId);
if (io.sockets.adapter.rooms.get(roomId)?.size === 2) {
if (!collabSessions.has(roomId)) {
createCollabSession(roomId);
}
io.to(roomId).emit(CollabEvents.ROOM_READY, true);
}
});
Expand Down Expand Up @@ -107,7 +108,8 @@ export const handleWebsocketCollabEvents = (socket: Socket) => {
if (doc) {
applyUpdateV2(doc, new Uint8Array(update));
} else {
// TODO: error handling
io.to(roomId).emit(CollabEvents.DOCUMENT_NOT_FOUND);
io.sockets.adapter.rooms.delete(roomId);
}
}
);
Expand All @@ -124,41 +126,45 @@ export const handleWebsocketCollabEvents = (socket: Socket) => {

socket.on(
CollabEvents.LEAVE,
(uid: string, roomId: string, isImmediate: boolean) => {
(uid: string, roomId: string, isPartnerNotified: boolean) => {
const connectionKey = `${uid}:${roomId}`;
if (isImmediate || !userConnections.has(connectionKey)) {
if (userConnections.has(connectionKey)) {
clearTimeout(userConnections.get(connectionKey)!);
}

if (isPartnerNotified) {
handleUserLeave(uid, roomId, socket);
return;
}

clearTimeout(userConnections.get(connectionKey)!);

const connectionTimeout = setTimeout(() => {
handleUserLeave(uid, roomId, socket);
io.to(roomId).emit(CollabEvents.PARTNER_DISCONNECTED);
}, CONNECTION_DELAY);

userConnections.set(connectionKey, connectionTimeout);
}
);

socket.on(CollabEvents.RECONNECT_REQUEST, async (roomId: string) => {
// TODO: Handle recconnection
socket.join(roomId);

const doc = getDocument(roomId);
const storeData = await redisClient.get(`collaboration:${roomId}`);
socket.on(
CollabEvents.END_SESSION_REQUEST,
(roomId: string, sessionDuration: number) => {
socket.to(roomId).emit(CollabEvents.END_SESSION, sessionDuration);
}
);

if (storeData) {
const tempDoc = new Doc();
const update = Buffer.from(storeData, "base64");
applyUpdateV2(tempDoc, new Uint8Array(update));
const tempText = tempDoc.getText().toString();
socket.on(CollabEvents.RECONNECT_REQUEST, (roomId: string) => {
const room = io.sockets.adapter.rooms.get(roomId);
if (!room || room.size < 2) {
socket.join(roomId);
socket.data.roomId = roomId;
}

const text = doc.getText();
doc.transact(() => {
text.delete(0, text.length);
text.insert(0, tempText);
});
if (
io.sockets.adapter.rooms.get(roomId)?.size === 2 &&
!collabSessions.has(roomId)
) {
restoreDocument(roomId);
}
});
};
Expand All @@ -172,6 +178,7 @@ const removeCollabSession = (roomId: string) => {
collabSessions.get(roomId)?.destroy();
collabSessions.delete(roomId);
partnerReadiness.delete(roomId);
redisClient.del(roomId);
};

const getDocument = (roomId: string) => {
Expand All @@ -188,28 +195,38 @@ const getDocument = (roomId: string) => {
return doc;
};

const saveDocument = async (roomId: string, doc: Doc) => {
const saveDocument = (roomId: string, doc: Doc) => {
const docState = encodeStateAsUpdateV2(doc);
const docAsString = Buffer.from(docState).toString("base64");
await redisClient.set(`collaboration:${roomId}`, docAsString, {
redisClient.set(`collaboration:${roomId}`, docAsString, {
EX: EXPIRY_TIME,
});
};

const restoreDocument = async (roomId: string) => {
const doc = getDocument(roomId);
const storeData = await redisClient.get(`collaboration:${roomId}`);

if (storeData) {
const tempDoc = new Doc();
const update = Buffer.from(storeData, "base64");
applyUpdateV2(tempDoc, new Uint8Array(update));
const tempText = tempDoc.getText().toString();

const text = doc.getText();
doc.transact(() => {
text.delete(0, text.length);
text.insert(0, tempText);
});
}
};

const handleUserLeave = (uid: string, roomId: string, socket: Socket) => {
const connectionKey = `${uid}:${roomId}`;
if (userConnections.has(connectionKey)) {
clearTimeout(userConnections.get(connectionKey)!);
userConnections.delete(connectionKey);
}
userConnections.delete(connectionKey);

socket.leave(roomId);
socket.disconnect();

const room = io.sockets.adapter.rooms.get(roomId);
if (!room || room.size === 0) {
removeCollabSession(roomId);
} else {
io.to(roomId).emit(CollabEvents.PARTNER_LEFT);
}
removeCollabSession(roomId);
};
48 changes: 30 additions & 18 deletions frontend/src/components/CodeEditor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,17 @@ import { useEffect, useState } from "react";
import { initDocument } from "../../utils/collabSocket";
import { cursorExtension } from "../../utils/collabCursor";
import { yCollab } from "y-codemirror.next";
import { Text } from "yjs";
import { Doc, Text } from "yjs";
import { Awareness } from "y-protocols/awareness";
import { useCollab } from "../../contexts/CollabContext";
import { USE_COLLAB_ERROR_MESSAGE, USE_MATCH_ERROR_MESSAGE } from "../../utils/constants";
import {
USE_COLLAB_ERROR_MESSAGE,
USE_MATCH_ERROR_MESSAGE,
} from "../../utils/constants";
import { useMatch } from "../../contexts/MatchContext";

interface CodeEditorProps {
editorState?: { text: Text; awareness: Awareness };
editorState?: { doc: Doc; text: Text; awareness: Awareness };
uid?: string;
username?: string;
language: string;
Expand Down Expand Up @@ -46,14 +49,15 @@ const CodeEditor: React.FC<CodeEditorProps> = (props) => {
throw new Error(USE_MATCH_ERROR_MESSAGE);
}

const { matchCriteria, matchUser, partner, questionId, questionTitle } = match;
const { matchCriteria, matchUser, partner, questionId, questionTitle } =
match;

const collab = useCollab();
if (!collab) {
throw new Error(USE_COLLAB_ERROR_MESSAGE);
}

const { setCode, checkDocReady } = collab;
const { checkDocReady } = collab;

const [isEditorReady, setIsEditorReady] = useState<boolean>(false);
const [isDocumentLoaded, setIsDocumentLoaded] = useState<boolean>(false);
Expand All @@ -64,25 +68,36 @@ const CodeEditor: React.FC<CodeEditorProps> = (props) => {
}
};

const handleChange = (value: string) => {
setCode(value);
};

useEffect(() => {
if (isReadOnly || !isEditorReady) {
if (isReadOnly || !isEditorReady || !editorState) {
return;
}

const loadTemplate = async () => {
if (matchUser && partner && matchCriteria && questionId && questionTitle) {
checkDocReady();
await initDocument(uid, roomId, template, matchUser.id, partner.id, matchCriteria.language, questionId, questionTitle);
if (
matchUser &&
partner &&
matchCriteria &&
questionId &&
questionTitle
) {
checkDocReady(roomId, editorState.doc, setIsDocumentLoaded);
await initDocument(
uid,
roomId,
template,
matchUser.id,
partner.id,
matchCriteria.language,
questionId,
questionTitle
);
setIsDocumentLoaded(true);
}
};
loadTemplate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isReadOnly, isEditorReady]);
}, [isReadOnly, isEditorReady, editorState]);

return (
<CodeMirror
Expand All @@ -92,7 +107,6 @@ const CodeEditor: React.FC<CodeEditorProps> = (props) => {
width="100%"
basicSetup={false}
id="codeEditor"
onChange={handleChange}
extensions={[
indentUnit.of("\t"),
basicSetup(),
Expand All @@ -109,9 +123,7 @@ const CodeEditor: React.FC<CodeEditorProps> = (props) => {
]}
value={isReadOnly ? template : undefined}
placeholder={
!isReadOnly && !isDocumentLoaded
? "Loading code template..."
: undefined
!isReadOnly && !isDocumentLoaded ? "Loading the code..." : undefined
}
/>
);
Expand Down
Loading

0 comments on commit 75529bb

Please sign in to comment.