-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
/online url, direct connect over webrtc
- Loading branch information
Showing
7 changed files
with
690 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,89 @@ | ||
import { useEffect, useState } from 'react'; | ||
|
||
import PeerConnection from '@/pages/online/connection'; | ||
import { Avatar } from '@/shared/components/user/Avatar'; | ||
import { Name } from '@/shared/components/user/Name'; | ||
|
||
export function UserRow({ | ||
pubKey, | ||
description, | ||
connection, | ||
isCurrentUser, | ||
}: { | ||
pubKey: string; | ||
description?: string; | ||
connection?: PeerConnection; | ||
isCurrentUser: boolean; | ||
}) { | ||
const [connectionStatus, setConnectionStatus] = useState( | ||
connection?.peerConnection.connectionState || 'No connection', | ||
); | ||
|
||
useEffect(() => { | ||
const handleConnectionStateChange = () => { | ||
setConnectionStatus(connection?.peerConnection.connectionState || 'No connection'); | ||
}; | ||
|
||
connection?.peerConnection.addEventListener( | ||
'connectionstatechange', | ||
handleConnectionStateChange, | ||
); | ||
|
||
// Cleanup event listener on unmount | ||
return () => { | ||
connection?.peerConnection.removeEventListener( | ||
'connectionstatechange', | ||
handleConnectionStateChange, | ||
); | ||
}; | ||
}, [connection]); | ||
|
||
const getStatusColor = (status: string) => { | ||
switch (status) { | ||
case 'connected': | ||
return 'bg-green-500'; | ||
case 'disconnected': | ||
case 'failed': | ||
return 'bg-red-500'; | ||
case 'connecting': | ||
return 'bg-yellow-500'; | ||
default: | ||
return 'bg-gray-500'; | ||
} | ||
}; | ||
|
||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { | ||
const file = event.target.files?.[0]; | ||
console.log('File:', file); | ||
if (file && connection) { | ||
console.log('Sending file:', file); | ||
connection.sendFile(file); | ||
} else { | ||
console.error('No file or connection'); | ||
} | ||
}; | ||
|
||
return ( | ||
<div className="flex flex-row items-center gap-2 justify-between"> | ||
<div className="flex items-center gap-2 flex-row"> | ||
<Avatar pubKey={pubKey} /> | ||
<Name pubKey={pubKey} /> | ||
</div> | ||
<span className="text-base-content">{description}</span> | ||
{connectionStatus === 'connected' && ( | ||
<> | ||
<input | ||
type="file" | ||
onChange={handleFileChange} | ||
className="hidden" | ||
id={`file-input-${pubKey}`} | ||
/> | ||
<label htmlFor={`file-input-${pubKey}`} className="btn btn-primary"> | ||
Send File | ||
</label> | ||
</> | ||
)} | ||
{!isCurrentUser && <span className={`badge ${getStatusColor(connectionStatus)}`}></span>} | ||
</div> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,227 @@ | ||
import debug from 'debug'; | ||
import { EventEmitter } from 'tseep'; | ||
|
||
import { SignalingMessageWithoutPeerId } from './types'; | ||
|
||
const log = debug('webrtc:connection'); | ||
|
||
export default class PeerConnection extends EventEmitter { | ||
peerId: string; | ||
signalingSend: (message: SignalingMessageWithoutPeerId) => void; | ||
peerConnection: RTCPeerConnection; | ||
dataChannel: RTCDataChannel | null; | ||
fileChannel: RTCDataChannel | null; | ||
incomingFileMetadata: { name: string; size: number; type: string } | null = null; | ||
receivedFileData: ArrayBuffer[] = []; | ||
receivedFileSize: number = 0; | ||
|
||
constructor(peerId: string, signalingSend: (message: SignalingMessageWithoutPeerId) => void) { | ||
super(); | ||
this.peerId = peerId; | ||
this.signalingSend = signalingSend; | ||
this.peerConnection = new RTCPeerConnection({ | ||
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], | ||
}); | ||
this.dataChannel = null; | ||
this.fileChannel = null; | ||
this.setupPeerConnectionEvents(); | ||
} | ||
|
||
setupPeerConnectionEvents() { | ||
this.peerConnection.onicecandidate = (event) => { | ||
if (event.candidate) { | ||
this.signalingSend({ | ||
type: 'candidate', | ||
candidate: event.candidate, | ||
recipient: this.peerId, | ||
}); | ||
} | ||
}; | ||
|
||
this.peerConnection.ondatachannel = (event) => { | ||
const channel = event.channel; | ||
if (channel.label.startsWith('fileChannel')) { | ||
this.setFileChannel(channel); | ||
} else { | ||
this.setDataChannel(channel); | ||
} | ||
}; | ||
|
||
this.peerConnection.onconnectionstatechange = () => { | ||
console.log('Connection state:', this.peerConnection.connectionState); | ||
if (this.peerConnection.connectionState === 'closed') { | ||
console.log(`${this.peerId} connection closed`); | ||
this.close(); | ||
} | ||
}; | ||
} | ||
|
||
async createOffer() { | ||
this.dataChannel = this.peerConnection.createDataChannel('jsonChannel'); | ||
this.setDataChannel(this.dataChannel); | ||
this.fileChannel = this.peerConnection.createDataChannel('fileChannel'); | ||
this.setFileChannel(this.fileChannel); | ||
const offer = await this.peerConnection.createOffer(); | ||
await this.peerConnection.setLocalDescription(offer); | ||
this.signalingSend({ type: 'offer', offer, recipient: this.peerId }); | ||
} | ||
|
||
async createAnswer(offer: RTCSessionDescriptionInit) { | ||
await this.peerConnection.setRemoteDescription(new RTCSessionDescription(offer)); | ||
const answer = await this.peerConnection.createAnswer(); | ||
await this.peerConnection.setLocalDescription(answer); | ||
this.signalingSend({ type: 'answer', answer, recipient: this.peerId }); | ||
} | ||
|
||
async addIceCandidate(candidate: RTCIceCandidateInit) { | ||
await this.peerConnection.addIceCandidate(new RTCIceCandidate(candidate)); | ||
} | ||
|
||
setDataChannel(dataChannel: RTCDataChannel) { | ||
this.dataChannel = dataChannel; | ||
this.dataChannel.onopen = () => console.log('Data channel is open'); | ||
this.dataChannel.onmessage = (event) => { | ||
console.log('Received message:', event.data); | ||
}; | ||
this.dataChannel.onclose = () => { | ||
console.log('Data channel is closed'); | ||
this.close(); | ||
}; | ||
} | ||
|
||
setFileChannel(fileChannel: RTCDataChannel) { | ||
this.fileChannel = fileChannel; | ||
this.fileChannel.binaryType = 'arraybuffer'; | ||
this.fileChannel.onopen = () => console.log('File channel is open'); | ||
this.fileChannel.onmessage = (event) => { | ||
console.log('File channel received message:', event.data); | ||
if (typeof event.data === 'string') { | ||
const metadata = JSON.parse(event.data); | ||
if (metadata.type === 'file-metadata') { | ||
this.incomingFileMetadata = metadata.metadata; | ||
this.receivedFileData = []; | ||
this.receivedFileSize = 0; | ||
console.log('Received file metadata:', this.incomingFileMetadata); | ||
} | ||
} else if (event.data instanceof ArrayBuffer) { | ||
this.receivedFileData.push(event.data); | ||
this.receivedFileSize += event.data.byteLength; | ||
console.log('Received file chunk:', event.data.byteLength, 'bytes'); | ||
console.log('Total received size:', this.receivedFileSize, 'bytes'); | ||
|
||
if (this.incomingFileMetadata) { | ||
console.log('Expected file size:', this.incomingFileMetadata.size, 'bytes'); | ||
if (this.receivedFileSize === this.incomingFileMetadata.size) { | ||
console.log('File fully received, saving file...'); | ||
this.saveReceivedFile(); | ||
} else { | ||
console.log('File not fully received, waiting...'); | ||
} | ||
} else { | ||
console.error('No file metadata available'); | ||
} | ||
} | ||
}; | ||
this.fileChannel.onclose = () => { | ||
console.log('File channel is closed'); | ||
}; | ||
} | ||
|
||
async saveReceivedFile() { | ||
if (!this.incomingFileMetadata) { | ||
console.error('No file metadata available'); | ||
return; | ||
} | ||
|
||
const confirmString = `Save ${this.incomingFileMetadata.name} from ${this.peerId}?`; | ||
if (!confirm(confirmString)) { | ||
console.log('User did not confirm file save'); | ||
this.incomingFileMetadata = null; | ||
this.receivedFileData = []; | ||
this.receivedFileSize = 0; | ||
return; | ||
} | ||
|
||
console.log('Saving file with metadata:', this.incomingFileMetadata); | ||
console.log('Total received file data size:', this.receivedFileSize); | ||
|
||
const blob = new Blob(this.receivedFileData, { type: this.incomingFileMetadata.type }); | ||
console.log('Created Blob:', blob); | ||
|
||
const url = URL.createObjectURL(blob); | ||
console.log('Created Object URL:', url); | ||
|
||
const a = document.createElement('a'); | ||
a.href = url; | ||
a.download = this.incomingFileMetadata.name; | ||
document.body.appendChild(a); | ||
console.log('Appended anchor element to body:', a); | ||
|
||
a.click(); | ||
console.log('Triggered download'); | ||
|
||
document.body.removeChild(a); | ||
console.log('Removed anchor element from body'); | ||
|
||
URL.revokeObjectURL(url); | ||
console.log('Revoked Object URL'); | ||
|
||
// Reset file data | ||
this.incomingFileMetadata = null; | ||
this.receivedFileData = []; | ||
this.receivedFileSize = 0; | ||
console.log('Reset file data'); | ||
} | ||
|
||
sendJsonData(jsonData: unknown) { | ||
if (this.dataChannel?.readyState === 'open') { | ||
const jsonString = JSON.stringify(jsonData); | ||
this.dataChannel.send(jsonString); | ||
} | ||
} | ||
|
||
sendFile(file: File) { | ||
if (this.peerConnection.connectionState === 'connected') { | ||
// Create a unique file channel name | ||
const fileChannelName = `fileChannel-${Date.now()}`; | ||
const fileChannel = this.peerConnection.createDataChannel(fileChannelName); | ||
this.setFileChannel(fileChannel); | ||
|
||
// Send file metadata over the file channel | ||
const metadata = { | ||
type: 'file-metadata', | ||
metadata: { | ||
name: file.name, | ||
size: file.size, | ||
type: file.type, | ||
}, | ||
}; | ||
fileChannel.onopen = () => { | ||
console.log('File channel is open, sending metadata'); | ||
fileChannel.send(JSON.stringify(metadata)); | ||
|
||
// Read and send the file as binary data | ||
const reader = new FileReader(); | ||
reader.onload = () => { | ||
if (reader.result && reader.result instanceof ArrayBuffer) { | ||
fileChannel.send(reader.result); | ||
} | ||
}; | ||
reader.readAsArrayBuffer(file); | ||
}; | ||
} else { | ||
console.error('Peer connection is not connected'); | ||
} | ||
} | ||
|
||
close() { | ||
if (this.dataChannel) { | ||
this.dataChannel.close(); | ||
} | ||
if (this.fileChannel) { | ||
this.fileChannel.close(); | ||
} | ||
this.peerConnection.close(); | ||
this.emit('close'); | ||
} | ||
} |
Oops, something went wrong.