Skip to content

Commit 4fd56a0

Browse files
authored
Merge pull request #14 from charlenetcy/feature/collaborative-editing
feat(collab): Add user presence notifications and cross-device support
2 parents 7e10b2e + 86a24a3 commit 4fd56a0

File tree

9 files changed

+6249
-3835
lines changed

9 files changed

+6249
-3835
lines changed

.env.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ NODE_ENV=development
55
SUPABASE_URL=
66
SUPABASE_KEY=
77

8-
PARTYKIT_HOST_URL='localhost:8082'
8+
PARTYKIT_HOST_URL='localhost:8082'
9+
VITE_NGROK_COLLAB_HOST=

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 ["npm", "run", "dev"]
24+
CMD sh -c 'echo "SUPABASE_URL=$SUPABASE_URL" > .env && echo "SUPABASE_KEY=$SUPABASE_KEY" >> .env && npm run dev'

collaboration-service/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"main": "index.js",
66
"type": "module",
77
"scripts": {
8-
"dev": "partykit dev --live --var SUPABASE_URL:$SUPABASE_URL --var SUPABASE_KEY:$SUPABASE_KEY",
8+
"dev": "npx partykit dev --live",
99
"lint": "cd .. && npm run lint:service collaboration-service --ext .ts",
1010
"lint:fix": "cd .. && npm run lint:service collaboration-service --ext .ts --fix",
1111
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\"",

collaboration-service/src/server.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,19 @@ import * as Y from 'yjs';
88

99
// TODO : separate database connection
1010
// Create a single supabase client for interacting with your database
11-
const supabase = createClient(
12-
process.env['SUPABASE_URL'] as string,
13-
process.env['SUPABASE_KEY'] as string,
14-
{auth: {persistSession: false}}
15-
);
11+
const SUPABASE_URL = process.env['SUPABASE_URL'] as string;
12+
const SUPABASE_KEY = process.env['SUPABASE_KEY'] as string;
13+
14+
if (!SUPABASE_URL || !SUPABASE_KEY) {
15+
console.error('FATAL ERROR: Missing required environment variables');
16+
console.error('Required: SUPABASE_URL, SUPABASE_KEY');
17+
console.error('Please check your .env file');
18+
throw new Error('Missing required environment variables: SUPABASE_URL, SUPABASE_KEY');
19+
}
20+
21+
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY, {
22+
auth: {persistSession: false}
23+
});
1624

