Skip to content

Commit cb8f087

Browse files
authored
Merge pull request #40 from charlenetcy/develop
feat(collaboration-service): Implement chat feature
2 parents 7ceaf72 + d8ba255 commit cb8f087

File tree

12 files changed

+605
-273
lines changed

12 files changed

+605
-273
lines changed

collaboration-service/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,4 @@ COPY . .
2121
EXPOSE 8082
2222

2323
# Start the application
24-
CMD sh -c 'echo "SUPABASE_URL=$SUPABASE_URL" > .env && echo "SUPABASE_KEY=$SUPABASE_KEY" >> .env && echo "JWT_SECRET=$JWT_SECRET" >> .env && npm run dev'
24+
CMD sh -c 'echo "SUPABASE_URL=$SUPABASE_URL" > .env && echo "SUPABASE_KEY=$SUPABASE_KEY" >> .env && echo "JWT_SECRET=$JWT_SECRET" >> .env && echo "USER_SERVICE_URL=$USER_SERVICE_URL" >> .env && npm run dev'

collaboration-service/src/party/codeServer.ts

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,32 @@ import * as Y from 'yjs';
77
import {parseCookies} from '../utils/cookies.js';
88
import {verifyToken} from '../utils/jwt.js';
99
import {getDocument, upsertDocument, checkRoomExists, checkUserVerified} from '../storage/db.js';
10+
11+
const CHAT_HISTORY_LIMIT = 500;
12+
const CHAT_COLLECTION_KEY = 'chatMessages';
13+
const EXECUTION_STATE_KEY = 'executionState';
14+
15+
function ensureSharedStructures(doc: Y.Doc) {
16+
doc.getText('codemirror');
17+
doc.getMap<string>('config');
18+
doc.getArray(CHAT_COLLECTION_KEY);
19+
doc.getMap(EXECUTION_STATE_KEY);
20+
}
21+
22+
function pruneChatHistory(doc: Y.Doc) {
23+
const chatArray = doc.getArray(CHAT_COLLECTION_KEY);
24+
if (chatArray.length <= CHAT_HISTORY_LIMIT) {
25+
return;
26+
}
27+
const excess = chatArray.length - CHAT_HISTORY_LIMIT;
28+
chatArray.delete(0, excess);
29+
}
1030
// import {roomRouter} from '../api/roomRoutes.js';
1131

