-
Notifications
You must be signed in to change notification settings - Fork 0
/
script.js
9 lines (9 loc) · 18.3 KB
/
script.js
1
2
3
4
5
6
7
8
9
window.isRTCSupported=!!(window.RTCPeerConnection||window.mozRTCPeerConnection||window.webkitRTCPeerConnection);class ServerConnection{constructor(){this._connect();Events.on("beforeunload",()=>this._disconnect());Events.on("pagehide",()=>this._disconnect());document.addEventListener("visibilitychange",()=>this._onVisibilityChange())}_connect(){clearTimeout(this._reconnectTimer);if(this._isConnected()||this._isConnecting()){return}const ws=new WebSocket(this._getEndpoint());ws.binaryType="arraybuffer";ws.onopen=()=>console.info("[WSS]: Connection established");ws.onmessage=e=>this._onMessage(e.data);ws.onclose=()=>this._onDisconnect();ws.onerror=e=>console.error(e);this._socket=ws}_onMessage(msg){msg=JSON.parse(msg);console.info("[WSS]:",msg);switch(msg.type){case "self":return Events.fire("self",msg.self);case "peers":return Events.fire("peers",msg.peers);case "peer-joined":return Events.fire("peer-joined",msg.peer);case "peer-left":return Events.fire("peer-left",msg.peer);case "signal":return Events.fire("signal",msg);case "ping":return this.send({type:"pong"});default:console.error("[WSS]: Unknown message type",msg)}}_getEndpoint(){return "wss://airbridge.herokuapp.com"+(window.isRTCSupported?"/webrtc":"/nortc")}_disconnect(){this.send({type:"disconnect"});this._socket.onclose=null;this._socket.close()}_onDisconnect(){console.info("[WSS]: Connection lost");Events.fire("toast-notification","Connection lost. Retrying in 5 seconds...");clearTimeout(this._reconnectTimer);this._reconnectTimer=setTimeout(_=>this._connect(),5000)}_onVisibilityChange(){if(document.hidden){return}this._connect()}_isConnected(){return this._socket&&this._socket.readyState===this._socket.OPEN}_isConnecting(){return this._socket&&this._socket.readyState===this._socket.CONNECTING}send(message){if(this._isConnected()){this._socket.send(JSON.stringify(message))}}}class Peer{constructor(serverConnection,peerId){this._server=serverConnection;this._peerId=peerId;this._filesQueue=[];this._busy=false}_dequeueFile(){if(this._filesQueue.length){this._busy=true;this._sendFile(this._filesQueue.shift())}}_sendFile(file){this.sendJSON({type:"header",name:file.name,mime:file.type,size:file.size,sender:document.getElementById("footer-name").getAttribute("self")});this._chunker=new FileChunker(file,chunk=>this._send(chunk),offset=>this._onPartitionEnd(offset));this._chunker.nextPartition()}_onPartitionEnd(offset){this.sendJSON({type:"partition",offset:offset})}_onReceivedPartitionEnd(offset){this.sendJSON({type:"partition-received",offset:offset})}_sendNextPartition(){if(this._chunker&&!this._chunker.isFileEnd()){this._chunker.nextPartition()}}_sendProgress(progress){this.sendJSON({type:"progress",progress:progress})}_onMessage(message){if(typeof message!=="string"){return this._onChunkReceived(message)}message=JSON.parse(message);console.info("[RTC]:",message);switch(message.type){case "header":return this._onFileHeader(message);case "partition":return this._onReceivedPartitionEnd(message);case "partition-received":return this._sendNextPartition();case "progress":return this._onDownloadProgress(message.progress);case "transfer-complete":return this._onTransferCompleted();case "text":return this._onTextReceived(message)}}_onFileHeader(header){this._lastProgress=0;this._digester=new FileDigester({name:header.name,mime:header.mime,size:header.size,sender:header.sender},file=>this._onFileReceived(file))}_onChunkReceived(chunk){this._digester.unchunk(chunk);const progress=this._digester.progress;this._onDownloadProgress(progress);if(progress-this._lastProgress<0.01){return}this._lastProgress=progress;this._sendProgress(progress)}_onDownloadProgress(progress){Events.fire("file-progress",{sender:this._peerId,progress:progress})}_onFileReceived(proxyFile){Events.fire("file-received",proxyFile);this.sendJSON({type:"transfer-complete"})}_onTransferCompleted(){this._onDownloadProgress(1);this._reader=null;this._busy=false;this._dequeueFile();Events.fire("toast-notification","File transfer completed.")}_onTextReceived(message){const escaped=decodeURIComponent(escape(atob(message.text)));Events.fire("text-received",{text:escaped,sender:this._peerId})}sendText(text){const unescaped=btoa(unescape(encodeURIComponent(text)));this.sendJSON({type:"text",text:unescaped})}sendJSON(message){this._send(JSON.stringify(message))}sendFiles(files){for(const file of files){this._filesQueue.push(file)}if(this._busy){return}this._dequeueFile()}}class RTCPeer extends Peer{constructor(serverConnection,peerId){super(serverConnection,peerId);if(peerId){this._connect(peerId,true)}}_connect(peerId,isCaller){if(!this._conn){this._openConnection(peerId,isCaller)}if(isCaller){this._openChannel()}else{this._conn.ondatachannel=e=>this._onChannelOpened(e)}}_openConnection(peerId,isCaller){this._isCaller=isCaller;this._peerId=peerId;this._conn=new RTCPeerConnection(RTCPeer.config);this._conn.onicecandidate=e=>this._onIceCandidate(e);this._conn.onconnectionstatechange=e=>this._onConnectionStateChange(e);this._conn.oniceconnectionstatechange=e=>this._onIceConnectionStateChange(e)}_openChannel(){const channel=this._conn.createDataChannel("data-channel",{reliable:true});channel.binaryType="arraybuffer";channel.onopen=e=>this._onChannelOpened(e);this._conn.createOffer().then(d=>this._onDescription(d)).catch(e=>this._onError(e))}_onDescription(description){this._conn.setLocalDescription(description).then(()=>this._sendSignal({sdp:description})).catch(e=>this._onError(e))}_onIceCandidate(event){if(event.candidate){this._sendSignal({ice:event.candidate})}}onServerMessage(message){if(!this._conn){this._connect(message.sender,false)}if(message.sdp){this._conn.setRemoteDescription(new RTCSessionDescription(message.sdp)).then(()=>{if(message.sdp.type==="offer"){return this._conn.createAnswer().then(d=>this._onDescription(d))}}).catch(e=>this._onError(e))}else if(message.ice){this._conn.addIceCandidate(new RTCIceCandidate(message.ice))}}_onChannelOpened(event){console.info("[RTC]: Data channel opened with Peer",this._peerId);const channel=event.channel||event.target;channel.onmessage=e=>this._onMessage(e.data);channel.onclose=e=>this._onChannelClosed();this._channel=channel}_onChannelClosed(){console.info("[RTC]: Data channel closed with Peer",this._peerId);if(this.isCaller){this._connect(this._peerId,true);}}_onConnectionStateChange(e){console.info("[RTC]: Connection state changed to",this._conn.connectionState);switch(this._conn.connectionState){case "disconnected":return this._onChannelClosed();case "failed":this._conn=null;return this._onChannelClosed()}}_onIceConnectionStateChange(e){switch(this._conn.iceConnectionState){case "failed":return console.error("[ICE]: Candidate failed to find compatible matches for all components of the connection.");default:console.info("[ICE]:",this._conn.iceConnectionState)}}_onError(error){console.error(error)}_send(message){if(this._channel){return this._channel.send(message)}this.refresh()}_sendSignal(signal){signal.type="signal";signal.to=this._peerId;this._server.send(signal)}refresh(){if(this._isConnected()||this._isConnecting()){return}this._connect(this._peerId,this._isCaller)}_isConnected(){return this._channel&&this._channel.readyState==="open"}_isConnecting(){return this._channel&&this._channel.readyState==="connecting"}}RTCPeer.config={iceServers:[{urls:["stun:stun.l.google.com:19302","stun:stun1.l.google.com:19302"]},{urls:"turn:numb.viagenie.ca",username:"[email protected]",credential:"muazkh"}]};class WSPeer extends Peer{_send(message){message.to=this._peerId;this._server.send(message)}}class PeersManager{constructor(serverConnection){this.peers={};this._server=serverConnection;Events.on("self",e=>this._onSelf(e.detail));Events.on("signal",e=>this._onMessage(e.detail));Events.on("peers",e=>this._onPeers(e.detail));Events.on("files-selected",e=>this._onFilesSelected(e.detail));Events.on("send-text",e=>this._onSendText(e.detail));Events.on("peer-left",e=>this._onPeerLeft(e.detail))}_onSelf(self){const element=document.getElementById("footer-name");element.setAttribute("self",self.id);element.innerHTML="Discoverable in the local network as <strong>"+self.name+"</strong>."}_onMessage(message){if(!this.peers[message.sender]){this.peers[message.sender]=new RTCPeer(this._server)}this.peers[message.sender].onServerMessage(message)}_onPeers(peers){peers.forEach(peer=>{if(this.peers[peer.id]){return this.peers[peer.id].refresh()}if(window.isRTCSupported&&peer.rtcSupported){this.peers[peer.id]=new RTCPeer(this._server,peer.id)}else{this.peers[peer.id]=new WSPeer(this._server,peer.id)}})}sendTo(peerId,message){this.peers[peerId].send(message)}_onFilesSelected(message){this.peers[message.to].sendFiles(message.files)}_onSendText(message){this.peers[message.to].sendText(message.text)}_onPeerLeft(peer){const p=this.peers[peer.id];delete this.peers[peer.id];if(!p||!p._peer){return}p._peer.close()}}class FileChunker{constructor(file,onChunk,onPartitionEnd){this._chunkSize=65536;this._maxPartitionSize=1048576;this._offset=0;this._partitionSize=0;this._file=file;this._onChunk=onChunk;this._onPartitionEnd=onPartitionEnd;this._reader=new FileReader();this._reader.addEventListener("load",e=>this._onChunkRead(e.target.result))}nextPartition(){this._partitionSize=0;this._readChunk()}_readChunk(){const chunk=this._file.slice(this._offset,this._offset+this._chunkSize);this._reader.readAsArrayBuffer(chunk)}_onChunkRead(chunk){this._offset+=chunk.byteLength;this._partitionSize+=chunk.byteLength;this._onChunk(chunk);if(this._isPartitionEnd()||this.isFileEnd()){this._onPartitionEnd(this._offset);return}this._readChunk()}repeatPartition(){this._offset-=this._partitionSize;this._nextPartition()}_isPartitionEnd(){return this._partitionSize>=this._maxPartitionSize}isFileEnd(){return this._offset>this._file.size}get progress(){return this._offset/this._file.size}}class FileDigester{constructor(meta,callback){this._buffer=[];this._bytesReceived=0;this._size=meta.size;this._mime=meta.mime||"application/octet-stream";this._name=meta.name;this._sender=meta.sender;this._callback=callback}unchunk(chunk){this._buffer.push(chunk);this._bytesReceived+=chunk.byteLength||chunk.size;this.progress=this._bytesReceived/this._size;if(this._bytesReceived<this._size){return}let blob=new Blob(this._buffer,{type:this._mime});this._callback({name:this._name,mime:this._mime,size:this._size,sender:this._sender,blob:blob})}}class Events{static fire(type,detail){window.dispatchEvent(new CustomEvent(type,{detail:detail}))}static on(type,callback){return window.addEventListener(type,callback,false)}}class PeersComponent{constructor(){Events.on("peer-joined",e=>this._handlePeerJoin(e.detail));Events.on("peer-left",e=>this._handlePeerLeave(e.detail));Events.on("peers",e=>this._handlePeers(e.detail));Events.on("file-progress",e=>this._handleFileProgress(e.detail))}_clearPeers(){return document.getElementById("peers").innerHTML=""}_handlePeerJoin(peer){if(document.getElementById(peer.id)){return}const peerComponent=new PeerComponent(peer);document.getElementById("peers").appendChild(peerComponent.element)}_handlePeers(peers){this._clearPeers();peers.forEach(peer=>this._handlePeerJoin(peer))}_handlePeerLeave(peer){const peerElement=document.getElementById(peer.id);if(peerElement){peerElement.remove()}}_handleFileProgress(progress){const peerElement=document.getElementById(progress.sender||progress.recipient);if(peerElement){peerElement.component.setProgress(progress.progress)}}}class PeerComponent{constructor(peer){this._peer=peer;const peerElement=document.createElement("peer");peerElement.id=this._peer.id;peerElement.innerHTML=`<label>
<input type="file" multiple>
<icon>
<ion-icon name="" style="width: 28px; height: 28px;"></ion-icon>
</icon>
<div class="progress"></div>
<div class="name"></div>
<div class="status"></div>
</label>`;peerElement.querySelector(".name").textContent=this._getName();peerElement.querySelector("ion-icon").setAttribute("name",this._getIcon());peerElement.component=this;this.element=peerElement;this.progressElement=peerElement.querySelector(".progress");peerElement.querySelector("input").addEventListener("change",e=>this._fileSelectHandler(e));peerElement.addEventListener("contextmenu",e=>this._handleContextMenu(e));peerElement.addEventListener("drop",e=>this._handleDrop(e));peerElement.addEventListener("dragend",e=>this._handleDragEnd(e));peerElement.addEventListener("dragleave",e=>this._handleDragEnd(e));peerElement.addEventListener("dragover",e=>this._handleDragOver(e));peerElement.addEventListener("touchstart",e=>this._handleTouchStart(e));peerElement.addEventListener("touchend",e=>this._handleTouchEnd(e));Events.on("dragover",e=>e.preventDefault());Events.on("drop",e=>e.preventDefault())}_getName(){return this._peer.name}_getIcon(){const device=this._peer.ua.platform.type||this._peer.ua.platform;if(device.type==="mobile"){return "phone-portrait-outline"}if(device.type==="tablet"){return "tablet-portrait-outline"}return "laptop-outline"}_fileSelectHandler(e){const inputElement=e.target;Events.fire("files-selected",{files:inputElement.files,to:this._peer.id});inputElement.value=null;this.setProgress(0.01)}_handleContextMenu(e){e.preventDefault();Events.fire("text-recipient",this._peer)}_handleDrop(e){e.preventDefault();Events.fire("files-selected",{files:e.dataTransfer.files,to:this._peer.id});this._handleDragEnd()}_handleDragOver(){this.element.setAttribute("drop",1)}_handleDragEnd(){this.element.removeAttribute("drop")}_handleTouchStart(_){this._touchStartedAt=Date.now();this._touchTimer=setTimeout(_=>this._handleTouchEnd(),610)}_handleTouchEnd(e){if(Date.now()-this._touchStartedAt<500){clearTimeout(this._touchTimer)}else{if(e){e.preventDefault()}Events.fire("text-recipient",this._peer)}}setProgress(progress){this.progressElement.style.setProperty("--progress",`scale(${progress*.8})`);if(progress>=1){this.setProgress(0);this.element.removeAttribute("transfer")}}}class Dialog{constructor(id){this.element=document.getElementById(id);this.element.querySelectorAll("[close]").forEach(e=>e.addEventListener("click",()=>this.hide()));this.autoFocus=this.element.querySelector("[autofocus]")}show(){this.element.setAttribute("show",1);if(this.autoFocus){this.autoFocus.focus()}}hide(){this.element.removeAttribute("show");document.activeElement.blur();window.blur()}}class Toast extends Dialog{constructor(){super("toast");Events.on("toast-notification",e=>this._handleNotification(e.detail))}_handleNotification(message){this.element.textContent=message;this.show();setTimeout(_=>this.hide(),5000)}}class SendTextDialog extends Dialog{constructor(){super("send-text");Events.on("text-recipient",e=>this._handleReceiver(e.detail));this.messageElement=this.element.querySelector(".message");this.element.querySelector("#send").addEventListener("click",e=>this._handleSend(e))}_handleReceiver(recipient){this._recipient=recipient;this.element.querySelector("#receiver").textContent="to "+this._recipient.name;this.show();this.messageElement.setSelectionRange(0,this.messageElement.value.length)}_handleSend(e){e.preventDefault();Events.fire("send-text",{to:this._recipient.id,text:this.messageElement.value});this.messageElement.value=""}}class ReceiveTextDialog extends Dialog{constructor(){super("receive-text");Events.on("text-received",e=>this._handleMessage(e.detail));this.messageElement=this.element.querySelector(".message");this.element.querySelector("#copy").addEventListener("click",_=>this._handleCopy())}_handleMessage(e){this.messageElement.innerHTML="";this.element.querySelector("#sender").textContent="from "+document.getElementById(e.sender).querySelector(".name").innerText;if(/^((https?:\/\/)[^\s]+)/ig.test(e.text)){const anchorElement=document.createElement("a");anchorElement.href=e.text;anchorElement.rel="noopener";anchorElement.target="_blank";anchorElement.textContent=e.text;this.messageElement.appendChild(anchorElement)}else{this.messageElement.textContent=e.text}this.show();window.chord.play()}_handleCopy(){if(this._copy(this.messageElement.textContent)){Events.fire("toast-notification","Copied to clipboard!")}}_copy(text){const span=document.createElement("span");span.textContent=text;span.style.whiteSpace="pre";span.style.position="absolute";span.style.left="-9999px";span.style.top="-9999px";const selection=window.getSelection();document.body.appendChild(span);const range=document.createRange();selection.removeAllRanges();range.selectNode(span);selection.addRange(range);let success=false;try{success=document.execCommand("copy")}catch(err){}selection.removeAllRanges();span.remove();return success}}class ReceiveFileDialog extends Dialog{constructor(){super("receive-file");Events.on("file-received",e=>{this._nextFile(e.detail);window.chord.play()});this._filesQueue=[]}_nextFile(nextFile){if(nextFile){this._filesQueue.push(nextFile);this.element.querySelector("#sender").textContent="from "+document.getElementById(nextFile.sender).querySelector(".name").innerText}if(this._busy){return}this._busy=true;this._displayFile(this._filesQueue.shift())}_dequeueFile(){if(this._filesQueue.length){setTimeout(_=>{this._busy=false;this._nextFile()},300)}else{this._busy=false}}_displayFile(file){const anchorElement=this.element.querySelector("#download");const url=(window.URL||window.webkitURL).createObjectURL(file.blob);anchorElement.href=url;anchorElement.download=file.name;this.element.querySelector(".filename").textContent=file.name;this.element.querySelector(".filesize").textContent=this._humanizeFileSize(file.size);this.show();if((typeof document.createElement("a").download==="undefined")){anchorElement.target="_blank";const reader=new FileReader();reader.onload=e=>anchorElement.href=reader.result;reader.readAsDataURL(file.blob)}}_humanizeFileSize(bytes,decimals=1){if(bytes===0){return "0 Bytes"}const k=1024;const dm=decimals<0?0:decimals;const sizes=["Bytes","KB","MB","GB","TB","PB","EB","ZB","YB"];const i=Math.floor(Math.log(bytes)/Math.log(k));return parseFloat((bytes/Math.pow(k,i)).toFixed(dm))+" "+sizes[i]}hide(){super.hide();this._dequeueFile()}}class NetworkStatus{constructor(){window.addEventListener("offline",()=>this._handleOffline(),false);window.addEventListener("online",()=>this._handleOnline(),false);if(!navigator.onLine){this._handleOffline()}}_handleOffline(){Events.fire("toast-notification","You are not connected to the Internet.")}_handleOnline(){Events.fire("toast-notification","You are now connected to the Internet.")}}class Drop{constructor(){const server=new ServerConnection();new PeersManager(server);new PeersComponent();Events.on("load",()=>{new SendTextDialog();new ReceiveTextDialog();new ReceiveFileDialog();new Toast();new NetworkStatus()})}}new Drop();