mirror of
https://github.com/xtr-dev/rondevu-demo.git
synced 2025-12-10 18:53:24 +00:00
Add file sharing and QR code features
- Add file sharing with chunked transfer over data channel - Display file messages with download button - Add QR code generation for connection sharing - Add QR scanner for easy connection joining - Update UI with file button and scan option - Add responsive CSS styling for new features
This commit is contained in:
330
src/App.jsx
330
src/App.jsx
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Rondevu, RondevuClient } from '@xtr-dev/rondevu-client';
|
||||
import QRCode from 'qrcode';
|
||||
import { BrowserQRCodeReader } from '@zxing/library';
|
||||
|
||||
const rdv = new Rondevu({
|
||||
baseUrl: 'https://rondevu.xtrdev.workers.dev',
|
||||
@@ -23,8 +25,9 @@ const client = new RondevuClient({
|
||||
function App() {
|
||||
// Step-based state
|
||||
const [step, setStep] = useState(1); // 1: action, 2: method, 3: details, 4: connected
|
||||
const [action, setAction] = useState(null); // 'create' or 'join'
|
||||
const [action, setAction] = useState(null); // 'create', 'join', or 'scan'
|
||||
const [method, setMethod] = useState(null); // 'topic', 'peer-id', 'connection-id'
|
||||
const [qrCodeUrl, setQrCodeUrl] = useState('');
|
||||
|
||||
// Connection state
|
||||
const [topic, setTopic] = useState('');
|
||||
@@ -40,9 +43,14 @@ function App() {
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [messageInput, setMessageInput] = useState('');
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [channelReady, setChannelReady] = useState(false);
|
||||
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
log('Demo initialized', 'info');
|
||||
@@ -107,13 +115,25 @@ function App() {
|
||||
const setupDataChannel = (channel) => {
|
||||
dataChannelRef.current = channel;
|
||||
|
||||
channel.onmessage = (event) => {
|
||||
setMessages(prev => [...prev, {
|
||||
text: event.data,
|
||||
type: 'received',
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
channel.onopen = () => {
|
||||
log('Data channel ready', 'success');
|
||||
setChannelReady(true);
|
||||
};
|
||||
|
||||
channel.onclose = () => {
|
||||
log('Data channel closed', 'info');
|
||||
setChannelReady(false);
|
||||
};
|
||||
|
||||
channel.onmessage = (event) => {
|
||||
handleReceivedMessage(event.data);
|
||||
};
|
||||
|
||||
// If channel is already open (for channels we create)
|
||||
if (channel.readyState === 'open') {
|
||||
log('Data channel ready', 'success');
|
||||
setChannelReady(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = async () => {
|
||||
@@ -152,30 +172,259 @@ function App() {
|
||||
|
||||
setConnectedPeer(connection.remotePeerId || 'Waiting...');
|
||||
setupConnection(connection);
|
||||
|
||||
// Generate QR code if creating a connection
|
||||
if (action === 'create' && currentConnectionId) {
|
||||
try {
|
||||
const qrUrl = await QRCode.toDataURL(currentConnectionId, {
|
||||
width: 256,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#667eea',
|
||||
light: '#ffffff'
|
||||
}
|
||||
});
|
||||
setQrCodeUrl(qrUrl);
|
||||
} catch (err) {
|
||||
log(`QR code generation error: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log(`Error: ${error.message}`, 'error');
|
||||
setConnectionStatus('disconnected');
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const selectedDeviceId = videoInputDevices[0].deviceId;
|
||||
|
||||
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 || !dataChannelRef.current || dataChannelRef.current.readyState !== 'open') {
|
||||
if (!messageInput || !channelReady || !dataChannelRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
dataChannelRef.current.send(messageInput);
|
||||
const message = { type: 'text', content: messageInput };
|
||||
dataChannelRef.current.send(JSON.stringify(message));
|
||||
setMessages(prev => [...prev, {
|
||||
text: messageInput,
|
||||
messageType: 'text',
|
||||
type: 'sent',
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
setMessageInput('');
|
||||
};
|
||||
|
||||
const handleFileSelect = async (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (!file || !channelReady || !dataChannelRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const CHUNK_SIZE = 16384; // 16KB chunks
|
||||
const fileId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
log(`Sending file: ${file.name} (${(file.size / 1024).toFixed(2)} KB)`, 'info');
|
||||
|
||||
try {
|
||||
// Send file metadata
|
||||
const metadata = {
|
||||
type: 'file-start',
|
||||
fileId,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
mimeType: file.type,
|
||||
chunks: Math.ceil(file.size / CHUNK_SIZE)
|
||||
};
|
||||
dataChannelRef.current.send(JSON.stringify(metadata));
|
||||
|
||||
// Read and send file in chunks
|
||||
const reader = new FileReader();
|
||||
let offset = 0;
|
||||
let chunkIndex = 0;
|
||||
|
||||
const readChunk = () => {
|
||||
const slice = file.slice(offset, offset + CHUNK_SIZE);
|
||||
reader.readAsArrayBuffer(slice);
|
||||
};
|
||||
|
||||
reader.onload = (e) => {
|
||||
const chunk = {
|
||||
type: 'file-chunk',
|
||||
fileId,
|
||||
index: chunkIndex,
|
||||
data: Array.from(new Uint8Array(e.target.result))
|
||||
};
|
||||
dataChannelRef.current.send(JSON.stringify(chunk));
|
||||
|
||||
offset += CHUNK_SIZE;
|
||||
chunkIndex++;
|
||||
|
||||
if (offset < file.size) {
|
||||
readChunk();
|
||||
} else {
|
||||
// Send completion message
|
||||
const complete = { type: 'file-complete', fileId };
|
||||
dataChannelRef.current.send(JSON.stringify(complete));
|
||||
|
||||
// Add to local messages
|
||||
setMessages(prev => [...prev, {
|
||||
messageType: 'file',
|
||||
file: {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
mimeType: file.type,
|
||||
data: file
|
||||
},
|
||||
type: 'sent',
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
|
||||
log(`File sent: ${file.name}`, 'success');
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
log(`Error reading file: ${file.name}`, 'error');
|
||||
};
|
||||
|
||||
readChunk();
|
||||
} catch (error) {
|
||||
log(`Error sending file: ${error.message}`, 'error');
|
||||
}
|
||||
|
||||
// Reset file input
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const handleReceivedMessage = (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data);
|
||||
|
||||
if (message.type === 'text') {
|
||||
setMessages(prev => [...prev, {
|
||||
text: message.content,
|
||||
messageType: 'text',
|
||||
type: 'received',
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
} else if (message.type === 'file-start') {
|
||||
fileTransfersRef.current.set(message.fileId, {
|
||||
name: message.name,
|
||||
size: message.size,
|
||||
mimeType: message.mimeType,
|
||||
chunks: new Array(message.chunks),
|
||||
receivedChunks: 0
|
||||
});
|
||||
log(`Receiving file: ${message.name}`, 'info');
|
||||
} else if (message.type === 'file-chunk') {
|
||||
const transfer = fileTransfersRef.current.get(message.fileId);
|
||||
if (transfer) {
|
||||
transfer.chunks[message.index] = new Uint8Array(message.data);
|
||||
transfer.receivedChunks++;
|
||||
}
|
||||
} else if (message.type === 'file-complete') {
|
||||
const transfer = fileTransfersRef.current.get(message.fileId);
|
||||
if (transfer) {
|
||||
// Combine all chunks
|
||||
const totalSize = transfer.chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const combined = new Uint8Array(totalSize);
|
||||
let offset = 0;
|
||||
for (const chunk of transfer.chunks) {
|
||||
combined.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
const blob = new Blob([combined], { type: transfer.mimeType });
|
||||
|
||||
setMessages(prev => [...prev, {
|
||||
messageType: 'file',
|
||||
file: {
|
||||
name: transfer.name,
|
||||
size: transfer.size,
|
||||
mimeType: transfer.mimeType,
|
||||
data: blob
|
||||
},
|
||||
type: 'received',
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
|
||||
log(`File received: ${transfer.name}`, 'success');
|
||||
fileTransfersRef.current.delete(message.fileId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Assume it's a plain text message (backward compatibility)
|
||||
setMessages(prev => [...prev, {
|
||||
text: data,
|
||||
messageType: 'text',
|
||||
type: 'received',
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadFile = (file) => {
|
||||
const url = URL.createObjectURL(file.data);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = file.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
if (connectionRef.current) {
|
||||
connectionRef.current.close();
|
||||
}
|
||||
stopScanning();
|
||||
setStep(1);
|
||||
setAction(null);
|
||||
setMethod(null);
|
||||
@@ -187,6 +436,8 @@ function App() {
|
||||
setConnectedPeer(null);
|
||||
setCurrentConnectionId(null);
|
||||
setMessages([]);
|
||||
setChannelReady(false);
|
||||
setQrCodeUrl('');
|
||||
connectionRef.current = null;
|
||||
dataChannelRef.current = null;
|
||||
};
|
||||
@@ -224,7 +475,7 @@ function App() {
|
||||
{step === 1 && (
|
||||
<div className="step-container">
|
||||
<h2>Choose Action</h2>
|
||||
<div className="button-grid">
|
||||
<div className="button-grid button-grid-three">
|
||||
<button
|
||||
className="action-button"
|
||||
onClick={() => {
|
||||
@@ -245,7 +496,22 @@ function App() {
|
||||
<div className="button-title">Join</div>
|
||||
<div className="button-description">Connect to existing peers</div>
|
||||
</button>
|
||||
<button
|
||||
className="action-button"
|
||||
onClick={() => {
|
||||
setAction('scan');
|
||||
}}
|
||||
>
|
||||
<div className="button-title">Scan QR</div>
|
||||
<div className="button-description">Scan a connection code</div>
|
||||
</button>
|
||||
</div>
|
||||
{action === 'scan' && (
|
||||
<div className="scanner-container">
|
||||
<video ref={videoRef} className="scanner-video" />
|
||||
<button className="back-button" onClick={() => setAction(null)}>← Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -398,13 +664,37 @@ function App() {
|
||||
<button className="disconnect-button" onClick={reset}>Disconnect</button>
|
||||
</div>
|
||||
|
||||
{qrCodeUrl && connectionStatus === 'connecting' && (
|
||||
<div className="qr-code-container">
|
||||
<p className="qr-label">Scan to connect:</p>
|
||||
<img src={qrCodeUrl} alt="Connection QR Code" className="qr-code" />
|
||||
<p className="connection-id-display">{currentConnectionId}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="messages">
|
||||
{messages.length === 0 ? (
|
||||
<p className="empty">No messages yet. Start chatting!</p>
|
||||
) : (
|
||||
messages.map((msg, idx) => (
|
||||
<div key={idx} className={`message ${msg.type}`}>
|
||||
<div className="message-text">{msg.text}</div>
|
||||
{msg.messageType === 'text' ? (
|
||||
<div className="message-text">{msg.text}</div>
|
||||
) : (
|
||||
<div className="message-file">
|
||||
<div className="file-icon">📎</div>
|
||||
<div className="file-info">
|
||||
<div className="file-name">{msg.file.name}</div>
|
||||
<div className="file-size">{(msg.file.size / 1024).toFixed(2)} KB</div>
|
||||
</div>
|
||||
<button
|
||||
className="file-download"
|
||||
onClick={() => downloadFile(msg.file)}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="message-time">{msg.timestamp.toLocaleTimeString()}</div>
|
||||
</div>
|
||||
))
|
||||
@@ -412,17 +702,31 @@ function App() {
|
||||
</div>
|
||||
|
||||
<div className="message-input">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<button
|
||||
className="file-button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={!channelReady}
|
||||
title="Send file"
|
||||
>
|
||||
📎
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
value={messageInput}
|
||||
onChange={(e) => setMessageInput(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
|
||||
placeholder="Type a message..."
|
||||
disabled={!dataChannelRef.current || dataChannelRef.current.readyState !== 'open'}
|
||||
disabled={!channelReady}
|
||||
/>
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={!dataChannelRef.current || dataChannelRef.current.readyState !== 'open'}
|
||||
disabled={!channelReady}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user