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:
2025-11-07 22:12:12 +01:00
parent 6446f21924
commit 5219b79bb5
4 changed files with 804 additions and 15 deletions

View File

@@ -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>

View File

@@ -103,6 +103,10 @@ body {
margin-bottom: 24px;
}
.button-grid-three {
grid-template-columns: repeat(3, 1fr);
}
.action-button {
background: white;
border: 3px solid #e0e0e0;
@@ -392,7 +396,7 @@ input[type="text"]:disabled {
gap: 12px;
}
.message-input input {
.message-input input[type="text"] {
flex: 1;
margin-bottom: 0;
}
@@ -409,6 +413,11 @@ input[type="text"]:disabled {
transition: all 0.2s;
}
.message-input .file-button {
padding: 12px 16px;
font-size: 1.2rem;
}
.message-input button:hover:not(:disabled) {
background: #5568d3;
transform: translateY(-1px);
@@ -421,6 +430,90 @@ input[type="text"]:disabled {
transform: none;
}
.message-file {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: 12px;
background: inherit;
width: 100%;
max-width: 400px;
}
.message.sent .message-file {
background: rgba(255, 255, 255, 0.2);
margin-left: auto;
}
.message.received .message-file {
background: #f8f9fa;
border: 2px solid #e0e0e0;
}
.file-icon {
font-size: 1.5rem;
flex-shrink: 0;
}
.file-info {
flex: 1;
min-width: 0;
}
.file-name {
font-weight: 600;
font-size: 0.9rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.message.sent .file-name {
color: white;
}
.message.received .file-name {
color: #333;
}
.file-size {
font-size: 0.75rem;
opacity: 0.8;
margin-top: 2px;
}
.message.sent .file-size {
color: white;
}
.message.received .file-size {
color: #6c757d;
}
.file-download {
padding: 6px 12px;
font-size: 0.85rem;
background: rgba(255, 255, 255, 0.9);
color: #667eea;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
}
.message.sent .file-download {
background: rgba(255, 255, 255, 0.3);
color: white;
}
.file-download:hover {
background: white;
transform: scale(1.05);
}
.logs {
margin-top: 24px;
border-top: 2px solid #f0f0f0;
@@ -478,6 +571,57 @@ input[type="text"]:disabled {
backdrop-filter: blur(10px);
}
.scanner-container {
margin-top: 24px;
text-align: center;
animation: fadeIn 0.3s ease-out;
}
.scanner-video {
width: 100%;
max-width: 400px;
height: 300px;
border-radius: 12px;
background: #1e1e1e;
margin-bottom: 16px;
object-fit: cover;
}
.qr-code-container {
text-align: center;
padding: 24px;
background: #f8f9fa;
border-radius: 12px;
margin-bottom: 20px;
}
.qr-label {
font-size: 0.95rem;
color: #667eea;
font-weight: 600;
margin-bottom: 12px;
}
.qr-code {
display: block;
margin: 0 auto 12px;
border-radius: 8px;
background: white;
padding: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.connection-id-display {
font-family: 'Courier New', monospace;
font-size: 0.9rem;
color: #333;
background: white;
padding: 8px 16px;
border-radius: 6px;
display: inline-block;
font-weight: 600;
}
.footer {
text-align: center;
padding: 40px 20px 30px;
@@ -517,7 +661,8 @@ input[type="text"]:disabled {
padding: 32px 24px;
}
.button-grid {
.button-grid,
.button-grid-three {
grid-template-columns: 1fr;
}