import { useEffect, useRef, useState } from 'react'; import * as signalR from '@microsoft/signalr'; export const useSignalR = (projectId, user, onRemoteMessage) => { const [peers, setPeers] = useState({}); const [activeUsers, setActiveUsers] = useState([]); const connectionRef = useRef(null); const peerConnectionsRef = useRef({}); // userId -> RTCPeerConnection const dataChannelsRef = useRef({}); // userId -> RTCDataChannel const pendingCandidatesRef = useRef({}); // userId -> RTCIceCandidate[] const activeUsersRef = useRef([]); const onRemoteMessageRef = useRef(onRemoteMessage); useEffect(() => { onRemoteMessageRef.current = onRemoteMessage; }, [onRemoteMessage]); const API_URL = import.meta.env.VITE_API_URL; const token = localStorage.getItem('token') || ''; // Standard RTC Configuration with public Google STUN and openrelay TURN servers for robust NAT traversing const rtcConfig = { iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }, { urls: 'stun:stun2.l.google.com:19302' }, { urls: 'stun:stun3.l.google.com:19302' }, { urls: 'stun:stun4.l.google.com:19302' }, { urls: 'stun:openrelay.metered.ca:80' }, { urls: 'turn:openrelay.metered.ca:80', username: 'openrelayproject', credential: 'openrelayproject' }, { urls: 'turn:openrelay.metered.ca:443', username: 'openrelayproject', credential: 'openrelayproject' }, { urls: 'turn:openrelay.metered.ca:443?transport=tcp', username: 'openrelayproject', credential: 'openrelayproject' } ], iceCandidatePoolSize: 10 }; // Clean Up Specific Peer Connection const cleanupPeer = (peerId) => { if (dataChannelsRef.current[peerId]) { try { dataChannelsRef.current[peerId].close(); } catch (e) {} delete dataChannelsRef.current[peerId]; } if (peerConnectionsRef.current[peerId]) { try { peerConnectionsRef.current[peerId].close(); } catch (e) {} delete peerConnectionsRef.current[peerId]; } setPeers(prev => { const updated = { ...prev }; delete updated[peerId]; return updated; }); }; // Send WebRTC Signaling Packet to Peer via SignalR BoardHub const sendSignal = async (connection, receiverId, type, data) => { if (!connection || connection.state !== signalR.HubConnectionState.Connected) return; try { await connection.invoke('SendSignal', projectId, receiverId, type, JSON.stringify(data)); } catch (err) { console.error(`[WebRTC] Failed to send signal ${type} to ${receiverId}`, err); } }; // Setup Local RTCPeerConnection Event Handlers const createPeerConnection = (connection, peerId, info) => { if (peerConnectionsRef.current[peerId]) { return peerConnectionsRef.current[peerId]; } console.log(`[WebRTC] Creating RTCPeerConnection for peer ${peerId}`); const pc = new RTCPeerConnection(rtcConfig); peerConnectionsRef.current[peerId] = pc; // Handle ICE Candidates pc.onicecandidate = (event) => { if (event.candidate) { sendSignal(connection, peerId, 'candidate', event.candidate); } }; // Monitor Connection State pc.onconnectionstatechange = () => { console.log(`[WebRTC] Connection state with peer ${peerId}: ${pc.connectionState}`); if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed') { cleanupPeer(peerId); } else { setPeers(prev => ({ ...prev, [peerId]: { ...prev[peerId], connectionState: pc.connectionState } })); } }; // Update state with new peer setPeers(prev => ({ ...prev, [peerId]: { userId: peerId, name: info?.name || 'Collaborator', email: info?.email || '', connectionState: pc.connectionState, channelOpen: false } })); return pc; }; // Configure Data Channel Events const setupDataChannel = (peerId, channel) => { dataChannelsRef.current[peerId] = channel; channel.onopen = () => { console.log(`[WebRTC] RTC Data Channel opened with peer ${peerId}`); setPeers(prev => ({ ...prev, [peerId]: { ...prev[peerId], channelOpen: true } })); }; channel.onclose = () => { console.log(`[WebRTC] RTC Data Channel closed with peer ${peerId}`); setPeers(prev => ({ ...prev, [peerId]: { ...prev[peerId], channelOpen: false } })); }; channel.onerror = (err) => { console.error(`[WebRTC] RTC Data Channel error with peer ${peerId}`, err); }; channel.onmessage = (event) => { try { const message = JSON.parse(event.data); if (onRemoteMessageRef.current) { onRemoteMessageRef.current(peerId, message); } } catch (e) { console.error('[WebRTC] Failed to parse incoming message', e); } }; }; useEffect(() => { if (!projectId || !user || !user.id || !token) return; const hubUrl = API_URL.replace(/\/api$/, '') + '/hubs/board'; console.log('[SignalR] Connecting to hub at:', hubUrl); const connection = new signalR.HubConnectionBuilder() .withUrl(hubUrl, { accessTokenFactory: () => token, skipNegotiation: true, transport: signalR.HttpTransportType.WebSockets }) .withAutomaticReconnect() .build(); connectionRef.current = connection; // Presence updates connection.on('PresenceUpdated', (uniquePresences) => { console.log('[SignalR] Presence updated:', uniquePresences); const onlineUsers = uniquePresences.filter(u => u.userId !== user.id); setActiveUsers(onlineUsers); activeUsersRef.current = onlineUsers; // 1. Clean up offline peers const onlineUserIds = new Set(onlineUsers.map(u => u.userId)); Object.keys(peerConnectionsRef.current).forEach(peerId => { if (!onlineUserIds.has(peerId)) { console.log(`[WebRTC] Peer ${peerId} went offline. Cleaning up.`); cleanupPeer(peerId); } }); // 2. Initiate connection with peers where we are the initiator (tie-breaker: our user ID is lexicographically greater) onlineUsers.forEach(async (peer) => { const peerId = peer.userId; if (user.id > peerId && !peerConnectionsRef.current[peerId]) { console.log(`[WebRTC] Initiating connection to peer ${peerId}`); const pc = createPeerConnection(connection, peerId, peer); // Create outgoing data channel const channel = pc.createDataChannel('taskflow-sync-channel', { ordered: true }); setupDataChannel(peerId, channel); try { const offer = await pc.createOffer(); await pc.setLocalDescription(offer); await sendSignal(connection, peerId, 'offer', offer); } catch (err) { console.error(`[WebRTC] Failed to create offer for ${peerId}`, err); } } }); }); // WebRTC Signaling Bridge receiver connection.on('ReceiveSignal', async (senderUserId, type, dataJson) => { try { const signalData = JSON.parse(dataJson); const peerInfo = activeUsersRef.current.find(u => u.userId === senderUserId); const processPendingCandidates = async (peerId, pc) => { const candidates = pendingCandidatesRef.current[peerId]; if (candidates && candidates.length > 0) { console.log(`[WebRTC] Applying ${candidates.length} queued ICE candidates for peer ${peerId}`); while (candidates.length > 0) { const cand = candidates.shift(); try { await pc.addIceCandidate(new RTCIceCandidate(cand)); } catch (e) { console.error('[WebRTC] Error adding queued ICE candidate', e); } } } }; if (type === 'offer') { console.log(`[WebRTC] Received SDP Offer from peer ${senderUserId}`); const pc = createPeerConnection(connection, senderUserId, peerInfo); // Capture incoming data channel pc.ondatachannel = (event) => { setupDataChannel(senderUserId, event.channel); }; await pc.setRemoteDescription(new RTCSessionDescription(signalData)); await processPendingCandidates(senderUserId, pc); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); await sendSignal(connection, senderUserId, 'answer', answer); } else if (type === 'answer') { console.log(`[WebRTC] Received SDP Answer from peer ${senderUserId}`); const pc = peerConnectionsRef.current[senderUserId]; if (pc) { await pc.setRemoteDescription(new RTCSessionDescription(signalData)); await processPendingCandidates(senderUserId, pc); } } else if (type === 'candidate') { console.log(`[WebRTC] Received ICE Candidate from peer ${senderUserId}`); const pc = peerConnectionsRef.current[senderUserId]; if (pc && pc.remoteDescription) { try { await pc.addIceCandidate(new RTCIceCandidate(signalData)); } catch (e) { console.error('[WebRTC] Error adding received ICE candidate', e); } } else { if (!pendingCandidatesRef.current[senderUserId]) { pendingCandidatesRef.current[senderUserId] = []; } pendingCandidatesRef.current[senderUserId].push(signalData); console.log(`[WebRTC] Queued ICE candidate for peer ${senderUserId} (remoteDescription is null)`); } } } catch (err) { console.error('[WebRTC] Error handling received signal', err); } }); // Cursors connection.on('CursorMoved', (connectionId, senderId, x, y) => { if (senderId === user.id) return; onRemoteMessageRef.current(senderId, { type: 'CURSOR_MOVE', payload: { x, y } }); }); // Live nodes/edges updates connection.on('NodeMoved', (nodeId, x, y) => { onRemoteMessageRef.current('', { type: 'NODE_MOVE', payload: { id: nodeId, position: { x, y } } }); }); connection.on('NodeDataUpdated', (nodeId, newDataJson) => { try { const newData = JSON.parse(newDataJson); onRemoteMessageRef.current('', { type: 'NODE_DATA', payload: { id: nodeId, newData } }); } catch (e) { console.error(e); } }); connection.on('NodeAdded', (nodeJson) => { try { const node = JSON.parse(nodeJson); onRemoteMessageRef.current('', { type: 'NODE_ADD', payload: node }); } catch (e) { console.error(e); } }); connection.on('NodeDeleted', (nodeId) => { onRemoteMessageRef.current('', { type: 'NODE_DELETE', payload: { id: nodeId } }); }); connection.on('EdgeAdded', (edgeJson) => { try { const edge = JSON.parse(edgeJson); onRemoteMessageRef.current('', { type: 'EDGE_ADD', payload: edge }); } catch (e) { console.error(e); } }); connection.on('EdgeDeleted', (edgeId) => { onRemoteMessageRef.current('', { type: 'EDGE_DELETE', payload: { id: edgeId } }); }); // Access authorization check failed connection.on('AccessDenied', () => { alert("У вас немає прав для доступу до цієї дошки."); window.location.href = '/dashboard'; }); const startConnection = async () => { try { await connection.start(); console.log('[SignalR] Connected to BoardHub successfully.'); await connection.invoke('JoinBoard', projectId); } catch (err) { console.error('[SignalR] Connection failed: ', err); } }; startConnection(); return () => { // Clean up all peer connections on unmount Object.keys(peerConnectionsRef.current).forEach(cleanupPeer); connection.stop() .then(() => console.log('[SignalR] Connection stopped.')) .catch(err => console.error('[SignalR] Error stopping connection:', err)); }; }, [projectId, user, token, API_URL]); const broadcastMessage = async (msg) => { const connection = connectionRef.current; // Determine if we should send high-frequency updates via WebRTC P2P Data Channel const isHighFrequency = msg.type === 'CURSOR_MOVE' || msg.type === 'NODE_MOVE'; let sentP2P = false; if (isHighFrequency) { const payload = JSON.stringify(msg); // Attempt to send to all peers with open WebRTC data channels Object.keys(dataChannelsRef.current).forEach(peerId => { const channel = dataChannelsRef.current[peerId]; if (channel && channel.readyState === 'open') { try { channel.send(payload); sentP2P = true; } catch (e) { console.error(`[WebRTC] Failed to send message to peer ${peerId}`, e); } } }); } // Fallback/Standard logic via SignalR Hub if not sent via P2P if (!sentP2P) { if (!connection || connection.state !== signalR.HubConnectionState.Connected) { return; } try { if (msg.type === 'CURSOR_MOVE') { const { x, y } = msg.payload; await connection.invoke('SendCursor', projectId, x, y); } else if (msg.type === 'NODE_MOVE') { const { id, position } = msg.payload; await connection.invoke('MoveNode', projectId, id, position.x, position.y); } else if (msg.type === 'NODE_DATA') { const { id, newData } = msg.payload; await connection.invoke('UpdateNodeData', projectId, id, JSON.stringify(newData)); } else if (msg.type === 'NODE_ADD') { await connection.invoke('AddNode', projectId, JSON.stringify(msg.payload)); } else if (msg.type === 'NODE_DELETE') { await connection.invoke('DeleteNode', projectId, msg.payload.id); } else if (msg.type === 'EDGE_ADD') { await connection.invoke('AddEdge', projectId, JSON.stringify(msg.payload)); } else if (msg.type === 'EDGE_DELETE') { await connection.invoke('DeleteEdge', projectId, msg.payload.id); } } catch (err) { console.error('[SignalR] Failed to invoke hub method:', err); } } }; return { peers, activeUsers, broadcastMessage }; };