Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add collab page #183

Merged
merged 28 commits into from
Nov 3, 2024
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
0363b51
Create basic collab room skeleton with code editor
dylkaw Oct 31, 2024
46682c7
Add chat placeholder
dylkaw Oct 31, 2024
78b8180
Add Question placeholder
dylkaw Oct 31, 2024
392d1ab
Merge branch 'main' into feat/collab-service/collab-page
dylkaw Oct 31, 2024
4b8b23e
Add routing to collab room
dylkaw Oct 31, 2024
5428fb8
Fix ESLint issues
dylkaw Oct 31, 2024
17c23e5
Fix prettier issues
dylkaw Oct 31, 2024
332084b
Standardise route with /app
dylkaw Oct 31, 2024
dec0816
Merge branch 'main' into feat/collab-service/collab-page
SelwynAng Nov 2, 2024
2672ed0
Link up frontend with collab service to redirect to common room
SelwynAng Nov 2, 2024
d04771c
Set up real time communication chat between users in same room
SelwynAng Nov 2, 2024
718cf32
Run yarn prettier fix
SelwynAng Nov 2, 2024
3e36b54
Reorganise utility functions for collab service api
SelwynAng Nov 2, 2024
ab06445
Resolve scroll down bug
SelwynAng Nov 2, 2024
03858fa
Allow matching service to fetch random question from question service
SelwynAng Nov 2, 2024
fd6e676
Enable fetching of question details to the frontend
SelwynAng Nov 2, 2024
f6381fa
Edit collab backend such that room requires question id
SelwynAng Nov 2, 2024
6f8b7f1
Remove excess console log statements
SelwynAng Nov 2, 2024
5b01757
Shift create room logic from frontend to matching service
SelwynAng Nov 2, 2024
608f28d
Resolve merge conflict
SelwynAng Nov 2, 2024
6050f39
Integrate collaborative monaco editor
SelwynAng Nov 2, 2024
c3f9789
Add language switching
dylkaw Nov 3, 2024
2c6698e
Resolve lint and prettier issues
dylkaw Nov 3, 2024
2bd1ee8
Resolve type issue
dylkaw Nov 3, 2024
b9b5bce
Chat: Fix bug where same user won't see message sent from another window
wr1159 Nov 3, 2024
b1ef5d6
Merge branch 'feat/collab-service/collab-page' of github.com:CS3219-A…
wr1159 Nov 3, 2024
70948a0
Enable toggling of theme for code editor
SelwynAng Nov 3, 2024
759666a
Remove debug statement
SelwynAng Nov 3, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions collab-service/app/controller/collab-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,25 @@ import {
getRoomId,
heartbeat,
getAllRooms,
getQuestionIdByRoomId,
} from "../model/repository.js";
import crypto from "crypto";