1232
export default class YjsServer implements Party.Server {
1333
constructor(public room: Party.Room) {}
1434

15-
static async onBeforeConnect(request: Party.Request, lobby: Party.Lobby) {
35+
static async onBeforeConnect(request: Party.Request, _lobby: Party.Lobby) {
1636
try {
1737
const cookieHeader = request.headers.get('cookie');
1838
if (!cookieHeader) {
@@ -46,15 +66,28 @@ export default class YjsServer implements Party.Server {
4666
return new Response('Room not found', {status: 404});
4767
}
4868

49-
const isUserVerified = await checkUserVerified(payload.userId.toString(), roomId);
69+
// const isUserVerified = await checkUserVerified(payload.userId.toString(), roomId);
5070

51-
if (!isUserVerified) {
52-
console.error(`User not authorised to enter this room`);
53-
return new Response('Unauthorised : User not authorised to enter this room', {status: 401});
54-
}
71+
// if (!isUserVerified) {
72+
// console.error(`User not authorised to enter this room`);
73+
// return new Response('Unauthorised : User not authorised to enter this room', {status: 401});
74+
// }
5575

5676
request.headers.set('X-User-ID', payload.userId.toString());
57-
request.headers.set('X-Session-ID', payload.sessionId.toString());
77+
78+
if (payload.sessionId) {
79+
try {
80+
request.headers.set('X-Session-ID', payload.sessionId.toString());
81+
} catch (sessionIdError) {
82+
console.warn(
83+
'Session ID missing or malformed on token payload',
84+
sessionIdError,
85+
payload.sessionId
86+
);
87+
}
88+
} else {
89+
console.info('Session ID not present on token payload; continuing without it');
90+
}
5891

5992
return request;
6093
} catch (e) {
@@ -85,14 +118,19 @@ export default class YjsServer implements Party.Server {
85118
try {
86119
const buffer = Buffer.from(data.document, 'base64');
87120
Y.applyUpdate(doc, new Uint8Array(buffer));
121+
ensureSharedStructures(doc);
122+
pruneChatHistory(doc);
88123
} catch (parseErr) {
89124
console.warn(`[${room.id}] Data corrupted, creating new document`);
90125
}
91126
} else {
92127
console.log(`[${room.id}] No existing document found, creating new document`);
128+
ensureSharedStructures(doc);
93129
}
94130

95131
// Return the Yjs document to y-partykit to manage
132+
ensureSharedStructures(doc);
133+
pruneChatHistory(doc);
96134
return doc;
97135
} catch (err) {
98136
console.error(`[${room.id}] Load failed:`, err);
@@ -105,6 +143,7 @@ export default class YjsServer implements Party.Server {
105143

106144
// convert the Yjs document to a Uint8Array
107145
try {
146+
pruneChatHistory(doc);
108147
const content = Y.encodeStateAsUpdate(doc);
109148

110149
// Save the document to the database

docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ services:
178178
- SUPABASE_URL=${SUPABASE_URL}
179179
- SUPABASE_KEY=${SUPABASE_KEY}
180180
- JWT_SECRET=${JWT_SECRET}
181+
- USER_SERVICE_URL=${USER_SERVICE_URL}
181182
env_file:
182183
- ./.env
183184
volumes:
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import React, {useState, useRef, useEffect} from 'react';
2+
import YPartyKitProvider from 'y-partykit/provider';
3+
import {Send} from 'lucide-react';
4+
import {useChat, type ChatMessage} from '../hooks/useChat';
5+
6+
export function ChatPanel({provider}: {provider: YPartyKitProvider | null}) {
7+
const {messages, sendMessage, isReady} = useChat({provider});
8+
const [newMessage, setNewMessage] = useState('');
9+
const messagesEndRef = useRef<HTMLDivElement>(null);
10+
11+
const handleSend = (e: React.FormEvent) => {
12+
e.preventDefault();
13+
sendMessage(newMessage);
14+
setNewMessage('');
15+
};
16+
17+
// Auto-scroll to the bottom when new messages arrive
18+
useEffect(() => {
19+
messagesEndRef.current?.scrollIntoView({behavior: 'smooth'});
20+
}, [messages]);
21+
22+
if (!isReady) {
23+
return <div className="p-4 text-sm text-gray-500">Chat connecting...</div>;
24+
}
25+
26+
return (
27+
<div className="flex flex-col h-full bg-white border-l border-gray-200">
28+
<div className="p-4 border-b border-gray-200">
29+
<h2 className="font-semibold text-gray-800">Session Chat</h2>
30+
</div>
31+
32+
{/* Message List */}
33+
<div className="flex-1 overflow-y-auto p-4 space-y-4">
34+
{messages.map(msg => (
35+
<MessageItem key={msg.id} msg={msg} />
36+
))}
37+
<div ref={messagesEndRef} />
38+
</div>
39+
40+
{/* Message Input Form */}
41+
<div className="p-4 border-t border-gray-200 bg-gray-50">
42+
<form onSubmit={handleSend} className="flex space-x-2">
43+
<input
44+
type="text"
45+
value={newMessage}
46+
onChange={e => setNewMessage(e.target.value)}
47+
placeholder="Type a message..."
48+
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
49+
autoComplete="off"
50+
/>
51+
<button
52+
type="submit"
53+
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 disabled:bg-gray-400"
54+
disabled={!newMessage.trim()}
55+
>
56+
<Send size={16} />
57+
</button>
58+
</form>
59+
</div>
60+
</div>
61+
);
62+
}
63+
64+
function MessageItem({msg}: {msg: ChatMessage}) {
65+
const sentDate = new Date(msg.timestamp);
66+
const time = sentDate.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'});
67+
68+
return (
69+
<div className="flex flex-col">
70+
<div className="flex items-center space-x-2">
71+
<span className="font-semibold text-sm text-gray-800">{msg.username}</span>
72+
<span className="text-xs text-gray-400">{time}</span>
73+
</div>
74+
<p className="text-sm text-gray-700">{msg.text}</p>
75+
</div>
76+
);
77+
}

frontend/src/collaboration/components/CollabEditor.tsx

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,29 @@
1+
import type YPartyKitProvider from 'y-partykit/provider';
12
import useCollabEditor from '../hooks/useCollabEditor';
23
import CodeMirror from './CodeMirror';
34
import React from 'react';
45

56
const LANGUAGE_OPTIONS = [
6-
{ value: 'python', label: 'Python' },
7-
{ value: 'javascript', label: 'JavaScript' },
8-
{ value: 'cpp', label: 'C++' },
9-
{ value: 'java', label: 'Java' },
10-
{ value: 'default', label: 'Plain Text' },
7+
{value: 'python', label: 'Python'},
8+
{value: 'javascript', label: 'JavaScript'},
9+
{value: 'cpp', label: 'C++'},
10+
{value: 'java', label: 'Java'},
11+
{value: 'default', label: 'Plain Text'},
1112
];
1213

13-
export default function CollabEditor({roomId}: {roomId: string}) {
14-
const {ytext, awareness, isReady, languageConfig, setSharedLanguage} = useCollabEditor({roomId});
14+
export default function CollabEditor({
15+
roomId,
16+
provider,
17+
}: {
18+
roomId: string;
19+
provider: YPartyKitProvider;
20+
}) {
21+
// const {ytext, awareness, isReady, languageConfig, setSharedLanguage} = useCollabEditor({roomId});
22+
const {ytext, awareness, isReady, languageConfig, setSharedLanguage} = useCollabEditor({
23+
roomId,
24+
provider,
25+
});
26+
1527
if (!isReady || !ytext) {
1628
return <div>Loading...</div>;
1729
}
@@ -22,14 +34,26 @@ export default function CollabEditor({roomId}: {roomId: string}) {
2234

2335
return (
2436
<div style={{padding: '20px'}}>
25-
<div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px'}}>
37+
<div
38+
style={{
39+
display: 'flex',
40+
justifyContent: 'space-between',
41+
alignItems: 'center',
42+
marginBottom: '10px',
43+
}}
44+
>
2645
<h2>Happy Coding :D</h2>
2746
<label>
28-
Language:
29-
<select
30-
value={languageConfig}
47+
Language:
48+
<select
49+
value={languageConfig}
3150
onChange={handleLanguageChange}
32-
style={{marginLeft: '10px', padding: '5px', borderRadius: '4px', border: '1px solid #ccc'}}
51+
style={{
52+
marginLeft: '10px',
53+
padding: '5px',
54+
borderRadius: '4px',
55+
border: '1px solid #ccc',
56+
}}
3357
>
3458
{LANGUAGE_OPTIONS.map(option => (
3559
<option key={option.value} value={option.value}>

frontend/src/collaboration/components/SubmissionPanel.tsx

Lines changed: 60 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
import {Send, Code, Image, Play, Eye, LogOut, Mic, Video} from 'lucide-react';
2-
import {useState} from 'react';
2+
import {useEffect, useState} from 'react';
33
import ConfirmDialog from '../../components/ConfirmDialog';
4+
import {ChatPanel} from './ChatPanel';
5+
import YPartyKitProvider from 'y-partykit/provider';
6+
import type {AwarenessUser} from '../hooks/useCollabRoom';
47

58
export default function SubmissionPanel({
69
isPenaltyOver,
710
handleLeaveRoom,
11+
provider,
812
}: {
913
isPenaltyOver: boolean;
1014
handleLeaveRoom: () => void;
15+
provider: YPartyKitProvider | null;
1116
}) {
1217
const [isDialogOpen, setIsDialogOpen] = useState(false);
18+
const [users, setUsers] = useState<AwarenessUser[]>([]);
1319

1420
function handleEndSession() {
1521
setIsDialogOpen(true);
@@ -25,52 +31,67 @@ export default function SubmissionPanel({
2531
}
2632
handleLeaveRoom();
2733
}
34+
35+
useEffect(() => {
36+
if (!provider) {
37+
setUsers([]);
38+
return;
39+
}
40+
41+
const awareness = provider.awareness; // Get awareness from the provider
42+
43+
const updateUsers = () => {
44+
const states = Array.from(awareness.getStates().values());
45+
const userList = states.map(state => state.user).filter(Boolean) as AwarenessUser[];
46+
setUsers(userList);
47+
};
48+
49+
awareness.on('change', updateUsers);
50+
updateUsers(); // Initial load
51+
52+
return () => {
53+
awareness.off('change', updateUsers);
54+
setUsers([]); // Cleanup state
55+
};
56+
}, [provider]);
57+
58+
const localUser = provider?.awareness.getLocalState()?.user as AwarenessUser | undefined;
59+
60+
// Find the first user in the list who is not the local user
61+
const otherUser = users.find(u => u.name !== localUser?.name);
62+
2863
return (
2964
<div className="w-96 bg-white border-l border-gray-200 flex flex-col">
3065
{/* User Avatars */}
3166
<div className="p-4 border-b border-gray-200 flex space-x-3">
32-
<div className="flex-1 bg-blue-500 rounded-lg p-4 text-white flex flex-col items-center justify-center">
33-
<Mic size={20} className="mb-1" />
34-
<Video size={20} className="mb-2" />
35-
<span className="font-semibold text-lg">You</span>
36-
</div>
37-
<div className="flex-1 bg-green-500 rounded-lg p-4 text-white flex items-center justify-center">
38-
<span className="font-semibold text-lg">Alex</span>
39-
</div>
40-
</div>
41-
42-
{/* Chat Header */}
43-
<div className="px-4 py-3 border-b border-gray-200">
44-
<h2 className="font-semibold text-lg">Chat</h2>
45-
</div>
46-
47-
{/* Messages */}
48-
<div className="flex-1 overflow-y-auto p-4 space-y-4">
49-
<div className="text-left">
50-
<div className="text-xs text-gray-500 mb-1">Alex · 2:14 PM</div>
51-
<div className="inline-block bg-gray-100 rounded-lg px-3 py-2 text-sm">
52-
Hey! Ready to chat?
67+
{localUser && (
68+
<div
69+
className="flex-1 rounded-lg p-4 text-white flex flex-col items-center justify-center"
70+
style={{backgroundColor: localUser.color}}
71+
>
72+
<Mic size={20} className="mb-1" />
73+
<Video size={20} className="mb-2" />
74+
<span className="font-semibold text-lg">{localUser.name} (You)</span>
5375
</div>
54-
</div>
55-
56-
<div className="text-right">
57-
<div className="text-xs text-gray-500 mb-1 text-right">You · 2:15 PM</div>
58-
<div className="inline-block bg-blue-500 text-white rounded-lg px-3 py-2 text-sm">
59-
Nope! This feature is under construction
76+
)}
77+
{otherUser && (
78+
<div
79+
className="flex-1 rounded-lg p-4 text-white flex items-center justify-center"
80+
style={{backgroundColor: otherUser.color}}
81+
>
82+
<span className="font-semibold text-lg">{otherUser.name}</span>
6083
</div>
61-
</div>
84+
)}
85+
{!otherUser && (
86+
<div className="flex-1 bg-gray-200 rounded-lg p-4 text-gray-500 flex items-center justify-center">
87+
<span className="font-semibold text-lg">Waiting...</span>
88+
</div>
89+
)}
6290
</div>
6391

64-
{/* Message Input */}
65-
<div className="flex items-center space-x-2 mb-3">
66-
<input
67-
type="text"
68-
placeholder="Type a message..."
69-
className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
70-
/>
71-
<button className="bg-blue-500 hover:bg-blue-600 text-white p-2 rounded-lg">
72-
<Send size={18} />
73-
</button>
92+
{/*Chat Panel*/}
93+
<div className="flex-1 flex flex-col h-0">
94+
<ChatPanel provider={provider} />
7495
</div>
7596

7697
<div className="p-4 border-t border-gray-200">

0 commit comments

Comments
 (0)