diff --git a/src/App.jsx b/src/App.jsx
index e58d7dc..f9614c9 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,7 +1,11 @@
import { useState, useEffect, useRef } from 'react';
import { Rondevu, RondevuClient } from '@xtr-dev/rondevu-client';
import QRCode from 'qrcode';
-import { BrowserQRCodeReader } from '@zxing/library';
+import Header from './components/Header';
+import ActionSelector from './components/ActionSelector';
+import MethodSelector from './components/MethodSelector';
+import ConnectionForm from './components/ConnectionForm';
+import ChatView from './components/ChatView';
const rdv = new Rondevu({
baseUrl: 'https://rondevu.xtrdev.workers.dev',
@@ -44,13 +48,12 @@ function App() {
const [messageInput, setMessageInput] = useState('');
const [logs, setLogs] = useState([]);
const [channelReady, setChannelReady] = useState(false);
+ const [fileUploadProgress, setFileUploadProgress] = useState(null);
const connectionRef = useRef(null);
const dataChannelRef = useRef(null);
- const fileInputRef = useRef(null);
const fileTransfersRef = useRef(new Map()); // Track ongoing file transfers
- const videoRef = useRef(null);
- const scannerRef = useRef(null);
+ const uploadCancelRef = useRef(false);
useEffect(() => {
log('Demo initialized', 'info');
@@ -195,68 +198,6 @@ function App() {
}
};
- const startScanning = async () => {
- try {
- scannerRef.current = new BrowserQRCodeReader();
- log('Starting QR scanner...', 'info');
-
- const videoInputDevices = await scannerRef.current.listVideoInputDevices();
-
- if (videoInputDevices.length === 0) {
- log('No camera found', 'error');
- return;
- }
-
- // Prefer back camera (environment-facing)
- let selectedDeviceId = videoInputDevices[0].deviceId;
- const backCamera = videoInputDevices.find(device =>
- device.label.toLowerCase().includes('back') ||
- device.label.toLowerCase().includes('rear') ||
- device.label.toLowerCase().includes('environment')
- );
-
- if (backCamera) {
- selectedDeviceId = backCamera.deviceId;
- log('Using back camera', 'info');
- } else {
- log('Back camera not found, using default', 'info');
- }
-
- scannerRef.current.decodeFromVideoDevice(
- selectedDeviceId,
- videoRef.current,
- (result, err) => {
- if (result) {
- const scannedId = result.getText();
- log(`Scanned: ${scannedId}`, 'success');
- setConnectionId(scannedId);
- stopScanning();
- setMethod('connection-id');
- setStep(3);
- }
- }
- );
- } catch (error) {
- log(`Scanner error: ${error.message}`, 'error');
- }
- };
-
- const stopScanning = () => {
- if (scannerRef.current) {
- scannerRef.current.reset();
- log('Scanner stopped', 'info');
- }
- };
-
- useEffect(() => {
- if (action === 'scan') {
- startScanning();
- }
- return () => {
- stopScanning();
- };
- }, [action]);
-
const sendMessage = () => {
if (!messageInput || !channelReady || !dataChannelRef.current) {
return;
@@ -281,6 +222,10 @@ function App() {
const CHUNK_SIZE = 16384; // 16KB chunks
const fileId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
+ const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
+
+ uploadCancelRef.current = false;
+ setFileUploadProgress({ fileName: file.name, progress: 0 });
log(`Sending file: ${file.name} (${(file.size / 1024).toFixed(2)} KB)`, 'info');
@@ -292,7 +237,7 @@ function App() {
name: file.name,
size: file.size,
mimeType: file.type,
- chunks: Math.ceil(file.size / CHUNK_SIZE)
+ chunks: totalChunks
};
dataChannelRef.current.send(JSON.stringify(metadata));
@@ -302,11 +247,22 @@ function App() {
let chunkIndex = 0;
const readChunk = () => {
+ if (uploadCancelRef.current) {
+ setFileUploadProgress(null);
+ log('File upload cancelled', 'info');
+ return;
+ }
+
const slice = file.slice(offset, offset + CHUNK_SIZE);
reader.readAsArrayBuffer(slice);
};
reader.onload = (e) => {
+ if (uploadCancelRef.current) {
+ setFileUploadProgress(null);
+ return;
+ }
+
const chunk = {
type: 'file-chunk',
fileId,
@@ -318,6 +274,10 @@ function App() {
offset += CHUNK_SIZE;
chunkIndex++;
+ // Update progress
+ const progress = Math.round((chunkIndex / totalChunks) * 100);
+ setFileUploadProgress({ fileName: file.name, progress });
+
if (offset < file.size) {
readChunk();
} else {
@@ -338,16 +298,19 @@ function App() {
timestamp: new Date()
}]);
+ setFileUploadProgress(null);
log(`File sent: ${file.name}`, 'success');
}
};
reader.onerror = () => {
+ setFileUploadProgress(null);
log(`Error reading file: ${file.name}`, 'error');
};
readChunk();
} catch (error) {
+ setFileUploadProgress(null);
log(`Error sending file: ${error.message}`, 'error');
}
@@ -355,6 +318,11 @@ function App() {
event.target.value = '';
};
+ const cancelFileUpload = () => {
+ uploadCancelRef.current = true;
+ setFileUploadProgress(null);
+ };
+
const handleReceivedMessage = (data) => {
try {
const message = JSON.parse(data);
@@ -437,7 +405,6 @@ function App() {
if (connectionRef.current) {
connectionRef.current.close();
}
- stopScanning();
setStep(1);
setAction(null);
setMethod(null);
@@ -455,317 +422,81 @@ function App() {
dataChannelRef.current = null;
};
+ const handleScanComplete = (scannedId) => {
+ setConnectionId(scannedId);
+ setAction('join');
+ setMethod('connection-id');
+ setStep(3);
+ };
+
+ const handleScanCancel = () => {
+ setAction(null);
+ };
+
return (
-
+
{step === 1 && (
-
-
Choose Action
-
-
-
-
-
- {action === 'scan' && (
-
-
-
-
- )}
-
+
)}
{step === 2 && (
-
-
{action === 'create' ? 'Create' : 'Join'} by...
-
-
- {action === 'join' && (
-
- )}
-
-
-
-
+ {
+ setMethod(m);
+ setStep(3);
+ }}
+ onBack={() => setStep(1)}
+ />
)}
{step === 3 && (
-
-
Enter Details
-
- {(method === 'topic' || (method === 'peer-id') || (method === 'connection-id' && action === 'create')) && (
-
-
-
setTopic(e.target.value)}
- placeholder="e.g., game-room"
- autoFocus
- />
- {topics.length > 0 && (
-
- {topics.map((t) => (
-
- ))}
-
- )}
-
- )}
-
- {method === 'peer-id' && (
-
-
-
setPeerId(e.target.value)}
- placeholder="e.g., player-123"
- />
- {sessions.length > 0 && (
-
- {sessions.map((s) => (
-
- ))}
-
- )}
-
- )}
-
- {method === 'connection-id' && (
-
-
- setConnectionId(e.target.value)}
- placeholder={action === 'create' ? 'Auto-generated if empty' : 'e.g., meeting-123'}
- autoFocus={action === 'join'}
- />
-
- )}
-
-
-
-
-
-
- {qrCodeUrl && connectionStatus === 'connecting' && action === 'create' && (
-
-
Scan to connect:
-

-
{currentConnectionId}
-
- )}
-
-
+ setStep(2)}
+ onTopicSelect={setTopic}
+ onDiscoverPeers={discoverPeers}
+ />
)}
{step === 4 && (
-
-
-
-
Connected
-
- Peer: {connectedPeer || 'Unknown'} • ID: {currentConnectionId}
-
-
-
-
-
- {qrCodeUrl && connectionStatus === 'connecting' && (
-
-
Scan to connect:
-

-
{currentConnectionId}
-
- )}
-
-
- {messages.length === 0 ? (
-
No messages yet. Start chatting!
- ) : (
- messages.map((msg, idx) => (
-
- {msg.messageType === 'text' ? (
-
{msg.text}
- ) : (
-
-
📎
-
-
{msg.file.name}
-
{(msg.file.size / 1024).toFixed(2)} KB
-
-
-
- )}
-
{msg.timestamp.toLocaleTimeString()}
-
- ))
- )}
-
-
-
-
-
- setMessageInput(e.target.value)}
- onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
- placeholder="Type a message..."
- disabled={!channelReady}
- />
-
-
-
- {logs.length > 0 && (
-
- Activity Log ({logs.length})
-
- {logs.map((log, idx) => (
-
- [{log.timestamp}] {log.message}
-
- ))}
-
-
- )}
-
+
)}
Your Peer ID: {rdv.peerId}
diff --git a/src/components/ActionSelector.jsx b/src/components/ActionSelector.jsx
new file mode 100644
index 0000000..c6101f9
--- /dev/null
+++ b/src/components/ActionSelector.jsx
@@ -0,0 +1,37 @@
+import QRScanner from './QRScanner';
+
+function ActionSelector({ action, onSelectAction, onScanComplete, onScanCancel, log }) {
+ return (
+
+
Choose Action
+
+
+
+
+
+ {action === 'scan' && (
+
+ )}
+
+ );
+}
+
+export default ActionSelector;
diff --git a/src/components/ChatView.jsx b/src/components/ChatView.jsx
new file mode 100644
index 0000000..53a6127
--- /dev/null
+++ b/src/components/ChatView.jsx
@@ -0,0 +1,99 @@
+import { useRef } from 'react';
+import Message from './Message';
+import FileUploadProgress from './FileUploadProgress';
+
+function ChatView({
+ connectedPeer,
+ currentConnectionId,
+ messages,
+ messageInput,
+ setMessageInput,
+ channelReady,
+ logs,
+ fileUploadProgress,
+ onSendMessage,
+ onFileSelect,
+ onDisconnect,
+ onDownloadFile,
+ onCancelUpload
+}) {
+ const fileInputRef = useRef(null);
+
+ return (
+
+
+
+
Connected
+
+ Peer: {connectedPeer || 'Unknown'} • ID: {currentConnectionId}
+
+
+
+
+
+
+ {messages.length === 0 ? (
+
No messages yet. Start chatting!
+ ) : (
+ messages.map((msg, idx) => (
+
+ ))
+ )}
+
+
+ {fileUploadProgress && (
+
+ )}
+
+
+
+
+ setMessageInput(e.target.value)}
+ onKeyPress={(e) => e.key === 'Enter' && onSendMessage()}
+ placeholder="Type a message..."
+ disabled={!channelReady}
+ />
+
+
+
+ {logs.length > 0 && (
+
+ Activity Log ({logs.length})
+
+ {logs.map((log, idx) => (
+
+ [{log.timestamp}] {log.message}
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+export default ChatView;
diff --git a/src/components/ConnectionForm.jsx b/src/components/ConnectionForm.jsx
new file mode 100644
index 0000000..20466af
--- /dev/null
+++ b/src/components/ConnectionForm.jsx
@@ -0,0 +1,119 @@
+import QRCodeDisplay from './QRCodeDisplay';
+
+function ConnectionForm({
+ action,
+ method,
+ topic,
+ setTopic,
+ connectionId,
+ setConnectionId,
+ peerId,
+ setPeerId,
+ topics,
+ sessions,
+ connectionStatus,
+ qrCodeUrl,
+ currentConnectionId,
+ onConnect,
+ onBack,
+ onTopicSelect,
+ onDiscoverPeers
+}) {
+ return (
+
+
Enter Details
+
+ {(method === 'topic' || method === 'peer-id' || (method === 'connection-id' && action === 'create')) && (
+
+
+
setTopic(e.target.value)}
+ placeholder="e.g., game-room"
+ autoFocus
+ />
+ {topics.length > 0 && (
+
+ {topics.map((t) => (
+
+ ))}
+
+ )}
+
+ )}
+
+ {method === 'peer-id' && (
+
+
+
setPeerId(e.target.value)}
+ placeholder="e.g., player-123"
+ />
+ {sessions.length > 0 && (
+
+ {sessions.map((s) => (
+
+ ))}
+
+ )}
+
+ )}
+
+ {method === 'connection-id' && (
+
+
+ setConnectionId(e.target.value)}
+ placeholder={action === 'create' ? 'Auto-generated if empty' : 'e.g., meeting-123'}
+ autoFocus={action === 'join'}
+ />
+
+ )}
+
+
+
+
+
+
+ {qrCodeUrl && connectionStatus === 'connecting' && action === 'create' && (
+
+ )}
+
+
+ );
+}
+
+export default ConnectionForm;
diff --git a/src/components/FileUploadProgress.jsx b/src/components/FileUploadProgress.jsx
new file mode 100644
index 0000000..85a0342
--- /dev/null
+++ b/src/components/FileUploadProgress.jsx
@@ -0,0 +1,17 @@
+function FileUploadProgress({ fileName, progress, onCancel }) {
+ return (
+
+ );
+}
+
+export default FileUploadProgress;
diff --git a/src/components/Header.jsx b/src/components/Header.jsx
new file mode 100644
index 0000000..187cf69
--- /dev/null
+++ b/src/components/Header.jsx
@@ -0,0 +1,32 @@
+function Header() {
+ return (
+
+ );
+}
+
+export default Header;
diff --git a/src/components/Message.jsx b/src/components/Message.jsx
new file mode 100644
index 0000000..b1b1817
--- /dev/null
+++ b/src/components/Message.jsx
@@ -0,0 +1,28 @@
+function Message({ message, onDownload }) {
+ const isFile = message.messageType === 'file';
+
+ return (
+
+ {isFile ? (
+
+
📎
+
+
{message.file.name}
+
{(message.file.size / 1024).toFixed(2)} KB
+
+
+
+ ) : (
+
{message.text}
+ )}
+
{message.timestamp.toLocaleTimeString()}
+
+ );
+}
+
+export default Message;
diff --git a/src/components/MethodSelector.jsx b/src/components/MethodSelector.jsx
new file mode 100644
index 0000000..f0c505d
--- /dev/null
+++ b/src/components/MethodSelector.jsx
@@ -0,0 +1,39 @@
+function MethodSelector({ action, onSelectMethod, onBack }) {
+ return (
+
+
{action === 'create' ? 'Create' : 'Join'} by...
+
+
+ {action === 'join' && (
+
+ )}
+
+
+
+
+ );
+}
+
+export default MethodSelector;
diff --git a/src/components/QRCodeDisplay.jsx b/src/components/QRCodeDisplay.jsx
new file mode 100644
index 0000000..6418ac6
--- /dev/null
+++ b/src/components/QRCodeDisplay.jsx
@@ -0,0 +1,13 @@
+function QRCodeDisplay({ qrCodeUrl, connectionId }) {
+ if (!qrCodeUrl) return null;
+
+ return (
+
+
Scan to connect:
+

+
{connectionId}
+
+ );
+}
+
+export default QRCodeDisplay;
diff --git a/src/components/QRScanner.jsx b/src/components/QRScanner.jsx
new file mode 100644
index 0000000..4d6dbc7
--- /dev/null
+++ b/src/components/QRScanner.jsx
@@ -0,0 +1,74 @@
+import { useRef, useEffect } from 'react';
+import { BrowserQRCodeReader } from '@zxing/library';
+
+function QRScanner({ onScan, onCancel, log }) {
+ const videoRef = useRef(null);
+ const scannerRef = useRef(null);
+
+ useEffect(() => {
+ startScanning();
+ return () => {
+ stopScanning();
+ };
+ }, []);
+
+ const startScanning = async () => {
+ try {
+ scannerRef.current = new BrowserQRCodeReader();
+ log('Starting QR scanner...', 'info');
+
+ const videoInputDevices = await scannerRef.current.listVideoInputDevices();
+
+ if (videoInputDevices.length === 0) {
+ log('No camera found', 'error');
+ return;
+ }
+
+ // Prefer back camera (environment-facing)
+ let selectedDeviceId = videoInputDevices[0].deviceId;
+ const backCamera = videoInputDevices.find(device =>
+ device.label.toLowerCase().includes('back') ||
+ device.label.toLowerCase().includes('rear') ||
+ device.label.toLowerCase().includes('environment')
+ );
+
+ if (backCamera) {
+ selectedDeviceId = backCamera.deviceId;
+ log('Using back camera', 'info');
+ } else {
+ log('Back camera not found, using default', 'info');
+ }
+
+ scannerRef.current.decodeFromVideoDevice(
+ selectedDeviceId,
+ videoRef.current,
+ (result, err) => {
+ if (result) {
+ const scannedId = result.getText();
+ log(`Scanned: ${scannedId}`, 'success');
+ stopScanning();
+ onScan(scannedId);
+ }
+ }
+ );
+ } catch (error) {
+ log(`Scanner error: ${error.message}`, 'error');
+ }
+ };
+
+ const stopScanning = () => {
+ if (scannerRef.current) {
+ scannerRef.current.reset();
+ log('Scanner stopped', 'info');
+ }
+ };
+
+ return (
+
+
+
+
+ );
+}
+
+export default QRScanner;
diff --git a/src/index.css b/src/index.css
index 85abfda..335fc5a 100644
--- a/src/index.css
+++ b/src/index.css
@@ -514,6 +514,74 @@ input[type="text"]:disabled {
transform: scale(1.05);
}
+.file-upload-progress {
+ background: #f8f9fa;
+ border: 2px solid #667eea;
+ border-radius: 8px;
+ padding: 16px;
+ margin-bottom: 16px;
+}
+
+.file-upload-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 12px;
+}
+
+.file-upload-name {
+ font-weight: 600;
+ color: #333;
+ font-size: 0.9rem;
+}
+
+.file-upload-cancel {
+ background: #dc3545;
+ color: white;
+ border: none;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ font-size: 1.2rem;
+ line-height: 1;
+ cursor: pointer;
+ transition: all 0.2s;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+}
+
+.file-upload-cancel:hover {
+ background: #c82333;
+ transform: scale(1.1);
+}
+
+.progress-bar {
+ background: #e0e0e0;
+ border-radius: 8px;
+ height: 24px;
+ overflow: hidden;
+ position: relative;
+}
+
+.progress-bar-fill {
+ background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
+ height: 100%;
+ transition: width 0.3s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+}
+
+.progress-text {
+ color: white;
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
+}
+
.logs {
margin-top: 24px;
border-top: 2px solid #f0f0f0;