// Create a room between two users
export async function createRoom(req, res) {
const { user1, user2 } = req.body;
const { user1, user2, question_id } = req.body;

if (!user1 || !user2) {
return res.status(400).json({ error: "Both user1 and user2 are required" });
if (!user1 || !user2 || !question_id) {
return res.status(400).json({ error: "user1,user2 and question_id are required" });
}

// Generate a unique room ID by hashing the two user IDs
const timeSalt = new Date().toISOString().slice(0, 13);
const roomId = crypto
.createHash("sha256")
.update(user1 + user2)
.update(user1 + user2 + timeSalt)
.digest("hex");
const room = await newRoom(user1, user2, roomId);
const room = await newRoom(user1, user2, roomId, question_id);

if (room) {
res.status(201).json(room);
Expand Down Expand Up @@ -72,3 +74,20 @@ export async function getAllRoomsController(req, res) {
res.status(500).json({ error: "Failed to retrieve rooms" });
}
}

// Get QuestionId from the room based on the roomId
export async function getQuestionId(req, res) {
const { roomId } = req.params;

if (!roomId) {
return res.status(400).json({ error: "Room ID is required" });
}

const questionId = await getQuestionIdByRoomId(roomId);

if (questionId) {
res.status(200).json({ questionId });
} else {
res.status(404).json({ error: `Question ID not found for room ID: ${roomId}` });
}
}
16 changes: 15 additions & 1 deletion collab-service/app/model/repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@ export async function connectToMongo() {
await connect(process.env.DB_URI);
}

export async function newRoom(user1, user2, roomId) {
export async function newRoom(user1, user2, roomId, questionId) {
try {
// Remove any existing rooms where either user1 or user2 is a participant
await UsersSession.deleteMany({ users: { $in: [user1, user2] } });
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can remove this if we're implementing the past rooms history feature


const newRoom = new UsersSession({
users: [user1, user2],
roomId: roomId,
questionId: questionId,
lastUpdated: new Date(),
});

Expand Down Expand Up @@ -102,3 +106,13 @@ export async function addMessageToChat(roomId, userId, text) {
throw error;
}
}

export async function getQuestionIdByRoomId(roomId) {
try {
const room = await UsersSession.findOne({ roomId });
return room ? room.questionId : null;
} catch (error) {
console.error(`Error finding questionId for roomId ${roomId}:`, error);
return null;
}
}
4 changes: 4 additions & 0 deletions collab-service/app/model/usersSession-model.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ const usersSessionSchema = new Schema({
type: String,
required: true,
},
questionId: {
type: String,
required: true,
},
lastUpdated: {
type: Date,
required: true,
Expand Down
3 changes: 3 additions & 0 deletions collab-service/app/routes/collab-routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
getRoomByUser,
updateHeartbeat,
getAllRoomsController,
getQuestionId
} from "../controller/collab-controller.js";

const router = express.Router();
Expand All @@ -16,4 +17,6 @@ router.patch("/heartbeat/:roomId", updateHeartbeat);

router.get("/rooms", getAllRoomsController);

router.get("/rooms/:roomId/questionId", getQuestionId);

export default router;
6 changes: 6 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ services:
- USER_SVC_PORT=$USER_SVC_PORT
- QUESTION_SVC_PORT=$QUESTION_SVC_PORT
- MATCHING_SVC_PORT=$MATCHING_SVC_PORT
- COLLAB_SVC_PORT=$COLLAB_SVC_PORT
ports:
- $FRONTEND_PORT:$FRONTEND_PORT
depends_on:
- question-service
- user-service
- collab-service
environment:
- PORT=$FRONTEND_PORT

Expand Down Expand Up @@ -46,8 +48,12 @@ services:
- PORT=$MATCHING_SVC_PORT
- REDIS_HOST=redis
- REDIS_PORT=$REDIS_PORT
- QUESTION_SVC_PORT=$QUESTION_SVC_PORT
- COLLAB_SVC_PORT=$COLLAB_SVC_PORT
depends_on:
- redis
- question-service
- collab-service

redis:
image: redis:7.4-alpine
Expand Down
6 changes: 4 additions & 2 deletions frontend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@ FROM node:20-alpine AS base
ARG BASE_URI \
USER_SVC_PORT \
QUESTION_SVC_PORT \
MATCHING_SVC_PORT
MATCHING_SVC_PORT \
COLLAB_SVC_PORT
WORKDIR /app
COPY package.json .
COPY yarn.lock .
RUN yarn install --frozen-lockfile
ENV NEXT_PUBLIC_BASE_URI=$BASE_URI \
NEXT_PUBLIC_USER_SVC_PORT=$USER_SVC_PORT \
NEXT_PUBLIC_QUESTION_SVC_PORT=$QUESTION_SVC_PORT \
NEXT_PUBLIC_MATCHING_SVC_PORT=$MATCHING_SVC_PORT
NEXT_PUBLIC_MATCHING_SVC_PORT=$MATCHING_SVC_PORT \
NEXT_PUBLIC_COLLAB_SVC_PORT=$COLLAB_SVC_PORT

# Production build stage
FROM base AS build
Expand Down
21 changes: 11 additions & 10 deletions frontend/app/app/collab/[room_id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import dynamic from "next/dynamic";
import AuthPageWrapper from "@/components/auth/auth-page-wrapper";
import CollabRoom from "@/components/collab/collab-room";
import { Suspense } from "react";

const MonacoEditor = dynamic(
() => import("@/components/collab/monaco-editor"),
{
ssr: false,
}
);

export default function CollabRoom({
export default function CollabPage({
params,
}: {
params: { room_id: string };
}) {
return <MonacoEditor roomId={params.room_id} />;
return (
<AuthPageWrapper requireLoggedIn>
<Suspense>
<CollabRoom roomId={params.room_id} />
</Suspense>
</AuthPageWrapper>
);
}
184 changes: 184 additions & 0 deletions frontend/components/collab/chat.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
"use client";

import React, { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Send } from "lucide-react";
import { io, Socket } from "socket.io-client";
import { useAuth } from "@/app/auth/auth-context";
import LoadingScreen from "@/components/common/loading-screen";

interface Message {
id: string;
userId: string;
text: string;
timestamp: Date;
}

export default function Chat({ roomId }: { roomId: string }) {
const auth = useAuth();
const own_user_id = auth?.user?.id;
const [socket, setSocket] = useState<Socket | null>(null);
const [chatTarget, setChatTarget] = useState<string>("partner");
const [newMessage, setNewMessage] = useState<string>("");
const [partnerMessages, setPartnerMessages] = useState<Message[]>([]);
const [aiMessages, setAiMessages] = useState<Message[]>([]);
const [isConnected, setIsConnected] = useState(false);
const lastMessageRef = useRef<HTMLDivElement | null>(null);

useEffect(() => {
if (!auth?.user?.id) return; // Avoid connecting if user is not authenticated

const socketInstance = io(
process.env.NEXT_PUBLIC_COLLAB_SERVICE_URL || "http://localhost:3002",
{
auth: { userId: own_user_id },
}
);

socketInstance.on("connect", () => {
console.log("Connected to Socket.IO");
setIsConnected(true);
socketInstance.emit("joinRoom", roomId);
});

socketInstance.on("disconnect", () => {
console.log("Disconnected from Socket.IO");
setIsConnected(false);
});

socketInstance.on("chatMessage", (message: Message) => {
setPartnerMessages((prev) => [...prev, message]);
});

setSocket(socketInstance);

return () => {
socketInstance.disconnect();
};
}, [roomId, own_user_id, auth?.user?.id]);

useEffect(() => {
const scrollWithDelay = () => {
setTimeout(() => {
if (lastMessageRef.current) {
lastMessageRef.current.scrollIntoView({ behavior: "smooth" });
}
}, 100); // Delay to ensure the DOM is fully rendered
};

scrollWithDelay();
}, [partnerMessages, aiMessages, chatTarget]);

const sendMessage = () => {
if (!newMessage.trim() || !socket || !isConnected || !own_user_id) return;

const message = {
id: crypto.randomUUID(),
userId: own_user_id,
text: newMessage,
timestamp: new Date(),
};

if (chatTarget === "partner") {
socket.emit("sendMessage", {
roomId,
userId: own_user_id,
text: newMessage,
});
} else {
setAiMessages((prev) => [...prev, message]);
}

setNewMessage("");
};

const formatTimestamp = (date: Date) => {
return new Date(date).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
};

const renderMessage = (message: Message, isOwnMessage: boolean) => (
<div
key={message.id}
className={`p-2 rounded-lg mb-2 max-w-[80%] ${
isOwnMessage
? "ml-auto bg-blue-500 text-white"
: "bg-gray-100 dark:bg-gray-800"
}`}
>
<div className="text-sm">{message.text}</div>
<div
className={`text-xs ${isOwnMessage ? "text-blue-100" : "text-gray-500"}`}
>
{formatTimestamp(message.timestamp)}
</div>
</div>
);

if (!own_user_id) {
return <LoadingScreen />;
}

return (
<Card className="flex flex-col">
<CardHeader>
<CardTitle className="flex justify-between items-center">
Chat
<span
className={`h-2 w-2 rounded-full ${isConnected ? "bg-green-500" : "bg-red-500"}`}
/>
</CardTitle>
</CardHeader>
<CardContent className="flex-1 flex flex-col">
<Tabs
value={chatTarget}
onValueChange={setChatTarget}
className="flex-col"
>
<TabsList className="flex-shrink-0 mb-2">
<TabsTrigger value="partner">Partner Chat</TabsTrigger>
<TabsTrigger value="ai">AI Chat</TabsTrigger>
</TabsList>
<TabsContent value="partner" className="h-full">
<ScrollArea className="h-[calc(70vh-280px)]">
<div className="pr-4 space-y-2">
{partnerMessages.map((msg) =>
renderMessage(msg, msg.userId === own_user_id)
)}
<div ref={lastMessageRef} />
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="ai" className="h-full">
<ScrollArea className="h-[calc(70vh-280px)]">
<div className="pr-4 space-y-2">
{aiMessages.map((msg) =>
renderMessage(msg, msg.userId === own_user_id)
)}
<div ref={lastMessageRef} />
</div>
</ScrollArea>
</TabsContent>
</Tabs>
<div className="flex space-x-2 mt-4 pt-4 border-t">
<Input
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
placeholder={`Message ${chatTarget === "partner" ? "your partner" : "AI assistant"}...`}
onKeyDown={(e) => e.key === "Enter" && sendMessage()}
disabled={!isConnected}
/>
<Button onClick={sendMessage} disabled={!isConnected}>
<Send className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
);
}
Loading