|
2 | 2 | import React, {useEffect, useRef, useState} from 'react'; |
3 | 3 | import YPartyKitProvider from 'y-partykit/provider'; |
4 | 4 | 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 | +}; |
5 | 94 |
|
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)]; |
10 | 95 |
|
11 | 96 | export default function useCollabEditor({roomId}: {roomId: string}) { |
12 | 97 | const [isReady, setIsReady] = useState<boolean>(false); |
13 | 98 | const ytextRef = useRef<Y.Text | null>(null); |
14 | 99 | const providerRef = useRef<YPartyKitProvider | null>(null); |
15 | 100 | 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 | + |
17 | 106 | useEffect(() => { |
| 107 | + console.log('useEffect RUNNING for room:', roomId); |
| 108 | + |
| 109 | + if (providerRef.current) { |
| 110 | + console.warn('Provider already exists!'); |
| 111 | + return; |
| 112 | + } |
18 | 113 | 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 |
20 | 115 | roomId //room |
21 | 116 | ); |
| 117 | + console.log('Created new provider for room:', roomId); |
| 118 | + console.log(' Client ID:', provider.awareness.clientID); |
22 | 119 | providerRef.current = provider; |
23 | | - |
| 120 | + |
24 | 121 | 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 |
27 | 124 | const ytext = provider.doc.getText('codemirror'); |
28 | 125 | 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}); |
29 | 133 |
|
30 | | - // Set up user awareness |
31 | 134 | 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', |
35 | 138 | }); |
36 | 139 |
|
37 | 140 | awarenessRef.current = provider.awareness; |
| 141 | + isFirstChangeRef.current = true; |
38 | 142 |
|
| 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); |
39 | 178 | setIsReady(true); |
| 179 | + |
40 | 180 | 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 | + |
41 | 192 | setIsReady(false); |
| 193 | + if (provider.awareness) { |
| 194 | + provider.awareness.setLocalState(null); |
| 195 | + } |
42 | 196 | provider.destroy(); //disconnect the WebSocket |
43 | 197 | providerRef.current = null; |
44 | 198 | ytextRef.current = null; |
|
0 commit comments