1725
export default class YjsServer implements Party.Server {
1826
constructor(public room: Party.Room) {}
@@ -22,7 +30,7 @@ export default class YjsServer implements Party.Server {
2230
async load() {
2331
// This is called once per "room" when the first user connects
2432

25-
// Let's make a Yjs document
33+
// Creates the backend Yjs document
2634
const doc = new Y.Doc();
2735

2836
// Load the document from the database
@@ -50,7 +58,7 @@ export default class YjsServer implements Party.Server {
5058
console.log(`[${room.id}] No existing document found, creating new document`);
5159
}
5260

53-
// Return the Yjs document
61+
// Return the Yjs document to y-partykit to manage
5462
return doc;
5563
} catch (err) {
5664
console.error(`[${room.id}] Load failed:`, err);

docker-compose.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ services:
5656
- '3000:5173'
5757
environment:
5858
- NODE_ENV=development
59+
- VITE_NGROK_COLLAB_HOST=${VITE_NGROK_COLLAB_HOST}
5960
volumes:
6061
- ./frontend:/app
6162
- /app/node_modules
@@ -161,6 +162,7 @@ services:
161162
volumes:
162163
- ./collaboration-service:/app
163164
- /app/node_modules
165+
- /app/.partykit
164166
networks:
165167
- app-network
166168

frontend/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,13 @@
1212
"preview": "vite preview"
1313
},
1414
"dependencies": {
15-
"lucide-react": "^0.545.0",
1615
"@tanstack/react-query": "^5.90.2",
1716
"@tanstack/react-query-devtools": "^5.90.2",
1817
"axios": "^1.12.2",
18+
"lucide-react": "^0.545.0",
1919
"react": "^19.1.1",
2020
"react-dom": "^19.1.1",
21+
"react-hot-toast": "^2.6.0",
2122
"react-router-dom": "^7.9.2"
2223
},
2324
"devDependencies": {

frontend/src/App.tsx

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import Register from './pages/register';
44
import Login from './pages/login';
55
import Input from './collaboration/pages/Input';
66
import {CollabPage} from './collaboration/pages/CollabPage';
7+
import { Toaster } from 'react-hot-toast';
78
import VerifyEmail from './pages/verifyEmail';
89
import Profile from './pages/profile';
910
import AuthContainer from './components/authContainer';
@@ -20,31 +21,35 @@ function App() {
2021
const navigate = useNavigate();
2122
setNavigate(navigate); // Allows use of navigate within Axios.
2223
return (
23-
<Routes>
24-
<Route path="/home" element={<Home />} />
25-
<Route path="/register" element={<Register />} />
26-
<Route path="/complete-profile" element={<CompleteProfile />} />
27-
<Route path="/email/verify/:code" element={<VerifyEmail />} />
28-
<Route path="/login" element={<Login />} />
29-
<Route path="/password/forgot" element={<ForgotPassword />} />
30-
<Route path="/password/reset" element={<ResetPassword />} />
31-
// Authorized users only (defined as having verfieid email).
32-
<Route path="/" element={<AuthContainer />}>
33-
<Route index element={<Profile />} />
34-
<Route path="profile/" element={<UserProfile />} />
35-
<Route path="profile/settings" element={<ProfileSettings />} />
36-
<Route path="room" element={<Input />} />
37-
<Route path="room/:roomId" element={<CollabPage />} />
38-
<Route
39-
path="admin/manage"
40-
element={
41-
<AdminContainer>
42-
<AdminManagement />
43-
</AdminContainer>
44-
}
45-
/>
46-
</Route>
47-
</Routes>
24+
<>
25+
<Toaster />
26+
27+
<Routes>
28+
<Route path="/home" element={<Home />} />
29+
<Route path="/register" element={<Register />} />
30+
<Route path="/complete-profile" element={<CompleteProfile />} />
31+
<Route path="/email/verify/:code" element={<VerifyEmail />} />
32+
<Route path="/login" element={<Login />} />
33+
<Route path="/password/forgot" element={<ForgotPassword />} />
34+
<Route path="/password/reset" element={<ResetPassword />} />
35+
// Authorized users only (defined as having verfieid email).
36+
<Route path="/" element={<AuthContainer />}>
37+
<Route index element={<Profile />} />
38+
<Route path="profile/" element={<UserProfile />} />
39+
<Route path="profile/settings" element={<ProfileSettings />} />
40+
<Route path="room" element={<Input />} />
41+
<Route path="room/:roomId" element={<CollabPage />} />
42+
<Route
43+
path="admin/manage"
44+
element={
45+
<AdminContainer>
46+
<AdminManagement />
47+
</AdminContainer>
48+
}
49+
/>
50+
</Route>
51+
</Routes>
52+
</>
4853
);
4954
}
5055

frontend/src/collaboration/hooks/useCollabEditor.ts

Lines changed: 167 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,43 +2,197 @@
22
import React, {useEffect, useRef, useState} from 'react';
33
import YPartyKitProvider from 'y-partykit/provider';
44
import * as Y from 'yjs';
5+
import toast from 'react-hot-toast';
6+
7+
const USER_CONFIG = {
8+
COLORS: ['#2563EB', '#DC2626', '#059669', '#7C3AED','#EA580C', '#DB2777', '#0891B2', '#CA8A04'],
9+
ANIMALS: ['Panda', 'Tiger', 'Lion', 'Eagle', 'Shark', 'Wolf', 'Fox', 'Bear', 'Owl', 'Cat'],
10+
ADJECTIVES: ['Code Master', 'Cracked', 'Pro Progammer', 'Bug Terminator', 'Wise', 'Debugger', 'Coder', 'Sharp', 'Giga Chad'],
11+
TOAST_DURATION_MS: 6000,
12+
DUPLICATE_TOAST_DEBOUNCE_MS: 15000,
13+
} as const;
14+
15+
const getRandomElement = <T,>(array: readonly T[]): T => {
16+
return array[Math.floor(Math.random() * array.length)];
17+
};
18+
19+
const getRandomName = (): string => {
20+
const animal = getRandomElement(USER_CONFIG.ANIMALS);
21+
const adjective = getRandomElement(USER_CONFIG.ADJECTIVES);
22+
return `${adjective} ${animal}`;
23+
};
24+
25+
26+
27+
const handleUsersAdded = (
28+
added: number[],
29+
currentUserId: number,
30+
awareness: any,
31+
userNamesMap: Map<number, string>,
32+
showToast: boolean = true
33+
): void => {
34+
added.forEach((clientId) => {
35+
if (clientId === currentUserId) {
36+
return;
37+
}
38+
const user = awareness.getStates().get(clientId)?.user;
39+
const username = user?.name;
40+
41+
if (username) {
42+
userNamesMap.set(clientId, username);
43+
console.log(`! User added: Client ${clientId}: ${username}`);
44+
45+
if (showToast) {
46+
toast.success(`${username} joined the room`, {
47+
icon: '👋',
48+
duration: USER_CONFIG.TOAST_DURATION_MS,
49+
});
50+
}
51+
}
52+
});
53+
};
54+
55+
const handleUsersRemoved = (
56+
removed: number[],
57+
currentUserId: number,
58+
userNamesMap: Map<number, string>,
59+
recentlyRemovedSet: Set<number>,
60+
timeoutRefsMap: Map<number, NodeJS.Timeout>
61+
): void => {
62+
removed.forEach((clientId) => {
63+
if (clientId === currentUserId) {
64+
return;
65+
}
66+
67+
// Prevents duplicate notifications within debounce period
68+
if (recentlyRemovedSet.has(clientId)) {
69+
return;
70+
}
71+
72+
recentlyRemovedSet.add(clientId);
73+
74+
const username = userNamesMap.get(clientId) || 'Coding buddy';
75+
console.log('!! User left:', clientId, username);
76+
toast.error(`${username} left the room`, {
77+
icon: '👋',
78+
});
79+
80+
// Clears any existing timeout for this client (prevents duplicates)
81+
if (timeoutRefsMap.has(clientId)) {
82+
clearTimeout(timeoutRefsMap.get(clientId));
83+
}
84+
85+
// Clears debounce flag after timeout to allow future notifications
86+
const timeoutId = setTimeout(() => {
87+
recentlyRemovedSet.delete(clientId);
88+
timeoutRefsMap.delete(clientId);
89+
}, USER_CONFIG.DUPLICATE_TOAST_DEBOUNCE_MS);
90+
91+
timeoutRefsMap.set(clientId, timeoutId);
92+
});
93+
};
594

6-
// 5 bright colors
7-
const colours = ['#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#00FFFF'];
8-
// Pick a random color from the list
9-
const MY_COLOR = colours[Math.floor(Math.random() * colours.length)];
1095

1196
export default function useCollabEditor({roomId}: {roomId: string}) {
1297
const [isReady, setIsReady] = useState<boolean>(false);
1398
const ytextRef = useRef<Y.Text | null>(null);
1499
const providerRef = useRef<YPartyKitProvider | null>(null);
15100
const awarenessRef = useRef<any>(null);
16-
101+
const isFirstChangeRef = useRef<boolean>(true);
102+
const userNamesRef = useRef<Map<number, string>>(new Map());
103+
const recentlyRemovedRef = useRef<Set<number>>(new Set());
104+
const timeoutRefs = useRef(new Map<number, NodeJS.Timeout>());
105+
17106
useEffect(() => {
107+
console.log('useEffect RUNNING for room:', roomId);
108+
109+
if (providerRef.current) {
110+
console.warn('Provider already exists!');
111+
return;
112+
}
18113
const provider = new YPartyKitProvider(
19-
import.meta.env.PARTYKIT_HOST_URL || 'localhost:8082', //host
114+
import.meta.env.VITE_NGROK_COLLAB_HOST || 'localhost:8082', //host
20115
roomId //room
21116
);
117+
console.log('Created new provider for room:', roomId);
118+
console.log(' Client ID:', provider.awareness.clientID);
22119
providerRef.current = provider;
23-
120+
24121
if (!provider) return;
25-
26-
// Get the shared text from the provider's document
122+
123+
// Gets the shared text from the provider's document
27124
const ytext = provider.doc.getText('codemirror');
28125
ytextRef.current = ytext;
126+
127+
// Sets up user awareness
128+
const currUserId = provider.awareness.clientID;
129+
const username = getRandomName();
130+
const userColor = getRandomElement(USER_CONFIG.COLORS);
131+
132+
console.log('Setting curr user info:', {clientId: currUserId, name: username});
29133

30-
// Set up user awareness
31134
provider.awareness.setLocalStateField('user', {
32-
name: `User-${Math.random().toString(36).substr(2, 4)}`,
33-
color: MY_COLOR,
34-
colorLight: MY_COLOR + '80',
135+
name: username,
136+
color: userColor,
137+
colorLight: userColor + '80',
35138
});
36139

37140
awarenessRef.current = provider.awareness;
141+
isFirstChangeRef.current = true;
38142

143+
// Listens for awareness changes (users joining or leaving)
144+
const awarenessChangeHandler = ({added, removed}: {added: number[]; removed: number[];}) => {
145+
if (isFirstChangeRef.current) {
146+
console.log('Initial sync');
147+
148+
const allUsers = Array.from(provider.awareness.getStates().entries()).map(
149+
([id, state]) => ({
150+
clientId: id,
151+
name: state.user?.name || 'Unknown',
152+
isMe: id === currUserId,
153+
})
154+
);
155+
console.log('All users in awareness:', allUsers);
156+
157+
handleUsersAdded(added, currUserId, provider.awareness, userNamesRef.current, false);
158+
159+
isFirstChangeRef.current = false;
160+
return;
161+
}
162+
163+
if (added.length === 0 && removed.length === 0) {
164+
console.log('Skipping empty awareness event!');
165+
return;
166+
}
167+
168+
console.log('Processing awareness change (not initial sync), added:', added, 'removed:', removed);
169+
170+
handleUsersAdded(added, currUserId, provider.awareness, userNamesRef.current);
171+
console.log(timeoutRefs.current);
172+
handleUsersRemoved(removed, currUserId, userNamesRef.current, recentlyRemovedRef.current, timeoutRefs.current);
173+
console.log(timeoutRefs.current);
174+
175+
};
176+
177+
provider.awareness.on('change', awarenessChangeHandler);
39178
setIsReady(true);
179+
40180
return () => {
181+
console.log('Cleanup! destroying provider for room:', roomId);
182+
183+
provider.awareness.off('change', awarenessChangeHandler);
184+
userNamesRef.current.clear();
185+
recentlyRemovedRef.current.clear();
186+
187+
timeoutRefs.current.forEach((timeoutId) => {
188+
clearTimeout(timeoutId);
189+
});
190+
timeoutRefs.current.clear();
191+
41192
setIsReady(false);
193+
if (provider.awareness) {
194+
provider.awareness.setLocalState(null);
195+
}
42196
provider.destroy(); //disconnect the WebSocket
43197
providerRef.current = null;
44198
ytextRef.current = null;

0 commit comments

Comments
 (0)