import { useState, useEffect, useRef } from 'react'; import io from 'socket.io-client'; import CodeDisplay from './components/CodeDisplay'; import FileList from './components/FileList'; import TextShare from './components/TextShare'; import './App.css'; //const SERVER_URL = import.meta.env.REACT_APP_SERVER_URL || 'http://localhost:3000'; //const SERVER_URL = ''; // prod const SERVER_URL = 'http://10.0.0.64:3000'; // dev const socket = io((SERVER_URL=="")?"/":SERVER_URL, { withCredentials: true }); //const socket = io('/', { path: '/socket.io', autoConnect: true, }); function App() { const [showApkLink, setShowApkLink] = useState(true); const [code, setCode] = useState(''); const [joinCode, setJoinCode] = useState(''); const [copied, setCopied] = useState(false); const [isRoomCreator, setIsRoomCreator] = useState(false); const [isConnected, setIsConnected] = useState(false); const [useFallback, setUseFallback] = useState(false); const [countdown, setCountdown] = useState(600); const [textHistory, setTextHistory] = useState([]); // Accumulative text const [error, setError] = useState(''); // For UI error messages const [dataChannelsState, setDataChannelsState] = useState({}); // Force re-render const [files, setFiles] = useState([]); const [downloadStates, setDownloadStates] = useState({}); // { fileName: { status: 'idle'|'downloading'|'completed', progress: number } } const [users, setUsers] = useState([]); const [downloadCounts, setDownloadCounts] = useState({}); const [reconTrig, setReconTrig] = useState(0); const localFilesRef = useRef({}); // Store local file data const filesRef = useRef([]); // Store latest files synchronously const retryCountsRef = useRef({}); const cancelRequestsRef = useRef(new Set()); // Track canceled downloads const thumbnailRetriesRef = useRef({}); // { [fileName]: number } const thumbnailRequestsRef = useRef(new Set()); // Track ongoing thumbnail requests: Set(`${fileName}:${peerId}`) const peerConnectionsRef = useRef({}); const dataChannelsRef = useRef({}); const binaryChannelsRef = useRef({}); const timeoutRefs = useRef({}); // Store timeouts per peer const transferIdCounterRef = useRef(0); const binaryStateRef = useRef({}); // { [transferId]: {kind, fileName, chunks: [], expectedSize, received: 0, peerId} } const downloadersRef = useRef({}); // { fileName: Set(peerIds) } const textHistoryRef = useRef(textHistory); // New ref to avoid stale closure in onopen // windoow expose to support blob download in app window.binaryStateRef = binaryStateRef; // Expose to window window.localFilesRef = localFilesRef; // Expose ref to window window.downloadStates = downloadStates; // Expose state to window // Update window.downloadStates on state changes useEffect(() => { window.downloadStates = downloadStates; }, [downloadStates]); // New: Sync ref with state changes useEffect(() => { textHistoryRef.current = textHistory; }, [textHistory]); const protocol = window.location.protocol; const hostname = window.location.hostname || 'localhost'; const roomCodeOnly = (code || '').replace(/ /g, '_'); const roomLink = `${protocol}//${hostname}/room/${(roomCodeOnly || '')}`; //const roomLink = `${protocol}://${hostname}/room/${(code || '').replace(/ /g, '_')}`; const handleCopyLink = async (type) => { if (!navigator.clipboard) { console.error('Clipboard API not available. Ensure the app is running in a secure context (https or localhost).'); return; } try { const textToCopy = type === 'link' ? roomLink : roomCodeOnly; await navigator.clipboard.writeText(textToCopy); setCopied(type); setTimeout(() => setCopied(null), 2000); // Reset after 2 seconds } catch (err) { console.error(`Failed to copy ${type}:`, err); } }; const handleCreateRoom = async () => { try { const response = await fetch(`${SERVER_URL}/api/generate-code`, { method: 'GET', credentials: 'include', }); const { code, error } = await response.json(); if (error) { setError(error); return; } setCode(code); setIsRoomCreator(true); setError(''); setUseFallback(false); socket.emit('create', code, (response) => { if (response?.error) { console.error(`Create room failed: ${response.error}`); setError(`Failed to create room: ${response.error}`); } else { console.log(`Successfully created room ${code}`); } }); //window.history.pushState({}, '', `/room/${code.replace(/ /g, '_')}`); } catch (err) { console.error('Error generating code:', err); setError('Failed to generate room code'); } }; const handleJoinRoom = (joinCode) => { if (!joinCode) { setError('Please enter a room code'); return; } const fixCode = joinCode.replace(/_/g, ' '); setCode(fixCode); setError(''); setUseFallback(false); socket.emit('join', fixCode, (response) => { if (!response || response.error) { const errorMsg = response?.error || 'Failed to join room'; if(errorMsg.includes('Already in room')) { // custom handle setFiles([]); setError(''); setReconTrig(prevCount => prevCount + 1); console.error(`Re-enter room: ${errorMsg}`); } else { console.error(`Join room failed: ${errorMsg}`); if (retryCount < 3) { console.log(`Retrying join room ${fixCode}, attempt ${retryCount + 1}/3`); setTimeout(() => handleJoinRoom(joinCode, retryCount + 1), 2000); } else { clearWebRTCState(); setError(`Failed to join room after retries: ${errorMsg}`); window.history.pushState({}, '', '/'); } } } else { console.log(`Successfully joined room ${fixCode}`); setError(''); } }); //window.history.pushState({}, '', `/room/${joinCode.replace(/ /g, '_')}`); }; const handleLeaveRoom = () => { // Notify server and peers of leaving socket.emit('leave', code, (response) => { if (response?.error) { console.error(`Leave room failed: ${response.error}`); setError(`Failed to leave room: ${response.error}`); clearWebRTCState(); setFiles([]); setCode(''); setJoinCode(''); localFilesRef.current = {}; filesRef.current = []; window.history.pushState({}, '', '/'); } else { console.log(`Successfully left room ${code}: ${response.message}`); // Clear local state clearWebRTCState(); // Reset file list for this client only (don't broadcast remove-file) setFiles([]); setCode(''); setJoinCode(''); localFilesRef.current = {}; filesRef.current = []; window.history.pushState({}, '', '/'); } }); }; const clearWebRTCState = () => { setCode(''); //setJoinCode(''); // only clear on error and leave setIsRoomCreator(false); setIsConnected(false); setUseFallback(false); setCountdown(600); setFiles([]); setTextHistory([]); setError(''); setDataChannelsState({}); setUsers([]); setDownloadCounts({}); localFilesRef.current = {}; Object.values(peerConnectionsRef.current).forEach((pc) => { pc.onicecandidate = null; pc.oniceconnectionstatechange = null; pc.onicecandidateerror = null; pc.ondatachannel = null; pc.onnegotiationneeded = null; pc.close(); }); Object.values(dataChannelsRef.current).forEach((ch) => { if (ch.readyState === 'open') ch.close(); }); Object.values(binaryChannelsRef.current).forEach((ch) => { if (ch.readyState === 'open') ch.close(); }); peerConnectionsRef.current = {}; dataChannelsRef.current = {}; binaryChannelsRef.current = {}; Object.keys(timeoutRefs.current).forEach((peerId) => clearTimeout(timeoutRefs.current[peerId])); timeoutRefs.current = {}; retryCountsRef.current = {}; cancelRequestsRef.current = new Set(); thumbnailRetriesRef.current = {}; thumbnailRequestsRef.current = new Set(); binaryStateRef.current = {}; downloadersRef.current = {}; }; // WebRTC setup const setupWebRTC = (peerId, roomCode, isOfferer = false) => { if (!peerId || peerId === socket.id || peerId === 'true') { console.log(`Skipping WebRTC setup for invalid peerId: ${peerId}`); return; } if (useFallback) { console.log(`Skipping WebRTC setup for ${peerId} due to fallback mode`); return; } if (peerConnectionsRef.current[peerId]) { console.log(`WebRTC connection already exists for ${peerId}, skipping setup`); return; } console.log(`Setting up WebRTC with peer ${peerId} (${isOfferer ? 'offerer' : 'answerer'}, attempt ${retryCountsRef.current[peerId] || 1}/3)`); const pc = new RTCPeerConnection({ iceServers: [ // more than 5=issues, more than 2=slows discovery //{ urls: 'turn:openrelay.metered.ca:80', username: 'openrelay.project', credential: 'openrelayproject' }, //{ urls: 'turn:openrelay.metered.ca:443', username: 'openrelay.project', credential: 'openrelayproject' }, { urls: 'turn:twily.info:5349', username:'ana', credential:'butt' }, { 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' }, ], }); retryCountsRef.current[peerId] = (retryCountsRef.current[peerId] || 0) + 1; peerConnectionsRef.current[peerId] = pc; pc.ondatachannel = (event) => { // answerer const channel = event.channel; if (channel.label === 'file-share') { dataChannelsRef.current[peerId] = channel; channel.onopen = () => { console.log(`Main DataChannel opened with peer ${peerId}`); setIsConnected(true); setCountdown(0); Object.keys(timeoutRefs.current).forEach((pId) => { console.log(`Clearing timeout for ${pId}`); clearTimeout(timeoutRefs.current[pId]); }); timeoutRefs.current = {}; retryCountsRef.current[peerId] = 0; if (dataChannelsRef.current['true']) { delete dataChannelsRef.current['true']; } setDataChannelsState({ ...dataChannelsRef.current }); // Share full file list with new peer const fileList = filesRef.current.map((file) => ({ name: file.name, size: file.size, peerId: file.peerId, thumbnail: !!localFilesRef.current[`${file.name}_thumbnail`], })); channel.send(JSON.stringify({ type: 'file-list', files: fileList, })); // Request thumbnails for all files not owned by this peer fileList.forEach((file) => { const requestKey = `${file.name}:${file.peerId}`; if (file.thumbnail && file.peerId !== socket.id && !localFilesRef.current[`${file.name}_thumbnail`] && !thumbnailRequestsRef.current.has(requestKey)) { requestThumbnail(file); } }); if (isRoomCreator) { setTimeout(() => { if (channel.readyState === 'open') { channel.send(JSON.stringify({ type: 'text-history', history: textHistoryRef.current })); // Use ref for latest value console.log(`Sent text-history to ${peerId}:`, textHistoryRef.current); } else { console.warn(`Main channel not open for ${peerId}, skipping text-history send`); } }, 1000); } }; channel.onmessage = (event) => handleMessage(peerId, event); channel.onclose = () => { console.log(`Main DataChannel closed with peer ${peerId}`); delete peerConnectionsRef.current[peerId]; setDataChannelsState({ ...dataChannelsRef.current }); }; channel.onerror = (err) => { console.error(`Main DataChannel error with ${peerId}:`, err); }; } else if (channel.label === 'binary-transfer') { channel.binaryType = 'arraybuffer'; binaryChannelsRef.current[peerId] = channel; channel.onmessage = (event) => handleBinaryMessage(peerId, event); channel.onclose = () => { console.log(`Binary channel closed with peer ${peerId}`); }; channel.onerror = (err) => { console.error(`Binary channel error with ${peerId}:`, err); }; } }; let mainChannel = null; let binaryChannel = null; if (isOfferer) { mainChannel = pc.createDataChannel('file-share'); dataChannelsRef.current[peerId] = mainChannel; mainChannel.onopen = () => { console.log(`Main DataChannel opened with peer ${peerId} (joiner)`); setIsConnected(true); setCountdown(0); Object.keys(timeoutRefs.current).forEach((pId) => { console.log(`Clearing timeout for ${pId}`); clearTimeout(timeoutRefs.current[pId]); }); timeoutRefs.current = {}; retryCountsRef.current[peerId] = 0; if (dataChannelsRef.current['true']) { delete dataChannelsRef.current['true']; } setDataChannelsState({ ...dataChannelsRef.current }); // Share full file list with new peer const fileList = filesRef.current.map((file) => ({ name: file.name, size: file.size, peerId: file.peerId, thumbnail: !!localFilesRef.current[`${file.name}_thumbnail`], })); mainChannel.send(JSON.stringify({ type: 'file-list', files: fileList, })); // Request thumbnails for all files not owned by this peer fileList.forEach((file) => { const requestKey = `${file.name}:${file.peerId}`; if (file.thumbnail && file.peerId !== socket.id && !localFilesRef.current[`${file.name}_thumbnail`] && !thumbnailRequestsRef.current.has(requestKey)) { requestThumbnail(file); } }); if (isRoomCreator) { setTimeout(() => { if (mainChannel.readyState === 'open') { mainChannel.send(JSON.stringify({ type: 'text-history', history: textHistoryRef.current })); // Use ref for latest value console.log(`Sent text-history to ${peerId}:`, textHistoryRef.current); } else { console.warn(`Main channel not open for ${peerId}, skipping text-history send`); } }, 1000); } }; mainChannel.onmessage = (event) => handleMessage(peerId, event); mainChannel.onclose = () => { console.log(`Main DataChannel closed with peer ${peerId}`); delete peerConnectionsRef.current[peerId]; setDataChannelsState({ ...dataChannelsRef.current }); }; mainChannel.onerror = (err) => { console.error(`Main DataChannel error with ${peerId}:`, err); }; binaryChannel = pc.createDataChannel('binary-transfer'); binaryChannel.binaryType = 'arraybuffer'; binaryChannelsRef.current[peerId] = binaryChannel; binaryChannel.onmessage = (event) => handleBinaryMessage(peerId, event); binaryChannel.onclose = () => { console.log(`Binary channel closed with peer ${peerId}`); }; binaryChannel.onerror = (err) => { console.error(`Binary channel error with ${peerId}:`, err); }; } pc.onicecandidate = (event) => { if (event.candidate && roomCode) { socket.emit('ice-candidate', { code: roomCode, candidate: event.candidate, to: peerId, from: socket.id }); console.log(`Sent ICE candidate to ${peerId} from ${socket.id}: ${event.candidate.candidate}`); } }; pc.onicecandidateerror = (err) => { console.error(`ICE candidate error for ${peerId}:`, err); }; pc.oniceconnectionstatechange = () => { console.log(`ICE state with ${peerId} (${isRoomCreator ? 'creator' : 'joiner'}): ${pc.iceConnectionState}`); if(typeof retryCountsRef.current[peerId]!=='undefined') { if (pc.iceConnectionState === 'failed' || pc.iceConnectionState === 'disconnected') { console.log(`WebRTC ${pc.iceConnectionState} with ${peerId}, retrying in 5s (attempt ${retryCountsRef.current[peerId]}/3)`); setTimeout(() => { if (pc.iceConnectionState !== 'connected' && !useFallback && retryCountsRef.current[peerId] < 3) { setupWebRTC(peerId, roomCode, isOfferer); } else if (pc.iceConnectionState !== 'connected' && !useFallback) { console.log(`Max retries reached for ${peerId}, switching to fallback`); setUseFallback(true); setError('WebRTC connection failed after retries, using server fallback'); setCountdown(0); setFiles([]); setTextHistory([]); if (isRoomCreator) { socket.emit('fallback', { code: roomCode }); } } }, 5000); } else if (pc.iceConnectionState === 'connected') { setIsConnected(true); setUseFallback(false); setError(''); //setFiles([]); clearTimeout(timeoutRefs.current[peerId]); retryCountsRef.current[peerId] = 0; } } else { console.log(`err: peerId ${peerId} not found in retryCountsRef`); setUseFallback(false); } }; // Re-negotiate for data channels change m= tracks in sdp pc.onnegotiationneeded = async () => { try { const offer = await pc.createOffer(); await pc.setLocalDescription(offer); socket.emit('offer', { code: roomCode, offer: pc.localDescription, to: peerId, from: socket.id }); console.log(`Sent renegotiation offer to ${peerId} for room ${roomCode}`); } catch (err) { console.error(`Negotiation needed error for ${peerId}:`, err); } }; if (isOfferer) { pc.createOffer() .then((offer) => { pc.setLocalDescription(offer); socket.emit('offer', { code: roomCode, offer, to: peerId, from: socket.id }); console.log(`Sent offer to ${peerId} for room ${roomCode}`); }) .catch((err) => { console.error(`Error creating offer for ${peerId}:`, err); setError('Failed to create WebRTC offer'); setUseFallback(true); setCountdown(0); setFiles([]); setTextHistory([]); if (isRoomCreator) { socket.emit('fallback', { code: roomCode }); } }); } return () => clearTimeout(timeoutRefs.current[peerId]); }; // Socket event handlers useEffect(() => { socket.on('peer-joined', (data) => { if (!data || !data.peerId || data.peerId === 'true') { console.error('Invalid peer-joined data:', data); return; } const { peerId } = data; console.log(`Peer ${peerId} joined room ${code}`); setupWebRTC(peerId, code, true); // "Other" is always the offerer }); socket.on('peer-left', (data) => { const { peerId } = data; if (peerConnectionsRef.current[peerId]) { peerConnectionsRef.current[peerId].close(); if (dataChannelsRef.current[peerId]?.readyState === 'open') { dataChannelsRef.current[peerId].close(); } if (binaryChannelsRef.current[peerId]?.readyState === 'open') { binaryChannelsRef.current[peerId].close(); } delete peerConnectionsRef.current[peerId]; delete dataChannelsRef.current[peerId]; delete binaryChannelsRef.current[peerId]; delete timeoutRefs.current[peerId]; // Clean binary state for this peer Object.keys(binaryStateRef.current).forEach((tid) => { if (binaryStateRef.current[tid].peerId === peerId) { delete binaryStateRef.current[tid]; thumbnailRequestsRef.current.delete(`${binaryStateRef.current[tid]?.fileName}:${peerId}`); } }); setDataChannelsState({ ...dataChannelsRef.current }); console.log(`Peer ${peerId} left room`); setFiles((prev) => { const remainingFiles = prev.filter((file) => file.peerId !== peerId); // Broadcast updated file list to remaining peers Object.values(dataChannelsRef.current).forEach((ch) => { if (ch.readyState === 'open') { ch.send(JSON.stringify({ type: 'file-list', files: remainingFiles.map((file) => ({ name: file.name, size: file.size, peerId: file.peerId, thumbnail: !!localFilesRef.current[`${file.name}_thumbnail`], })), })); } }); return remainingFiles; }); // Clean download counts for left peer let updated = false; Object.keys(downloadersRef.current).forEach((fileName) => { if (downloadersRef.current[fileName].delete(peerId)) { updated = true; broadcastUpdateCount(fileName, downloadersRef.current[fileName].size); } }); if (updated) { setDownloadCounts((prev) => ({ ...prev })); // Trigger re-render if needed } } if (isRoomCreator && Object.keys(peerConnectionsRef.current).length === 0) { setIsConnected(false); // Do not clear files or text here, as creator remains if (useFallback) { setUseFallback(false); setError(''); } else { setCountdown(600); } } }); socket.on('offer', async ({ code: receivedCode, offer, to, from }) => { // other but first creator only if (to !== socket.id || useFallback || receivedCode !== code) { console.log(`Ignoring offer from ${from} (to=${to}, socket.id=${socket.id}, useFallback=${useFallback}, code mismatch: ${receivedCode} vs ${code})`); return; } console.log(`Received offer from ${from} for room ${receivedCode}`); if (!peerConnectionsRef.current[from]) { console.log(`No existing connection for ${from}, setting up as answerer`); setupWebRTC(from, receivedCode, false); // Joiner is answerer } try { const pc = peerConnectionsRef.current[from]; await pc.setRemoteDescription(new RTCSessionDescription(offer)); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); socket.emit('answer', { code: receivedCode, answer, to: from, from: socket.id }); console.log(`Sent answer to ${from} from ${socket.id} for room ${receivedCode}`); } catch (err) { console.error(`Error handling offer from ${from}:`, err); setError('Failed to process WebRTC offer'); setUseFallback(true); setCountdown(0); setFiles([]); setTextHistory([]); if (isRoomCreator) { socket.emit('fallback', { code: receivedCode }); } } }); socket.on('answer', async ({ code: receivedCode, answer, to, from }) => { // peer-joined always answers if (to !== socket.id || useFallback || receivedCode !== code) { console.log(`Ignoring answer from ${from} (to=${to}, socket.id=${socket.id}, useFallback=${useFallback}, code mismatch: ${receivedCode} vs ${code})`); return; } console.log(`Received answer from ${from} for room ${receivedCode}`); if (peerConnectionsRef.current[from]) { try { await peerConnectionsRef.current[from].setRemoteDescription(new RTCSessionDescription(answer)); console.log(`Successfully set remote description for answer from ${from}`); } catch (err) { console.error(`Error handling answer from ${from}:`, err); setError('Failed to process WebRTC answer'); setUseFallback(true); setCountdown(0); setFiles([]); setTextHistory([]); if (isRoomCreator) { socket.emit('fallback', { code: receivedCode }); } } } else { console.warn(`No peer connection found for ${from} when receiving answer`); } }); // textHistory in fallback through room-status- socket.on('room-status', ({ useFallback, textHistory, files }) => { if (useFallback) { console.log(`Received room-status: useFallback=${useFallback}, textHistory=`, textHistory); setUseFallback(useFallback); setTextHistory(textHistory || []); setFiles(files || []); setIsConnected(true); setCountdown(0); setError('Room is in fallback mode'); } }); socket.on('fallback', () => { console.log(`Received fallback signal for room ${roomCode}`); setUseFallback(true); setError('Room switched to fallback mode'); setCountdown(0); setFiles([]); setTextHistory([]); setDownloadCounts({}); // Clear download counts in fallback mode Object.values(peerConnectionsRef.current).forEach((pc) => pc.close()); peerConnectionsRef.current = {}; dataChannelsRef.current = {}; binaryChannelsRef.current = {}; Object.keys(timeoutRefs.current).forEach((pId) => clearTimeout(timeoutRefs.current[pId])); timeoutRefs.current = {}; retryCountsRef.current = {}; thumbnailRequestsRef.current = new Set(); }); socket.on('ice-candidate', async ({ code: receivedCode, candidate, from, to }) => { if (to !== socket.id || receivedCode !== code) { console.log(`Ignoring ICE candidate from ${from} (to=${to}, code mismatch: ${receivedCode} vs ${code})`); return; } console.log(`Received ICE candidate from ${from}`); if (peerConnectionsRef.current[from]) { try { await peerConnectionsRef.current[from].addIceCandidate(new RTCIceCandidate(candidate)); console.log(`Added ICE candidate from ${from}`); } catch (err) { console.error(`Error adding ICE candidate from ${from}:`, err); } } else { console.warn(`No peer connection found for ${from} when receiving ICE candidate`); } }); socket.on('error', (message) => { clearWebRTCState(); console.log('Server error:', message); setError(message); if (message.includes('Invalid code') || message.includes('room not found')) { window.history.pushState({}, '', '/'); } else if (message.includes('Room closed by')) { setJoinCode(''); } }); socket.on('files', (files) => { console.log('Received files:', files); setFiles(files); }); // fallback files are uploaded? direct download? //socket.on('file', ({ name, path, peerId, size }) => { // console.log(`Received file via socket: ${name}, uploader: ${peerId}`); // setFiles((prev) => [...prev, { name, path, peerId, size }]); //}); // fallback text-history is handled in room-status //socket.on('text-history', (history) => { // console.log(`Received text history for room ${code}:`, history); // setTextHistory(history || []); //}); socket.on('text', ({ text }) => { console.log(`Received text via socket for room ${code}:`, text); setTextHistory((prev) => [...prev, text]); }); socket.on('file-list', ({ files }) => { setFiles((prev) => { const newFiles = files.filter( (newFile) => !prev.some((file) => file.name === newFile.name && file.peerId === newFile.peerId) ).map((file) => ({ ...file, peerId: file.peerId || 'unknown' })); newFiles.forEach((file) => { const requestKey = `${file.name}:${file.peerId}`; if (file.thumbnail && !localFilesRef.current[`${file.name}_thumbnail`] && !thumbnailRequestsRef.current.has(requestKey)) { console.log(`Requesting thumbnail for ${file.name} from ${file.peerId}`); requestThumbnail(file); } else if (file.thumbnail) { console.log(`Thumbnail for ${file.name} already cached or requested, skipping request`); } }); console.log(`Received file-list from server:`, files); return [...prev, ...newFiles]; }); }); // New: Handle fallback download count updates from server socket.on('update-count-fallback', ({ fileName, count }) => { setDownloadCounts((prev) => ({ ...prev, [fileName]: count })); }); // New: Handle room status check response socket.on('room-status-check', ({ exists, isInRoom }) => { if (!exists) { clearWebRTCState(); setError('Room closed'); setJoinCode(''); console.log(`Room ${code} closed`); } else if (!isInRoom) { console.log(`Rejoining room ${code}`); handleJoinRoom(code); // Auto-rejoin if room exists but user not in it } }); return () => { socket.off('peer-joined'); socket.off('offer'); socket.off('answer'); socket.off('room-status'); socket.off('fallback'); socket.off('peer-left'); socket.off('ice-candidate'); socket.off('error'); socket.off('files'); //socket.off('file'); socket.off('text'); //socket.off('text-history'); socket.off('file-list'); socket.off('update-count-fallback'); socket.off('room-status-check'); }; }, [code, isRoomCreator, useFallback]); // Depend on code to ensure handlers update with room // New: Check room status on socket reconnect useEffect(() => { const handleReconnect = () => { if (code) { socket.emit('check-room-status', code); console.log(`Reconnected, checking room status for ${code}`); } }; socket.io.on('reconnect', handleReconnect); return () => { socket.io.off('reconnect', handleReconnect); }; }, [code]); // New: Reconnect and check on visible if disconnected (no force disconnect on hidden) useEffect(() => { const handleVisibilityChange = () => { if (document.visibilityState === 'visible' && code) { if (socket.disconnected) { socket.connect(); console.log('Tab visible, reconnecting socket'); } socket.emit('check-room-status', code); console.log(`Tab visible, checking room status for ${code}`); } }; document.addEventListener('visibilitychange', handleVisibilityChange); return () => { document.removeEventListener('visibilitychange', handleVisibilityChange); }; }, [code]); // Countdown for room creator useEffect(() => { if (isRoomCreator && countdown > 0 && !isConnected && !useFallback) { const timer = setInterval(() => { setCountdown((prev) => { if (prev <= 1) { clearWebRTCState(); setError('No one joined the room'); return 0; } return prev - 1; }); }, 1000); return () => clearInterval(timer); } }, [isRoomCreator, countdown, isConnected, useFallback, code]); // Sync filesRef with files state useEffect(() => { filesRef.current = files; console.log('Updated filesRef:', filesRef.current); }, [files]); // Update users list useEffect(() => { const peerIds = Object.keys(peerConnectionsRef.current); setUsers([socket.id, ...peerIds].sort()); }, [dataChannelsState, reconTrig]); // Auto join from URL useEffect(() => { if (!code) { const path = window.location.pathname; if (path.startsWith('/room/')) { const urlCode = path.slice(6).replace(/_/g, ' '); handleJoinRoom(urlCode); } } }, []); // disabled for now //useEffect(() => { // const path = window.location.pathname; // setShowApkLink(!path.startsWith('/app/')); //}, []); const requestThumbnail = (file) => { const requestKey = `${file.name}:${file.peerId}`; if (file.thumbnail && file.peerId !== socket.id && !useFallback) { const retries = (thumbnailRetriesRef.current[file.name] || 0) + 1; if (retries > 3) { console.error(`Max retries reached for thumbnail ${file.name} from ${file.peerId}`); return; } if (thumbnailRequestsRef.current.has(requestKey)) { console.log(`Thumbnail request for ${file.name} from ${file.peerId} already in progress, skipping`); return; } const channel = dataChannelsRef.current[file.peerId]; if (channel?.readyState === 'open') { thumbnailRetriesRef.current[file.name] = retries; thumbnailRequestsRef.current.add(requestKey); channel.send(JSON.stringify({ type: 'request-thumbnail', fileName: file.name })); console.log(`Requested thumbnail for ${file.name} from ${file.peerId} via WebRTC (retry ${retries})`); } else { console.warn(`Data channel not ready for ${file.peerId}, retrying in 1s`); setTimeout(() => { if (!thumbnailRequestsRef.current.has(requestKey)) { requestThumbnail(file); } }, 1000); } } else { console.error(`Cannot request thumbnail for ${file.name}:`, { isThumbnail: file.thumbnail, isLocal: file.peerId === socket.id, useFallback, channelOpen: dataChannelsRef.current[file.peerId]?.readyState, }); } }; const sendBinaryData = async (peerId, kind, fileName, data) => { const binaryChannel = binaryChannelsRef.current[peerId]; if (!binaryChannel || binaryChannel.readyState !== 'open') { console.error(`Binary channel not open for ${peerId}`); return; } if (kind === 'thumbnail' && data.size > 100 * 1024) { console.warn(`Thumbnail ${fileName} size ${data.size} exceeds 100KB limit, skipping`); return; } const transferId = transferIdCounterRef.current++; console.log(`Sending ${kind} ${fileName} to ${peerId}, size: ${data.size}`); binaryChannel.send(JSON.stringify({ type: 'start', transferId, kind, fileName, size: data.size })); const reader = new FileReader(); reader.onload = () => { const buffer = reader.result; const fileNameBytes = new TextEncoder().encode(fileName); const prefixLength = 4 + 4 + fileNameBytes.length; // 4 bytes for transferId, 4 for fileName length const prefixed = new ArrayBuffer(prefixLength + buffer.byteLength); const view = new DataView(prefixed); view.setUint32(0, transferId); view.setUint32(4, fileNameBytes.length); new Uint8Array(prefixed).set(fileNameBytes, 8); new Uint8Array(prefixed).set(new Uint8Array(buffer), prefixLength); binaryChannel.send(prefixed); binaryChannel.send(JSON.stringify({ type: 'end', transferId })); }; reader.onerror = (err) => { console.error(`Error reading ${kind} ${fileName} for ${peerId}:`, err); }; reader.readAsArrayBuffer(data); }; const sendFileChunked = async (peerId, fileName, file) => { if (!fileName) { console.error(`No fileName provided for sendFileChunked to ${peerId}`); return; } const binaryChannel = binaryChannelsRef.current[peerId]; if (!binaryChannel || binaryChannel.readyState !== 'open') { console.error(`Binary channel not open for ${peerId}`); return; } if (cancelRequestsRef.current.has(fileName)) { cancelRequestsRef.current.delete(fileName); console.log(`Cleared previous cancel for ${fileName}, starting new send to ${peerId}`); } const transferId = transferIdCounterRef.current++; console.log(`Sending start message for file ${fileName} to ${peerId}, transferId: ${transferId}, size: ${file.size}`); binaryChannel.send(JSON.stringify({ type: 'start', transferId, kind: 'file', fileName, size: file.size })); // drain to avoid 18MB transfer limit, large file support const HIGH_WATER_MARK = 8 * 1024 * 1024; // 8MB const LOW_WATER_MARK = 4 * 1024 * 1024; // 4MB binaryChannel.bufferedAmountLowThreshold = LOW_WATER_MARK; let drainResolver; binaryChannel.onbufferedamountlow = () => { if (drainResolver) { drainResolver(); drainResolver = null; } }; const waitForDrain = () => { if (binaryChannel.bufferedAmount <= HIGH_WATER_MARK) { return Promise.resolve(); } return new Promise((resolve) => { drainResolver = resolve; }); }; await waitForDrain(); const chunkSize = 16384; let offset = 0; console.log(`--- offset : ${offset} --- file.size : ${file.size}`); while (offset < file.size) { if (cancelRequestsRef.current.has(fileName)) { console.log(`Canceled sending ${fileName} to ${peerId}`); binaryChannel.send(JSON.stringify({ type: 'cancel', transferId })); return; } const slice = file.slice(offset, offset + chunkSize); console.log(`--- slice.size : ${slice.size}`); if (slice.size === 0) { break; } try { const chunk = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (e) => resolve(e.target.result); reader.onerror = reject; reader.readAsArrayBuffer(slice); }); if (cancelRequestsRef.current.has(fileName)) { console.log(`Canceled sending ${fileName} to ${peerId} during read`); binaryChannel.send(JSON.stringify({ type: 'cancel', transferId })); return; } // Prefix chunk with transferId and fileName const fileNameBytes = new TextEncoder().encode(fileName); const prefixLength = 4 + 4 + fileNameBytes.length; // 4 bytes for transferId, 4 for fileName length const prefixed = new ArrayBuffer(prefixLength + chunk.byteLength); const view = new DataView(prefixed); view.setUint32(0, transferId); view.setUint32(4, fileNameBytes.length); new Uint8Array(prefixed).set(fileNameBytes, 8); new Uint8Array(prefixed).set(new Uint8Array(chunk), prefixLength); binaryChannel.send(prefixed); await waitForDrain(); offset += chunk.byteLength; } catch (err) { console.error(`Error reading ${fileName}:`, err); binaryChannel.send(JSON.stringify({ type: 'cancel', transferId })); return; } } binaryChannel.send(JSON.stringify({ type: 'end', transferId })); cancelRequestsRef.current.delete(fileName); console.log(`Finished sending ${fileName} to ${peerId}`); }; const cancelDownload = (fileName, peerId) => { if (!cancelRequestsRef.current.has(fileName)) { cancelRequestsRef.current.add(fileName); const mainCh = dataChannelsRef.current[peerId]; if (mainCh && mainCh.readyState === 'open') { mainCh.send(JSON.stringify({ type: 'end-download', fileName })); mainCh.send(JSON.stringify({ type: 'cancel-download', fileName })); console.log(`Sent end-download and cancel-download for ${fileName} to ${peerId}`); } setDownloadStates((prev) => { const wasSaved = prev[fileName]?.status === 'saved'; const { [fileName]: _, ...rest } = prev; return wasSaved ? { ...rest, [fileName]: { status: 'saved', progress: 100, peerId } } : rest; }); // Cancel any ongoing binary transfers for this file Object.keys(binaryStateRef.current).forEach((tid) => { const state = binaryStateRef.current[tid]; if (state.fileName === fileName && state.peerId === peerId) { delete binaryStateRef.current[tid]; thumbnailRequestsRef.current.delete(`${fileName}:${peerId}`); } }); console.log(`Canceled download for ${fileName} locally`); } }; const handleDeleteFile = (fileName) => { setFiles((prev) => prev.filter((file) => file.name !== fileName)); delete localFilesRef.current[fileName]; delete localFilesRef.current[`${fileName}_thumbnail`]; Object.values(dataChannelsRef.current).forEach((ch) => { if (ch.readyState === 'open') { ch.send(JSON.stringify({ type: 'remove-file', fileName })); } }); // Clear download counts for deleted file if (downloadersRef.current[fileName]) { delete downloadersRef.current[fileName]; setDownloadCounts((prev) => { const { [fileName]: _, ...rest } = prev; return rest; }); broadcastUpdateCount(fileName, 0); } console.log(`Deleted file ${fileName} and notified peers`); }; const broadcastUpdateCount = (fileName, count) => { setDownloadCounts((prev) => ({ ...prev, [fileName]: count })); Object.values(dataChannelsRef.current).forEach((ch) => { if (ch.readyState === 'open') { ch.send(JSON.stringify({ type: 'update-count', fileName, count })); } }); }; const handleMessage = (peerId, event) => { const message = event.data; try { if (typeof message === 'string') { const data = JSON.parse(message); if (data.type === 'text') { setTextHistory((prev) => [...prev, data.text]); console.log(`Received text via WebRTC from ${peerId}: ${data.text}`); } else if (data.type === 'text-history') { console.log(`Received text-history from ${peerId}:`, data.history, `Current textHistory:`, textHistory); if (Array.isArray(data.history)) { setTextHistory(data.history); } else { console.warn(`Invalid text-history format from ${peerId}:`, data.history); } } else if (data.type === 'request-file') { const file = localFilesRef.current[data.fileName]; if (file) { console.log(`Handling request-file for ${data.fileName} from ${peerId}`); // Increment download count when a download starts handleDownloadCountUpdate(peerId, data.fileName, 'start'); sendFileChunked(peerId, data.fileName, file); } else { console.error(`File not found: ${data.fileName}`); } } else if (data.type === 'request-thumbnail') { const thumbnail = localFilesRef.current[`${data.fileName}_thumbnail`]; if (thumbnail) { console.log(`Handling request-thumbnail for ${data.fileName} from ${peerId}`); sendBinaryData(peerId, 'thumbnail', data.fileName, thumbnail); } else { console.error(`Thumbnail not found for ${data.fileName}`); } } else if (data.type === 'end-download') { handleDownloadCountUpdate(peerId, data.fileName, 'end'); } else if (data.type === 'remove-file') { setFiles((prev) => prev.filter((file) => file.name !== data.fileName)); console.log(`Removed file ${data.fileName} from list`); } else if (data.type === 'update-count') { setDownloadCounts((prev) => ({ ...prev, [data.fileName]: data.count })); } else if (data.type === 'file-list') { setFiles((prev) => { const newFiles = data.files.filter( (newFile) => !prev.some((file) => file.name === newFile.name && file.peerId === newFile.peerId) ).map((file) => ({ ...file, peerId: file.peerId || 'unknown' })); // Ensure peerId is set newFiles.forEach((file) => { const requestKey = `${file.name}:${file.peerId}`; if (file.thumbnail && !localFilesRef.current[`${file.name}_thumbnail`] && !thumbnailRequestsRef.current.has(requestKey)) { console.log(`Requesting thumbnail for ${file.name} from ${file.peerId}`); requestThumbnail(file); } else if (file.thumbnail) { console.log(`Thumbnail for ${file.name} already cached or requested, skipping request`); } }); console.log(`Received file-list from server:`, data.files); return [...prev, ...newFiles]; }); } } else { console.warn(`Unexpected non-string message on main channel from ${peerId}`); console.log(message); } } catch (err) { console.error(`Error processing message from ${peerId}:`, err); } }; const handleBinaryMessage = (peerId, event) => { const message = event.data; //console.log(`Received message from ${peerId}, type: ${typeof message}, size: ${message instanceof Blob ? message.size : message.byteLength || 'N/A'}`); if (typeof message === 'string') { try { const data = JSON.parse(message); if (data.type === 'start') { if (data.transferId === undefined || data.transferId === null || !data.fileName || !data.kind || !data.size) { console.error(`Invalid start message from ${peerId}:`, data); return; } if (binaryStateRef.current[data.transferId]) { console.warn(`TransferId ${data.transferId} already exists for ${peerId}, ignoring new start for ${data.fileName}`); return; } binaryStateRef.current[data.transferId] = { kind: data.kind, fileName: data.fileName, chunks: [], expectedSize: data.size, received: 0, peerId, }; console.log(`Started ${data.kind} transfer ${data.transferId} for ${data.fileName} from ${peerId}, expected size: ${data.size}`); if (data.kind === 'file') { setDownloadStates((prev) => ({ ...prev, [data.fileName]: { status: 'downloading', progress: 0, total: data.size, received: 0, peerId }, })); } } else if (data.type === 'end') { const state = binaryStateRef.current[data.transferId]; if (!state) { console.warn(`No state for transferId ${data.transferId} from ${peerId}`); return; } if (state.received >= state.expectedSize) { const blob = new Blob(state.chunks, { type: state.kind === 'thumbnail' ? 'image/jpeg' : 'application/octet-stream' }); console.log(`Assembled ${state.kind} ${state.fileName}, size: ${blob.size}`); if (state.kind === 'thumbnail') { localFilesRef.current[`${state.fileName}_thumbnail`] = blob; console.log(`Received thumbnail for ${state.fileName} from ${peerId}, size: ${blob.size}`); setFiles((prev) => prev.map((file) => file.name === state.fileName && file.peerId !== socket.id ? { ...file, thumbnail: true } : file ) ); thumbnailRequestsRef.current.delete(`${state.fileName}:${peerId}`); } else if (state.kind === 'file') { localFilesRef.current[state.fileName] = blob; const url = URL.createObjectURL(blob); // Store blobUrl in binaryStateRef binaryStateRef.current[data.transferId].blobUrl = url; console.log(`Stored blobUrl ${url} for transferId ${data.transferId}, fileName: ${state.fileName}`); console.log(`binaryStateRef.current keys: `, JSON.stringify(Object.keys(binaryStateRef.current))); console.log(`binaryStateRef.current[${data.transferId}]: `, JSON.stringify(binaryStateRef.current[data.transferId])); const link = document.createElement('a'); link.href = url; link.download = state.fileName; link.click(); //URL.revokeObjectURL(url); setTimeout(() => { URL.revokeObjectURL(url); console.log(`Revoked blobUrl ${url} for transferId ${data.transferId}`); // Optionally keep binaryStateRef.current for debugging // delete binaryStateRef.current[data.transferId]; // Comment out if present }, 5000); // Delay revoke by 5s setDownloadStates((prev) => ({ ...prev, [state.fileName]: { status: 'saved', progress: 100, peerId }, })); const mainCh = dataChannelsRef.current[peerId]; if (mainCh && mainCh.readyState === 'open') { mainCh.send(JSON.stringify({ type: 'end-download', fileName: state.fileName })); } } } else { console.warn(`Size mismatch for ${state.kind} ${state.fileName}: received ${state.received}, expected ${state.expectedSize}`); if (state.kind === 'file') { setDownloadStates((prev) => ({ ...prev, [state.fileName]: { status: 'error', progress: (state.received / state.expectedSize) * 100 }, })); } thumbnailRequestsRef.current.delete(`${state.fileName}:${peerId}`); } //--delete binaryStateRef.current[data.transferId]; } else if (data.type === 'cancel') { const state = binaryStateRef.current[data.transferId]; if (state && state.kind === 'file') { setDownloadStates((prev) => ({ ...prev, [state.fileName]: { status: 'canceled', progress: 0 }, })); } thumbnailRequestsRef.current.delete(`${state.fileName}:${peerId}`); delete binaryStateRef.current[data.transferId]; } } catch (err) { console.error(`Error processing binary control message from ${peerId}:`, err); } } else if (message instanceof ArrayBuffer) { if (message.byteLength < 8) { console.warn(`Invalid binary message size from ${peerId}: ${message.byteLength} bytes`); return; } const view = new DataView(message); const transferId = view.getUint32(0); const fileNameLength = view.getUint32(4); if (message.byteLength < 8 + fileNameLength) { console.warn(`Invalid binary message: insufficient length for fileName from ${peerId}`); return; } const fileNameBytes = new Uint8Array(message, 8, fileNameLength); const fileName = new TextDecoder().decode(fileNameBytes); const chunk = message.slice(8 + fileNameLength); const state = binaryStateRef.current[transferId]; if (state && state.fileName === fileName) { if (cancelRequestsRef.current.has(state.fileName)) { console.log(`Ignoring chunk for canceled ${state.kind} ${state.fileName}`); return; } state.chunks.push(chunk); state.received += chunk.byteLength; //console.log(`Received chunk for ${state.kind} ${state.fileName}, transferId ${transferId}: ${state.received}/${state.expectedSize}`); if (state.kind === 'file') { const progress = Math.min(100, (state.received / state.expectedSize) * 100); setDownloadStates((prev) => ({ ...prev, [state.fileName]: { ...prev[state.fileName], progress, received: state.received }, })); } } else { console.warn(`No state or fileName mismatch for transferId ${transferId} from ${peerId}: expected ${state?.fileName}, got ${fileName}`); } } else if (message instanceof Blob) { console.log(`Received Blob message from ${peerId}, size: ${message.size}`); console.dir(message); const reader = new FileReader(); reader.onload = () => { const arrayBuffer = reader.result; if (arrayBuffer.byteLength < 4) { console.warn(`Invalid Blob-converted ArrayBuffer size from ${peerId}: ${arrayBuffer.byteLength} bytes`); return; } const view = new DataView(arrayBuffer); const transferId = view.getUint32(0); const fileNameLength = view.getUint32(4); if (arrayBuffer.byteLength < 8 + fileNameLength) { console.warn(`Invalid Blob-converted ArrayBuffer: insufficient length for fileName from ${peerId}`); return; } const fileNameBytes = new Uint8Array(arrayBuffer, 8, fileNameLength); const fileName = new TextDecoder().decode(fileNameBytes); const chunk = arrayBuffer.slice(8 + fileNameLength); const state = binaryStateRef.current[transferId]; if (state && state.fileName === fileName) { if (cancelRequestsRef.current.has(state.fileName)) { console.log(`Ignoring chunk for canceled ${state.kind} ${state.fileName}`); return; } state.chunks.push(chunk); state.received += chunk.byteLength; console.log(`Received Blob chunk for ${state.kind} ${state.fileName}, transferId ${transferId}: ${state.received}/${state.expectedSize}`); if (state.kind === 'file') { const progress = Math.min(100, (state.received / state.expectedSize) * 100); setDownloadStates((prev) => ({ ...prev, [state.fileName]: { ...prev[state.fileName], progress, received: state.received }, })); } } else { console.warn(`No state or fileName mismatch for transferId ${transferId} from ${peerId}: expected ${state?.fileName}, got ${fileName}`); } }; reader.onerror = (err) => { console.error(`Error reading Blob from ${peerId}:`, err); }; reader.readAsArrayBuffer(message); } else { console.warn(`Unexpected message type from ${peerId}:`, typeof message); console.log(message); } }; const handleDownloadCountUpdate = (peerId, fileName, action) => { if (localFilesRef.current[fileName]) { // Only uploader handles downloadersRef.current[fileName] = downloadersRef.current[fileName] || new Set(); if (action === 'start') { downloadersRef.current[fileName].add(peerId); console.log(`Started download of ${fileName} by ${peerId}, count: ${downloadersRef.current[fileName].size}`); } else if (action === 'end') { downloadersRef.current[fileName].delete(peerId); console.log(`Ended download of ${fileName} by ${peerId}, count: ${downloadersRef.current[fileName].size}`); } const count = downloadersRef.current[fileName].size; broadcastUpdateCount(fileName, count); } }; return (
Waiting for peer(s) to connect [WebRTC]... {countdown}s
)} > )}{error}
}{/*{protocol}//{hostname}/room/{code || 'N/A'}*/} {protocol}//{hostname}/room/{roomCodeOnly || 'N/A'}
●Using server fallback
} > )} {!code && showApkLink && ( /**/ )}